package world

import (
	"github.com/stretchr/testify/require"
	"image/color"
	"testing"
)

// TestHitTest_ReturnsBestByPriorityAndAllHits verifies hit Test Returns Best By Priority And All Hits.
func TestHitTest_ReturnsBestByPriorityAndAllHits(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)

	// Build index once renderer state is initialized.
	params := RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 100,
		MarginXPx:        0,
		MarginYPx:        0,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
	}
	w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)

	// Add overlapping objects near center.
	idLine, err := w.AddLine(4.5, 5.0, 5.5, 5.0, LineWithPriority(100))
	require.NoError(t, err)

	idCircle, err := w.AddCircle(5.0, 5.0, 1.0, CircleWithPriority(300))
	require.NoError(t, err)

	idPoint, err := w.AddPoint(5.0, 5.0, PointWithPriority(200))
	require.NoError(t, err)

	// Force index rebuild from last state (Add already does it, but keep explicit).
	w.Reindex()

	buf := make([]Hit, 0, 8)
	hits, err := w.HitTest(buf, &params, 50, 50) // center of viewport
	require.NoError(t, err)

	// Should find all three, best first (priority desc).
	require.Len(t, hits, 3)
	require.Equal(t, idCircle, hits[0].ID)
	require.Equal(t, idPoint, hits[1].ID)
	require.Equal(t, idLine, hits[2].ID)
}

// TestHitTest_BufferTooSmall_KeepsBestHits verifies hit Test Buffer Too Small Keeps Best Hits.
func TestHitTest_BufferTooSmall_KeepsBestHits(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)

	params := RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 100,
		MarginXPx:        0,
		MarginYPx:        0,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
	}
	w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)

	_, _ = w.AddLine(4.5, 5.0, 5.5, 5.0, LineWithPriority(100))
	idCircle, _ := w.AddCircle(5.0, 5.0, 1.0, CircleWithPriority(300))
	_, _ = w.AddPoint(5.0, 5.0, PointWithPriority(200))
	w.Reindex()

	// Only room for 1 hit => must keep the best (highest priority).
	buf := make([]Hit, 0, 1)
	hits, err := w.HitTest(buf, &params, 50, 50)
	require.NoError(t, err)
	require.Len(t, hits, 1)
	require.Equal(t, idCircle, hits[0].ID)
}

// TestHitTest_NoWrap_ClampsCameraAndStillHits verifies hit Test No Wrap Clamps Camera And Still Hits.
func TestHitTest_NoWrap_ClampsCameraAndStillHits(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)

	params := RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 100,
		MarginXPx:        25,
		MarginYPx:        25,
		CameraXWorldFp:   -100000, // invalid camera, should be clamped
		CameraYWorldFp:   -100000,
		CameraZoom:       1.0,
		Options:          &RenderOptions{DisableWrapScroll: true},
	}
	w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)

	_, err := w.AddPoint(0.0, 0.0, PointWithPriority(100))
	require.NoError(t, err)
	w.Reindex()

	// Tap near top-left of viewport should still map to world and find the point.
	buf := make([]Hit, 0, 8)
	hits, err := w.HitTest(buf, &params, 0, 0)
	require.NoError(t, err)
	require.NotEmpty(t, hits)
}

// TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter verifies hit Test Circle Stroke Only Hits Near Ring Not Center.
func TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)

	params := RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 100,
		MarginXPx:        0,
		MarginYPx:        0,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
	}
	w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)

	// Stroke-only circle: FillColor alpha=0 => ring mode.
	ov := StyleOverride{
		FillColor:   color.RGBA{A: 0},
		StrokeColor: color.RGBA{A: 255},
	}
	strokeStyle := w.AddStyleCircle(ov)

	_, err := w.AddCircle(5.0, 5.0, 2.0,
		CircleWithStyleID(strokeStyle),
		CircleWithPriority(100),
	)
	require.NoError(t, err)

	w.Reindex()

	buf := make([]Hit, 0, 8)

	// Center must NOT hit.
	hits, err := w.HitTest(buf, &params, 50, 50)
	require.NoError(t, err)
	require.Empty(t, hits)

	// Near ring should hit. For small circles we use a minimum visible ring radius (3px).
	// So tapping at +3px from center should be within ring+slop.
	hits, err = w.HitTest(buf, &params, 50+3, 50)
	require.NoError(t, err)
	require.NotEmpty(t, hits)
	require.Equal(t, KindCircle, hits[0].Kind)
}

// TestHitTest_CircleRadiusScale_AffectsHitArea verifies hit Test Circle Radius Scale Affects Hit Area.
func TestHitTest_CircleRadiusScale_AffectsHitArea(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.SetTheme(DefaultTheme{}) // filled circles by default in our defaults
	w.IndexOnViewportChange(100, 100, 1.0)

	// raw radius=2 units, centered at (5,5)
	_, err := w.AddCircle(5, 5, 2)
	require.NoError(t, err)

	// scale=2 => eff radius=4
	require.NoError(t, w.SetCircleRadiusScaleFp(2*SCALE))
	w.Reindex()

	params := RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 100,
		MarginXPx:        0,
		MarginYPx:        0,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
	}

	// Tap at +4 px from center should hit (eff radius 4).
	buf := make([]Hit, 0, 8)
	hits, err := w.HitTest(buf, &params, 50+4, 50)
	require.NoError(t, err)
	require.NotEmpty(t, hits)
	require.Equal(t, KindCircle, hits[0].Kind)

	// Tap at +5 should typically miss (depending on slop); enforce by setting small slop via options.
	// We'll add a small-slope circle and test deterministically.
}

// TestHitTest_Circle_StrictThresholds_WithRadiusScale_Table verifies hit Test Circle Strict Thresholds With Radius Scale Table.
func TestHitTest_Circle_StrictThresholds_WithRadiusScale_Table(t *testing.T) {
	t.Parallel()

	type tc struct {
		name        string
		fillVisible bool
		rawRadius   int // world units (not fixed); zoom=1 => 1px per unit
		scaleFp     int
		hitSlopPx   int
		cursorDxPx  int // offset from center in pixels along X axis
		wantHit     bool
		wantKind    PrimitiveKind
	}

	// Common settings: world 20x20, viewport 200x200, camera at center (10,10).
	params := RenderParams{
		ViewportWidthPx:  200,
		ViewportHeightPx: 200,
		MarginXPx:        0,
		MarginYPx:        0,
		CameraXWorldFp:   10 * SCALE,
		CameraYWorldFp:   10 * SCALE,
		CameraZoom:       1.0,
	}

	tests := []tc{
		{
			name:        "filled: on boundary hits (R=4, S=1, dx=4)",
			fillVisible: true,
			rawRadius:   2,
			scaleFp:     2 * SCALE, // eff radius = 4
			hitSlopPx:   1,
			cursorDxPx:  4,
			wantHit:     true,
			wantKind:    KindCircle,
		},
		{
			name:        "filled: outside beyond slop misses (R=4, S=1, dx=6)",
			fillVisible: true,
			rawRadius:   2,
			scaleFp:     2 * SCALE,
			hitSlopPx:   1,
			cursorDxPx:  6, // 6 > R+S = 5
			wantHit:     false,
		},
		{
			name:        "filled: just inside slop hits (R=4, S=1, dx=5)",
			fillVisible: true,
			rawRadius:   2,
			scaleFp:     2 * SCALE,
			hitSlopPx:   1,
			cursorDxPx:  5, // == R+S
			wantHit:     true,
			wantKind:    KindCircle,
		},
		{
			name:        "stroke-only: center must miss even if slop would cover",
			fillVisible: false,
			rawRadius:   2,
			scaleFp:     2 * SCALE, // eff radius = 4
			hitSlopPx:   10,        // huge, would normally include center without our rule
			cursorDxPx:  0,
			wantHit:     false,
		},
		{
			name:        "stroke-only: on ring hits (R=4, S=1, dx=4)",
			fillVisible: false,
			rawRadius:   2,
			scaleFp:     2 * SCALE,
			hitSlopPx:   1,
			cursorDxPx:  4,
			wantHit:     true,
			wantKind:    KindCircle,
		},
		{
			name:        "stroke-only: inside ring beyond slop misses (R=4, S=1, dx=2)",
			fillVisible: false,
			rawRadius:   2,
			scaleFp:     2 * SCALE,
			hitSlopPx:   1,
			cursorDxPx:  2, // 2 < R-S = 3
			wantHit:     false,
		},
		{
			name:        "stroke-only: outside ring beyond slop misses (R=4, S=1, dx=6)",
			fillVisible: false,
			rawRadius:   2,
			scaleFp:     2 * SCALE,
			hitSlopPx:   1,
			cursorDxPx:  6, // 6 > R+S = 5
			wantHit:     false,
		},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			w := NewWorld(20, 20)
			w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)

			require.NoError(t, w.SetCircleRadiusScaleFp(tt.scaleFp))

			// Build a stroke-only circle style if needed.
			var opts []CircleOpt
			opts = append(opts, CircleWithHitSlopPx(tt.hitSlopPx))

			if !tt.fillVisible {
				// Force fill alpha=0 => stroke-only for hit-test and rendering.
				sw := 1.0
				styleID := w.AddStyleCircle(StyleOverride{
					FillColor:     color.RGBA{A: 0},
					StrokeColor:   color.RGBA{A: 255},
					StrokeWidthPx: &sw,
				})
				opts = append(opts, CircleWithStyleID(styleID))
			}

			_, err := w.AddCircle(10, 10, float64(tt.rawRadius), opts...)
			require.NoError(t, err)

			w.Reindex()

			// Cursor at viewport center +/- dx along X. At zoom=1, 1px == 1 world unit.
			cx := params.ViewportWidthPx/2 + tt.cursorDxPx
			cy := params.ViewportHeightPx / 2

			buf := make([]Hit, 0, 8)
			hits, err := w.HitTest(buf, &params, cx, cy)
			require.NoError(t, err)

			if !tt.wantHit {
				require.Empty(t, hits)
				return
			}

			require.NotEmpty(t, hits)
			require.Equal(t, tt.wantKind, hits[0].Kind)
		})
	}
}
