package world

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

// TestRenderParamsCanvasSize verifies render Params Canvas Size.
func TestRenderParamsCanvasSize(t *testing.T) {
	t.Parallel()

	params := RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 80,
		MarginXPx:        25,
		MarginYPx:        20,
	}

	require.Equal(t, 150, params.CanvasWidthPx())
	require.Equal(t, 120, params.CanvasHeightPx())
}

// TestRenderParamsCameraZoomFp verifies render Params Camera Zoom Fp.
func TestRenderParamsCameraZoomFp(t *testing.T) {
	t.Parallel()

	params := RenderParams{
		CameraZoom: 1.25,
	}

	zoomFp, err := params.CameraZoomFp()
	require.NoError(t, err)
	require.Equal(t, 1250, zoomFp)
}

// TestRenderParamsExpandedCanvasWorldRect verifies render Params Expanded Canvas World Rect.
func TestRenderParamsExpandedCanvasWorldRect(t *testing.T) {
	t.Parallel()

	params := RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 80,
		MarginXPx:        25,
		MarginYPx:        20,
		CameraXWorldFp:   50 * SCALE,
		CameraYWorldFp:   70 * SCALE,
		CameraZoom:       2.0,
	}

	rect, err := params.ExpandedCanvasWorldRect()
	require.NoError(t, err)

	require.Equal(t, 12500, rect.minX)
	require.Equal(t, 87500, rect.maxX)
	require.Equal(t, 40000, rect.minY)
	require.Equal(t, 100000, rect.maxY)
}

// TestRenderParamsExpandedCanvasWorldRectAllowsOutOfWorldCamera verifies render Params Expanded Canvas World Rect Allows Out Of World Camera.
func TestRenderParamsExpandedCanvasWorldRectAllowsOutOfWorldCamera(t *testing.T) {
	t.Parallel()

	params := RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 80,
		MarginXPx:        25,
		MarginYPx:        20,
		CameraXWorldFp:   -10 * SCALE,
		CameraYWorldFp:   3 * SCALE,
		CameraZoom:       1.0,
	}

	rect, err := params.ExpandedCanvasWorldRect()
	require.NoError(t, err)

	require.Equal(t, -85000, rect.minX)
	require.Equal(t, 65000, rect.maxX)
	require.Equal(t, -57000, rect.minY)
	require.Equal(t, 63000, rect.maxY)
}

// TestRenderParamsValidate verifies render Params Validate.
func TestRenderParamsValidate(t *testing.T) {
	t.Parallel()

	params := RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 80,
		MarginXPx:        25,
		MarginYPx:        20,
		CameraXWorldFp:   123456,
		CameraYWorldFp:   -987654,
		CameraZoom:       1.0,
	}

	require.NoError(t, params.Validate())
}

// TestRenderParamsValidateRejectsInvalidViewport verifies render Params Validate Rejects Invalid Viewport.
func TestRenderParamsValidateRejectsInvalidViewport(t *testing.T) {
	t.Parallel()

	tests := []RenderParams{
		{
			ViewportWidthPx:  0,
			ViewportHeightPx: 80,
			MarginXPx:        25,
			MarginYPx:        20,
			CameraZoom:       1.0,
		},
		{
			ViewportWidthPx:  100,
			ViewportHeightPx: 0,
			MarginXPx:        25,
			MarginYPx:        20,
			CameraZoom:       1.0,
		},
		{
			ViewportWidthPx:  -1,
			ViewportHeightPx: 80,
			MarginXPx:        25,
			MarginYPx:        20,
			CameraZoom:       1.0,
		},
		{
			ViewportWidthPx:  100,
			ViewportHeightPx: -1,
			MarginXPx:        25,
			MarginYPx:        20,
			CameraZoom:       1.0,
		},
	}

	for _, params := range tests {
		require.ErrorIs(t, params.Validate(), errInvalidViewportSize)
	}
}

// TestRenderParamsValidateRejectsInvalidMargins verifies render Params Validate Rejects Invalid Margins.
func TestRenderParamsValidateRejectsInvalidMargins(t *testing.T) {
	t.Parallel()

	tests := []RenderParams{
		{
			ViewportWidthPx:  100,
			ViewportHeightPx: 80,
			MarginXPx:        -1,
			MarginYPx:        20,
			CameraZoom:       1.0,
		},
		{
			ViewportWidthPx:  100,
			ViewportHeightPx: 80,
			MarginXPx:        25,
			MarginYPx:        -1,
			CameraZoom:       1.0,
		},
	}

	for _, params := range tests {
		require.ErrorIs(t, params.Validate(), errInvalidMargins)
	}
}

// TestRenderParamsValidateRejectsInvalidCameraZoom verifies render Params Validate Rejects Invalid Camera Zoom.
func TestRenderParamsValidateRejectsInvalidCameraZoom(t *testing.T) {
	t.Parallel()

	tests := []RenderParams{
		{
			ViewportWidthPx:  100,
			ViewportHeightPx: 80,
			MarginXPx:        25,
			MarginYPx:        20,
			CameraZoom:       0,
		},
		{
			ViewportWidthPx:  100,
			ViewportHeightPx: 80,
			MarginXPx:        25,
			MarginYPx:        20,
			CameraZoom:       -1,
		},
		{
			ViewportWidthPx:  100,
			ViewportHeightPx: 80,
			MarginXPx:        25,
			MarginYPx:        20,
			CameraZoom:       0.0004,
		},
	}

	for _, params := range tests {
		require.EqualError(t, params.Validate(), "invalid camera zoom")
	}
}

// TestExpandedCanvasWorldRect verifies expanded Canvas World Rect.
func TestExpandedCanvasWorldRect(t *testing.T) {
	t.Parallel()

	rect := expandedCanvasWorldRect(
		50*SCALE, 70*SCALE,
		150, 120,
		2*SCALE,
	)

	require.Equal(t, 12500, rect.minX)
	require.Equal(t, 87500, rect.maxX)
	require.Equal(t, 40000, rect.minY)
	require.Equal(t, 100000, rect.maxY)
}

// TestExpandedCanvasWorldRectPanics verifies expanded Canvas World Rect Panics.
func TestExpandedCanvasWorldRectPanics(t *testing.T) {
	t.Parallel()

	require.Panics(t, func() {
		_ = expandedCanvasWorldRect(0, 0, 0, 10, SCALE)
	})

	require.Panics(t, func() {
		_ = expandedCanvasWorldRect(0, 0, 10, 0, SCALE)
	})

	require.Panics(t, func() {
		_ = expandedCanvasWorldRect(0, 0, 10, 10, 0)
	})
}

// TestWorldRenderRejectsNilDrawer verifies world Render Rejects Nil Drawer.
func TestWorldRenderRejectsNilDrawer(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)

	err := w.Render(nil, RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 80,
		MarginXPx:        25,
		MarginYPx:        20,
		CameraZoom:       1.0,
	})

	require.ErrorIs(t, err, errNilDrawer)
}

// TestWorldRenderRejectsInvalidParams verifies world Render Rejects Invalid Params.
func TestWorldRenderRejectsInvalidParams(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)

	err := w.Render(&fakePrimitiveDrawer{}, RenderParams{
		ViewportWidthPx:  0,
		ViewportHeightPx: 80,
		MarginXPx:        25,
		MarginYPx:        20,
		CameraZoom:       1.0,
	})

	require.ErrorIs(t, err, errInvalidViewportSize)
}

// TestWorldRenderReturnsErrorWhenGridNotBuilt verifies world Render Returns Error When Grid Not Built.
func TestWorldRenderReturnsErrorWhenGridNotBuilt(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)

	err := w.Render(&fakePrimitiveDrawer{}, RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 80,
		MarginXPx:        25,
		MarginYPx:        20,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
	})

	require.ErrorIs(t, err, errGridNotBuilt)
}

// TestWorldRenderStageAStubReturnsNilOnValidInput verifies world Render Stage A Stub Returns Nil On Valid Input.
func TestWorldRenderStageAStubReturnsNilOnValidInput(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)

	// Render relies on the spatial grid being built.
	// In production UI this is done via IndexOnViewportChange.
	w.IndexOnViewportChange(100, 80, 1.0)

	err := w.Render(&fakePrimitiveDrawer{}, RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 80,
		MarginXPx:        25,
		MarginYPx:        20,
		CameraXWorldFp:   12345,
		CameraYWorldFp:   -67890,
		CameraZoom:       1.0,
	})

	require.NoError(t, err)
}

// TestTileWorldRect_NoWrapSingleTile verifies tile World Rect No Wrap Single Tile.
func TestTileWorldRect_NoWrapSingleTile(t *testing.T) {
	t.Parallel()

	worldW := 100
	worldH := 80

	rect := Rect{minX: 10, maxX: 30, minY: 5, maxY: 25}
	tiles := tileWorldRect(rect, worldW, worldH)

	require.Len(t, tiles, 1)
	require.Equal(t, 0, tiles[0].OffsetX)
	require.Equal(t, 0, tiles[0].OffsetY)

	require.Equal(t, 10, tiles[0].Rect.minX)
	require.Equal(t, 30, tiles[0].Rect.maxX)
	require.Equal(t, 5, tiles[0].Rect.minY)
	require.Equal(t, 25, tiles[0].Rect.maxY)
}

// TestTileWorldRect_WrapX_TwoTiles verifies tile World Rect Wrap X Two Tiles.
func TestTileWorldRect_WrapX_TwoTiles(t *testing.T) {
	t.Parallel()

	worldW := 100
	worldH := 80

	// Crosses the X boundary once: [30..130) maps to:
	// tile 0: [30..100) offset 0
	// tile 1: [0..30)   offset 100
	rect := Rect{minX: 30, maxX: 130, minY: 10, maxY: 20}
	tiles := tileWorldRect(rect, worldW, worldH)

	require.Len(t, tiles, 2)

	require.Equal(t, 0, tiles[0].OffsetX)
	require.Equal(t, 0, tiles[0].OffsetY)
	require.Equal(t, Rect{minX: 30, maxX: 100, minY: 10, maxY: 20}, tiles[0].Rect)

	require.Equal(t, 100, tiles[1].OffsetX)
	require.Equal(t, 0, tiles[1].OffsetY)
	require.Equal(t, Rect{minX: 0, maxX: 30, minY: 10, maxY: 20}, tiles[1].Rect)
}

// TestTileWorldRect_WrapX_NegativeCoords verifies tile World Rect Wrap X Negative Coords.
func TestTileWorldRect_WrapX_NegativeCoords(t *testing.T) {
	t.Parallel()

	worldW := 100
	worldH := 80

	// Crosses boundary around 0: [-20..20) maps to:
	// tile -1: [80..100) offset -100
	// tile  0: [0..20)   offset 0
	rect := Rect{minX: -20, maxX: 20, minY: 10, maxY: 20}
	tiles := tileWorldRect(rect, worldW, worldH)

	require.Len(t, tiles, 2)

	require.Equal(t, -100, tiles[0].OffsetX)
	require.Equal(t, 0, tiles[0].OffsetY)
	require.Equal(t, Rect{minX: 80, maxX: 100, minY: 10, maxY: 20}, tiles[0].Rect)

	require.Equal(t, 0, tiles[1].OffsetX)
	require.Equal(t, 0, tiles[1].OffsetY)
	require.Equal(t, Rect{minX: 0, maxX: 20, minY: 10, maxY: 20}, tiles[1].Rect)
}

// TestTileWorldRect_WrapXY_FourTiles verifies tile World Rect Wrap XY Four Tiles.
func TestTileWorldRect_WrapXY_FourTiles(t *testing.T) {
	t.Parallel()

	worldW := 100
	worldH := 80

	// Crosses both X and Y boundaries once.
	// X: [30..130) => tile 0 [30..100), tile 1 [0..30)
	// Y: [60..100) => tile 0 [60..80),  tile 1 [0..20)
	rect := Rect{minX: 30, maxX: 130, minY: 60, maxY: 100}
	tiles := tileWorldRect(rect, worldW, worldH)

	require.Len(t, tiles, 4)

	// Order: tx ascending, then ty ascending.
	require.Equal(t, 0, tiles[0].OffsetX)
	require.Equal(t, 0, tiles[0].OffsetY)
	require.Equal(t, Rect{minX: 30, maxX: 100, minY: 60, maxY: 80}, tiles[0].Rect)

	require.Equal(t, 0, tiles[1].OffsetX)
	require.Equal(t, 80, tiles[1].OffsetY)
	require.Equal(t, Rect{minX: 30, maxX: 100, minY: 0, maxY: 20}, tiles[1].Rect)

	require.Equal(t, 100, tiles[2].OffsetX)
	require.Equal(t, 0, tiles[2].OffsetY)
	require.Equal(t, Rect{minX: 0, maxX: 30, minY: 60, maxY: 80}, tiles[2].Rect)

	require.Equal(t, 100, tiles[3].OffsetX)
	require.Equal(t, 80, tiles[3].OffsetY)
	require.Equal(t, Rect{minX: 0, maxX: 30, minY: 0, maxY: 20}, tiles[3].Rect)
}

// TestTileWorldRect_EmptyRectReturnsNil verifies tile World Rect Empty Rect Returns Nil.
func TestTileWorldRect_EmptyRectReturnsNil(t *testing.T) {
	t.Parallel()

	worldW := 100
	worldH := 80

	require.Nil(t, tileWorldRect(Rect{minX: 0, maxX: 0, minY: 0, maxY: 10}, worldW, worldH))
	require.Nil(t, tileWorldRect(Rect{minX: 0, maxX: 10, minY: 0, maxY: 0}, worldW, worldH))
	require.Nil(t, tileWorldRect(Rect{minX: 10, maxX: 0, minY: 0, maxY: 10}, worldW, worldH))
}

// TestTileWorldRectPanicsOnInvalidWorldSize verifies tile World Rect Panics On Invalid World Size.
func TestTileWorldRectPanicsOnInvalidWorldSize(t *testing.T) {
	t.Parallel()

	require.Panics(t, func() { _ = tileWorldRect(Rect{minX: 0, maxX: 1, minY: 0, maxY: 1}, 0, 10) })
	require.Panics(t, func() { _ = tileWorldRect(Rect{minX: 0, maxX: 1, minY: 0, maxY: 1}, 10, 0) })
}

// TestCollectCandidatesForTilesReturnsErrorWhenGridNotBuilt verifies collect Candidates For Tiles Returns Error When Grid Not Built.
func TestCollectCandidatesForTilesReturnsErrorWhenGridNotBuilt(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)

	tiles := []WorldTile{
		{
			Rect: Rect{minX: 0, maxX: w.W, minY: 0, maxY: w.H},
		},
	}

	_, err := w.collectCandidatesForTiles(tiles)
	require.ErrorIs(t, err, errGridNotBuilt)
}

// TestCollectCandidatesForTileDedupsWithinOneTile verifies collect Candidates For Tile Dedups Within One Tile.
func TestCollectCandidatesForTileDedupsWithinOneTile(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE) // 5x5 grid

	// Circle in the middle, radius big enough to cover multiple cells.
	id, err := w.AddCircle(5, 5, 2.2)
	require.NoError(t, err)

	// Build index.
	w.indexObject(w.objects[id])

	// Query whole world tile.
	items := w.collectCandidatesForTile(Rect{minX: 0, maxX: w.W, minY: 0, maxY: w.H})

	// The circle is indexed into multiple cells, but must appear only once in candidates.
	require.Len(t, items, 1)
	require.Equal(t, id, items[0].ID())
}

// TestCollectCandidatesForTileReturnsPointInCoveredCell verifies collect Candidates For Tile Returns Point In Covered Cell.
func TestCollectCandidatesForTileReturnsPointInCoveredCell(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE) // cells are [0..2), [2..4), ...

	id, err := w.AddPoint(1.0, 1.0) // (1000,1000) => cell (0,0)
	require.NoError(t, err)

	w.indexObject(w.objects[id])

	// Query exactly the first cell as half-open rect.
	r := Rect{minX: 0, maxX: 2 * SCALE, minY: 0, maxY: 2 * SCALE}
	items := w.collectCandidatesForTile(r)

	require.Len(t, items, 1)
	require.Equal(t, id, items[0].ID())

	// Query adjacent cell (should not contain the point).
	r2 := Rect{minX: 2 * SCALE, maxX: 4 * SCALE, minY: 0, maxY: 2 * SCALE}
	items2 := w.collectCandidatesForTile(r2)
	require.Empty(t, items2)
}

// TestCollectCandidatesForTilesWrapIndexedCircleAppearsInBothSides verifies collect Candidates For Tiles Wrap Indexed Circle Appears In Both Sides.
func TestCollectCandidatesForTilesWrapIndexedCircleAppearsInBothSides(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.SetCircleRadiusScaleFp(SCALE)
	w.resetGrid(2 * SCALE)

	// Circle near the left edge crossing X=0 boundary.
	// With radius 1.0 it covers [-0.5..1.5] in world units => wrap indexes both left and right sides.
	id, err := w.AddCircle(0.5, 5.0, 1.0)
	require.NoError(t, err)
	w.indexObject(w.objects[id])

	leftStrip := WorldTile{
		Rect: Rect{minX: 0, maxX: 2 * SCALE, minY: 0, maxY: w.H},
	}
	rightStrip := WorldTile{
		Rect: Rect{minX: w.W - 2*SCALE, maxX: w.W, minY: 0, maxY: w.H},
	}

	batches, err := w.collectCandidatesForTiles([]WorldTile{leftStrip, rightStrip})
	require.NoError(t, err)
	require.Len(t, batches, 2)

	// Expect the circle candidate to appear in both batches (different render offsets later).
	require.Len(t, batches[0].Items, 1)
	require.Equal(t, id, batches[0].Items[0].ID())

	require.Len(t, batches[1].Items, 1)
	require.Equal(t, id, batches[1].Items[0].ID())
}

// TestBuildRenderPlanStageA_SingleTileClipIsWholeCanvas verifies build Render Plan Stage A Single Tile Clip Is Whole Canvas.
func TestBuildRenderPlanStageA_SingleTileClipIsWholeCanvas(t *testing.T) {
	t.Parallel()

	// World: 100x80 real => 100000x80000 fixed.
	w := NewWorld(100, 80)

	// Build any grid (cell size doesn't matter for this test, but grid must exist).
	w.resetGrid(10 * SCALE)

	params := RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 80,
		MarginXPx:        25,
		MarginYPx:        20,
		CameraXWorldFp:   50 * SCALE,
		CameraYWorldFp:   40 * SCALE,
		CameraZoom:       2.0,
	}

	plan, err := w.buildRenderPlan(params)
	require.NoError(t, err)

	require.Equal(t, 150, plan.CanvasWidthPx)
	require.Equal(t, 120, plan.CanvasHeightPx)
	require.Equal(t, 2*SCALE, plan.ZoomFp)

	require.Len(t, plan.Tiles, 1)
	td := plan.Tiles[0]

	require.Equal(t, 0, td.ClipX)
	require.Equal(t, 0, td.ClipY)
	require.Equal(t, 150, td.ClipW)
	require.Equal(t, 120, td.ClipH)

	require.Equal(t, 0, td.Tile.OffsetX)
	require.Equal(t, 0, td.Tile.OffsetY)
}

// TestBuildRenderPlanStageA_TilesCoverCanvasWithoutGaps verifies build Render Plan Stage A Tiles Cover Canvas Without Gaps.
func TestBuildRenderPlanStageA_TilesCoverCanvasWithoutGaps(t *testing.T) {
	t.Parallel()

	// World: 10x10 => 10000x10000 fixed.
	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	// Use zoom=1 and the same canvas geometry as earlier: 150x120.
	params := RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 80,
		MarginXPx:        25,
		MarginYPx:        20,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
	}

	plan, err := w.buildRenderPlan(params)
	require.NoError(t, err)

	require.Equal(t, 150, plan.CanvasWidthPx)
	require.Equal(t, 120, plan.CanvasHeightPx)
	require.Equal(t, SCALE, plan.ZoomFp)

	type interval struct {
		start int
		end   int
	}

	// Full tile size in pixels for one whole world width/height at the current zoom.
	fullTileXPx := worldSpanFixedToCanvasPx(w.W, plan.ZoomFp)
	fullTileYPx := worldSpanFixedToCanvasPx(w.H, plan.ZoomFp)
	require.Greater(t, fullTileXPx, 0)
	require.Greater(t, fullTileYPx, 0)

	// ---- X coverage ----
	// Take the first Y strip only to avoid duplicates across Y.
	intervalsX := make([]interval, 0, len(plan.Tiles))
	for _, td := range plan.Tiles {
		if td.ClipY == 0 && td.ClipH > 0 && td.ClipW > 0 {
			intervalsX = append(intervalsX, interval{
				start: td.ClipX,
				end:   td.ClipX + td.ClipW,
			})
			// A single tile must never exceed one whole world-tile width in pixels.
			require.LessOrEqual(t, td.ClipW, fullTileXPx, "tile width must not exceed one world tile in pixels")
		}
	}
	require.NotEmpty(t, intervalsX)

	sort.Slice(intervalsX, func(i, j int) bool {
		if intervalsX[i].start != intervalsX[j].start {
			return intervalsX[i].start < intervalsX[j].start
		}
		return intervalsX[i].end < intervalsX[j].end
	})

	require.Equal(t, 0, intervalsX[0].start)
	cursorX := intervalsX[0].end
	for i := 1; i < len(intervalsX); i++ {
		require.Equal(t, cursorX, intervalsX[i].start, "gap/overlap in X coverage between intervals %d and %d", i-1, i)
		cursorX = intervalsX[i].end
	}
	require.Equal(t, plan.CanvasWidthPx, cursorX)

	// ---- Y coverage ----
	// Take the first X strip only to avoid duplicates across X.
	intervalsY := make([]interval, 0, len(plan.Tiles))
	for _, td := range plan.Tiles {
		if td.ClipX == 0 && td.ClipW > 0 && td.ClipH > 0 {
			intervalsY = append(intervalsY, interval{
				start: td.ClipY,
				end:   td.ClipY + td.ClipH,
			})
			// A single tile must never exceed one whole world-tile height in pixels.
			require.LessOrEqual(t, td.ClipH, fullTileYPx, "tile height must not exceed one world tile in pixels")
		}
	}
	require.NotEmpty(t, intervalsY)

	sort.Slice(intervalsY, func(i, j int) bool {
		if intervalsY[i].start != intervalsY[j].start {
			return intervalsY[i].start < intervalsY[j].start
		}
		return intervalsY[i].end < intervalsY[j].end
	})

	require.Equal(t, 0, intervalsY[0].start)
	cursorY := intervalsY[0].end
	for i := 1; i < len(intervalsY); i++ {
		require.Equal(t, cursorY, intervalsY[i].start, "gap/overlap in Y coverage between intervals %d and %d", i-1, i)
		cursorY = intervalsY[i].end
	}
	require.Equal(t, plan.CanvasHeightPx, cursorY)
}

// TestBuildRenderPlanStageA_CandidatesArePerTileDeduped verifies build Render Plan Stage A Candidates Are Per Tile Deduped.
func TestBuildRenderPlanStageA_CandidatesArePerTileDeduped(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	// Put one circle in the world and index it, it will occupy multiple cells.
	id, err := w.AddCircle(5, 5, 2.2)
	require.NoError(t, err)
	w.indexObject(w.objects[id])

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

	plan, err := w.buildRenderPlan(params)
	require.NoError(t, err)
	require.NotEmpty(t, plan.Tiles)

	// Find any tile that has candidates; expect the circle to appear at most once per tile.
	for _, td := range plan.Tiles {
		if len(td.Candidates) == 0 {
			continue
		}
		seen := map[PrimitiveID]struct{}{}
		for _, it := range td.Candidates {
			_, ok := seen[it.ID()]
			require.False(t, ok, "candidate duplicated within a tile")
			seen[it.ID()] = struct{}{}
		}
	}
}

// TestWorldForceFullRedrawNextResetsIncrementalState verifies world Force Full Redraw Next Resets Incremental State.
func TestWorldForceFullRedrawNextResetsIncrementalState(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)

	// Initialize state via a full render commit.
	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        2,
		MarginYPx:        2,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
	}
	w.resetGrid(2 * SCALE)
	require.NoError(t, w.CommitFullRedrawState(params))
	require.True(t, w.renderState.initialized)

	w.ForceFullRedrawNext()
	require.False(t, w.renderState.initialized)
}

// TestRender_SortsByPriorityWithinTile verifies render Sorts By Priority Within Tile.
func TestRender_SortsByPriorityWithinTile(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	// Same tile. Priorities deliberately mixed.
	_, err := w.AddCircle(5, 5, 1, CircleWithPriority(500))
	require.NoError(t, err)

	_, err = w.AddLine(1, 5, 9, 5, LineWithPriority(100))
	require.NoError(t, err)

	_, err = w.AddPoint(5, 6, PointWithPriority(300))
	require.NoError(t, err)

	for _, obj := range w.objects {
		w.indexObject(obj)
	}

	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        0,
		MarginYPx:        0,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
		Options:          &RenderOptions{
			// default: wrap on
		},
	}

	d := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d, params))

	// We verify the first occurrence of each primitive kind follows priority order.
	// Since each object is drawn with Add* + Fill/Stroke immediately, order should match.
	cmds := d.Commands()
	firstLine := indexOfFirst(cmds, "AddLine")
	firstCircle := indexOfFirst(cmds, "AddCircle")
	firstPoint := indexOfFirst(cmds, "AddPoint")

	require.NotEqual(t, -1, firstLine)
	require.NotEqual(t, -1, firstCircle)
	require.NotEqual(t, -1, firstPoint)

	require.Less(t, firstLine, firstPoint)
	require.Less(t, firstPoint, firstCircle) // 300 before 500
}

func indexOfFirst(cmds []fakeDrawerCommand, name string) int {
	for i, c := range cmds {
		if c.Name == name {
			return i
		}
	}
	return -1
}

type bgOffsetScaleTheme struct {
	img    image.Image
	anchor BackgroundAnchorMode
}

func (t bgOffsetScaleTheme) ID() string   { return "bgoffset" }
func (t bgOffsetScaleTheme) Name() string { return "bgoffset" }

func (t bgOffsetScaleTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (t bgOffsetScaleTheme) BackgroundImage() image.Image { return t.img }

func (t bgOffsetScaleTheme) BackgroundTileMode() BackgroundTileMode     { return BackgroundTileRepeat }
func (t bgOffsetScaleTheme) BackgroundScaleMode() BackgroundScaleMode   { return BackgroundScaleNone }
func (t bgOffsetScaleTheme) BackgroundAnchorMode() BackgroundAnchorMode { return t.anchor }

func (t bgOffsetScaleTheme) PointStyle() Style {
	return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2}
}
func (t bgOffsetScaleTheme) LineStyle() Style {
	return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (t bgOffsetScaleTheme) CircleStyle() Style {
	return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}

func (t bgOffsetScaleTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
	return StyleOverride{}, false
}
func (t bgOffsetScaleTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
	return StyleOverride{}, false
}
func (t bgOffsetScaleTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
	return StyleOverride{}, false
}

// TestRender_BackgroundTileRepeat_WorldAnchored_ShiftsWithPan verifies render Background Tile Repeat World Anchored Shifts With Pan.
func TestRender_BackgroundTileRepeat_WorldAnchored_ShiftsWithPan(t *testing.T) {
	t.Parallel()

	w := NewWorld(20, 20)
	w.resetGrid(2 * SCALE)

	img := image.NewRGBA(image.Rect(0, 0, 4, 4)) // tile 4x4
	w.SetTheme(bgOffsetScaleTheme{img: img, anchor: BackgroundAnchorWorld})

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

	// First render.
	d1 := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d1, params))

	minX1, minY1 := minDrawImageXY(t, d1)
	require.Equal(t, -1, minX1)
	require.Equal(t, -1, minY1)

	// Pan camera by +1 world unit along both axes (zoom=1 => 1px).
	params2 := params
	params2.CameraXWorldFp += 1 * SCALE
	params2.CameraYWorldFp += 1 * SCALE

	// Force full redraw to make this test independent of incremental pipeline.
	w.ForceFullRedrawNext()

	d2 := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d2, params2))

	minX2, minY2 := minDrawImageXY(t, d2)

	// With world anchoring, moving camera +1 shifts the tiling origin by -1 (mod tile size).
	require.Equal(t, -2, minX2)
	require.Equal(t, -2, minY2)
}

// TestRender_BackgroundTileRepeat_ViewportAnchored_DoesNotShiftWithPan verifies render Background Tile Repeat Viewport Anchored Does Not Shift With Pan.
func TestRender_BackgroundTileRepeat_ViewportAnchored_DoesNotShiftWithPan(t *testing.T) {
	t.Parallel()

	w := NewWorld(20, 20)
	w.resetGrid(2 * SCALE)

	img := image.NewRGBA(image.Rect(0, 0, 4, 4))
	w.SetTheme(bgOffsetScaleTheme{img: img, anchor: BackgroundAnchorViewport})

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

	d1 := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d1, params))

	minX1, minY1 := minDrawImageXY(t, d1)

	params2 := params
	params2.CameraXWorldFp += 1 * SCALE
	params2.CameraYWorldFp += 1 * SCALE

	w.ForceFullRedrawNext()

	d2 := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d2, params2))

	minX2, minY2 := minDrawImageXY(t, d2)

	// With viewport anchoring, tiling origin is fixed (no camera dependency).
	require.Equal(t, minX1, minX2)
	require.Equal(t, minY1, minY2)
}

func minDrawImageXY(t *testing.T, d *fakePrimitiveDrawer) (int, int) {
	t.Helper()

	cmds := d.CommandsByName("DrawImage")
	require.NotEmpty(t, cmds, "expected DrawImage calls from background tiling")

	minX := int(cmds[0].Args[0])
	minY := int(cmds[0].Args[1])

	for _, c := range cmds[1:] {
		x := int(c.Args[0])
		y := int(c.Args[1])
		if x < minX {
			minX = x
		}
		if y < minY {
			minY = y
		}
	}
	return minX, minY
}

type bgOffsetTheme struct {
	img       image.Image
	scaleMode BackgroundScaleMode
}

func (t bgOffsetTheme) ID() string   { return "bgscale" }
func (t bgOffsetTheme) Name() string { return "bgscale" }

func (t bgOffsetTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (t bgOffsetTheme) BackgroundImage() image.Image { return t.img }

func (t bgOffsetTheme) BackgroundTileMode() BackgroundTileMode     { return BackgroundTileNone }
func (t bgOffsetTheme) BackgroundScaleMode() BackgroundScaleMode   { return t.scaleMode }
func (t bgOffsetTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport }

func (t bgOffsetTheme) PointStyle() Style {
	return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2}
}
func (t bgOffsetTheme) LineStyle() Style {
	return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (t bgOffsetTheme) CircleStyle() Style {
	return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}

func (t bgOffsetTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
	return StyleOverride{}, false
}
func (t bgOffsetTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
	return StyleOverride{}, false
}
func (t bgOffsetTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
	return StyleOverride{}, false
}

// TestRender_BackgroundScaleNone_UsesOffsetDrawImage verifies render Background Scale None Uses Offset Draw Image.
func TestRender_BackgroundScaleNone_UsesOffsetDrawImage(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	img := image.NewRGBA(image.Rect(0, 0, 4, 4))
	w.SetTheme(bgOffsetTheme{img: img, scaleMode: BackgroundScaleNone})

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

	d := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d, params))

	require.NotEmpty(t, d.CommandsByName("DrawImage"))
	require.Empty(t, d.CommandsByName("DrawImageScaled"))
}

// TestRender_BackgroundScaleFit_UsesDrawOffsetImageScaled verifies render Background Scale Fit Uses Draw Offset Image Scaled.
func TestRender_BackgroundScaleFit_UsesDrawOffsetImageScaled(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	img := image.NewRGBA(image.Rect(0, 0, 4, 4))
	w.SetTheme(bgOffsetTheme{img: img, scaleMode: BackgroundScaleFit})

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

	d := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d, params))

	require.NotEmpty(t, d.CommandsByName("DrawImageScaled"))
}

type bgScaleTheme struct {
	img       image.Image
	scaleMode BackgroundScaleMode
}

func (t bgScaleTheme) ID() string   { return "bgscale" }
func (t bgScaleTheme) Name() string { return "bgscale" }

func (t bgScaleTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (t bgScaleTheme) BackgroundImage() image.Image { return t.img }

func (t bgScaleTheme) BackgroundTileMode() BackgroundTileMode     { return BackgroundTileNone }
func (t bgScaleTheme) BackgroundScaleMode() BackgroundScaleMode   { return t.scaleMode }
func (t bgScaleTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport }

func (t bgScaleTheme) PointStyle() Style {
	return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2}
}
func (t bgScaleTheme) LineStyle() Style {
	return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (t bgScaleTheme) CircleStyle() Style {
	return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}

func (t bgScaleTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
	return StyleOverride{}, false
}
func (t bgScaleTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
	return StyleOverride{}, false
}
func (t bgScaleTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
	return StyleOverride{}, false
}

// TestRender_BackgroundScaleNone_UsesDrawImage verifies render Background Scale None Uses Draw Image.
func TestRender_BackgroundScaleNone_UsesDrawImage(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	img := image.NewRGBA(image.Rect(0, 0, 4, 4))
	w.SetTheme(bgScaleTheme{img: img, scaleMode: BackgroundScaleNone})

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

	d := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d, params))

	require.NotEmpty(t, d.CommandsByName("DrawImage"))
	require.Empty(t, d.CommandsByName("DrawImageScaled"))
}

// TestRender_BackgroundScaleFit_UsesDrawImageScaled verifies render Background Scale Fit Uses Draw Image Scaled.
func TestRender_BackgroundScaleFit_UsesDrawImageScaled(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	img := image.NewRGBA(image.Rect(0, 0, 4, 4))
	w.SetTheme(bgScaleTheme{img: img, scaleMode: BackgroundScaleFit})

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

	d := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d, params))

	require.NotEmpty(t, d.CommandsByName("DrawImageScaled"))
}

type bgTheme struct {
	img image.Image
}

func (t bgTheme) ID() string   { return "bg" }
func (t bgTheme) Name() string { return "bg" }

func (t bgTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (t bgTheme) BackgroundImage() image.Image { return t.img }

func (t bgTheme) BackgroundTileMode() BackgroundTileMode     { return BackgroundTileRepeat }
func (t bgTheme) BackgroundScaleMode() BackgroundScaleMode   { return BackgroundScaleNone }
func (t bgTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport }

func (t bgTheme) PointStyle() Style { return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} }
func (t bgTheme) LineStyle() Style  { return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} }
func (t bgTheme) CircleStyle() Style {
	return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}

func (t bgTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
	return StyleOverride{}, false
}
func (t bgTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false }
func (t bgTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
	return StyleOverride{}, false
}

// TestRender_BackgroundImage_DrawsBeforePrimitives_FullRedraw verifies render Background Image Draws Before Primitives Full Redraw.
func TestRender_BackgroundImage_DrawsBeforePrimitives_FullRedraw(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	// 4x4 opaque image
	img := image.NewRGBA(image.Rect(0, 0, 4, 4))
	w.SetTheme(bgTheme{img: img})

	_, err := w.AddPoint(5, 5)
	require.NoError(t, err)
	for _, obj := range w.objects {
		w.indexObject(obj)
	}

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

	d := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d, params))

	cmds := d.Commands()
	iClear := indexOfFirstName(cmds, "ClearAllTo")
	iBg := indexOfFirstName(cmds, "DrawImage")
	iPrim := indexOfFirstName(cmds, "AddPoint")

	require.NotEqual(t, -1, iClear)
	require.NotEqual(t, -1, iBg)
	require.NotEqual(t, -1, iPrim)
	require.Less(t, iClear, iBg)
	require.Less(t, iBg, iPrim)
}

// TestRender_BackgroundImage_RedrawnInDirtyRects_OnIncrementalShift verifies render Background Image Redrawn In Dirty Rects On Incremental Shift.
func TestRender_BackgroundImage_RedrawnInDirtyRects_OnIncrementalShift(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	img := image.NewRGBA(image.Rect(0, 0, 4, 4))
	w.SetTheme(bgTheme{img: img})

	// Ensure state: first full render commits.
	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        2,
		MarginYPx:        2,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
		Options: &RenderOptions{
			Incremental: &IncrementalPolicy{
				AllowShiftOnly:   false,
				MaxCatchUpAreaPx: 0,
				RenderBudgetMs:   0,
				CoalesceUpdates:  false,
			},
		},
	}

	d := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d, params))

	// Move camera by 1px right in world (zoom=1 => 1px == 1 unit).
	params2 := params
	params2.CameraXWorldFp += 1 * SCALE

	d2 := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d2, params2))

	// In incremental shift path we must see ClearRectTo and DrawImage.
	require.NotEmpty(t, d2.CommandsByName("CopyShift"))
	require.NotEmpty(t, d2.CommandsByName("ClearRectTo"))
	require.NotEmpty(t, d2.CommandsByName("DrawImage"))
}

// TestDrawCirclesFromPlan_DuplicatesAcrossTilesAndClips verifies draw Circles From Plan Duplicates Across Tiles And Clips.
func TestDrawCirclesFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) {
	t.Parallel()

	// World is 10x10 world units => 10000x10000 fixed.
	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	// Circle near origin so that in expanded canvas (bigger than world)
	// it will appear in multiple torus tiles.
	id, err := w.AddCircle(1.0, 1.0, 1.0) // center (1000,1000), radius 1000
	require.NoError(t, err)

	w.indexObject(w.objects[id])

	// Same geometry as points-only test:
	// viewport 10x10 px, margins 2px => canvas 14x14 px at zoom=1 => expanded span 14 units > world.
	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        2,
		MarginYPx:        2,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
	}

	plan, err := w.buildRenderPlan(params)
	require.NoError(t, err)

	d := &fakePrimitiveDrawer{}
	drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)

	// Expect 4 circle copies, one per tile that covers the expanded canvas.
	wantNames := []string{
		"Save", "ClipRect", "AddCircle", "Fill", "Restore",
		"Save", "ClipRect", "AddCircle", "Fill", "Restore",
		"Save", "ClipRect", "AddCircle", "Fill", "Restore",
		"Save", "ClipRect", "AddCircle", "Fill", "Restore",
	}
	require.Equal(t, wantNames, d.CommandNames())

	// At zoom=1, 1 world unit -> 1 px, so:
	// circle center at (1,1) => base copy at (3,3) like point test
	// radius 1 => 1 px
	//
	// The rest are shifted by +10px in X and/or Y due to torus tiling.
	{
		clip := requireDrawerCommandAt(t, d, 1)
		require.Equal(t, "ClipRect", clip.Name)
		requireCommandArgs(t, clip, 2, 2, 10, 10)

		c := requireDrawerCommandAt(t, d, 2)
		require.Equal(t, "AddCircle", c.Name)
		requireCommandArgs(t, c, 3, 3, 1)
	}

	{
		clip := requireDrawerCommandAt(t, d, 6)
		require.Equal(t, "ClipRect", clip.Name)
		requireCommandArgs(t, clip, 2, 12, 10, 2)

		c := requireDrawerCommandAt(t, d, 7)
		require.Equal(t, "AddCircle", c.Name)
		requireCommandArgs(t, c, 3, 13, 1)
	}

	{
		clip := requireDrawerCommandAt(t, d, 11)
		require.Equal(t, "ClipRect", clip.Name)
		requireCommandArgs(t, clip, 12, 2, 2, 10)

		c := requireDrawerCommandAt(t, d, 12)
		require.Equal(t, "AddCircle", c.Name)
		requireCommandArgs(t, c, 13, 3, 1)
	}

	{
		clip := requireDrawerCommandAt(t, d, 16)
		require.Equal(t, "ClipRect", clip.Name)
		requireCommandArgs(t, clip, 12, 12, 2, 2)

		c := requireDrawerCommandAt(t, d, 17)
		require.Equal(t, "AddCircle", c.Name)
		requireCommandArgs(t, c, 13, 13, 1)
	}
}

// TestDrawCirclesFromPlan_SkipsTilesWithoutCircles verifies draw Circles From Plan Skips Tiles Without Circles.
func TestDrawCirclesFromPlan_SkipsTilesWithoutCircles(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	// Add only a point, no circles.
	id, err := w.AddPoint(5, 5)
	require.NoError(t, err)
	w.indexObject(w.objects[id])

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

	plan, err := w.buildRenderPlan(params)
	require.NoError(t, err)

	d := &fakePrimitiveDrawer{}
	drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)

	// No circles => no commands.
	require.Empty(t, d.Commands())
}

// TestDrawCirclesFromPlan_ProjectsRadiusWithZoom verifies draw Circles From Plan Projects Radius With Zoom.
func TestDrawCirclesFromPlan_ProjectsRadiusWithZoom(t *testing.T) {
	t.Parallel()

	w := NewWorld(100, 100)
	w.resetGrid(10 * SCALE)

	// radius 2 world units; zoom=2 => should be 4 px when 1 unit == 1px at zoom=1.
	id, err := w.AddCircle(50, 50, 2)
	require.NoError(t, err)
	w.indexObject(w.objects[id])

	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        2,
		MarginYPx:        2,
		CameraXWorldFp:   50 * SCALE,
		CameraYWorldFp:   50 * SCALE,
		CameraZoom:       2.0,
	}

	plan, err := w.buildRenderPlan(params)
	require.NoError(t, err)

	d := &fakePrimitiveDrawer{}
	drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)

	// There should be at least one AddCircle.
	cmds := d.CommandsByName("AddCircle")
	require.NotEmpty(t, cmds)

	// All circles in this plan should have radius 4px (2 units * 2x zoom).
	for _, c := range cmds {
		require.Len(t, c.Args, 3)
		require.Equal(t, 4.0, c.Args[2])
	}
}

// TestCircles_NoWrap_DoesNotDuplicateAcrossEdges verifies circles No Wrap Does Not Duplicate Across Edges.
func TestCircles_NoWrap_DoesNotDuplicateAcrossEdges(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.SetCircleRadiusScaleFp(SCALE)
	w.resetGrid(2 * SCALE)

	_, err := w.AddCircle(9, 9, 2)
	require.NoError(t, err)
	for _, obj := range w.objects {
		w.indexObject(obj)
	}

	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        0,
		MarginYPx:        0,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
		Options: &RenderOptions{
			DisableWrapScroll: true,
			Layers:            []RenderLayer{RenderLayerCircles},
		},
	}

	d := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d, params))

	cmds := d.CommandsByName("AddCircle")
	require.Len(t, cmds, 1)

	// Center must be at (9,9) only, no (-1,*) or (*,-1).
	require.Equal(t, []float64{9, 9, 2}, cmds[0].Args)
}

// TestRender_CircleTransparentFill_UsesStrokeNotFill verifies render Circle Transparent Fill Uses Stroke Not Fill.
func TestRender_CircleTransparentFill_UsesStrokeNotFill(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	sw := 4.0
	circleStyle := w.AddStyleCircle(StyleOverride{
		FillColor:     color.RGBA{A: 0}, // explicitly transparent
		StrokeColor:   color.RGBA{R: 255, G: 255, B: 255, A: 255},
		StrokeWidthPx: &sw,
	})

	_, err := w.AddCircle(5, 5, 2, CircleWithStyleID(circleStyle), CircleWithPriority(100))
	require.NoError(t, err)

	for _, obj := range w.objects {
		w.indexObject(obj)
	}

	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        0,
		MarginYPx:        0,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
		Options: &RenderOptions{
			BackgroundColor: color.RGBA{A: 255},
		},
	}

	d := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d, params))

	cmds := d.Commands()

	iAdd := indexOfFirstName(cmds, "AddCircle")
	require.NotEqual(t, -1, iAdd)

	// After AddCircle we must see Stroke (not Fill).
	iFill := indexOfFirstNameInRange(cmds, "Fill", iAdd+1, min(iAdd+6, len(cmds)))
	iStroke := indexOfFirstNameInRange(cmds, "Stroke", iAdd+1, min(iAdd+6, len(cmds)))

	require.Equal(t, -1, iFill, "transparent fill must not trigger Fill()")
	require.NotEqual(t, -1, iStroke, "transparent fill must trigger Stroke() when stroke is visible")
}

// TestRender_CircleFillAndStroke_DrawsFillThenStroke verifies render Circle Fill And Stroke Draws Fill Then Stroke.
func TestRender_CircleFillAndStroke_DrawsFillThenStroke(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	sw := 2.0
	styleID := w.AddStyleCircle(StyleOverride{
		FillColor:     color.RGBA{R: 10, G: 20, B: 30, A: 255},
		StrokeColor:   color.RGBA{R: 255, G: 255, B: 255, A: 255},
		StrokeWidthPx: &sw,
	})

	_, err := w.AddCircle(5, 5, 2, CircleWithStyleID(styleID), CircleWithPriority(100))
	require.NoError(t, err)

	for _, obj := range w.objects {
		w.indexObject(obj)
	}

	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        0,
		MarginYPx:        0,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
		Options: &RenderOptions{
			BackgroundColor: color.RGBA{A: 255},
		},
	}

	d := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d, params))

	cmds := d.Commands()
	iAdd := indexOfFirstName(cmds, "AddCircle")
	require.NotEqual(t, -1, iAdd)

	iFill := indexOfFirstNameInRange(cmds, "Fill", iAdd+1, min(iAdd+10, len(cmds)))
	iStroke := indexOfFirstNameInRange(cmds, "Stroke", iAdd+1, min(iAdd+10, len(cmds)))

	require.NotEqual(t, -1, iFill, "expected Fill() for visible fill")
	require.NotEqual(t, -1, iStroke, "expected Stroke() for visible stroke")
	require.Less(t, iFill, iStroke, "Stroke must be last when both are visible")
}

// TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld verifies circles Wrap Copies Appear Inside Viewport When Viewport Equals World.
func TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testing.T) {
	t.Parallel()

	// World 10x10 units => 10px at zoom=1 when viewport==world.
	w := NewWorld(10, 10)
	w.SetCircleRadiusScaleFp(SCALE)
	w.resetGrid(2 * SCALE)

	type tc struct {
		name        string
		x, y        float64
		r           float64
		wantCenters [][2]float64 // expected (cx,cy) in canvas px for zoom=1, worldRect min = 0
	}

	// Camera is centered => expanded world rect equals [0..W)x[0..H) when margin=0.
	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        0,
		MarginYPx:        0,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
	}

	tests := []tc{
		{
			name: "bottom boundary wraps to top",
			x:    5, y: 9, r: 2,
			// Centers: original at y=9, copy at y=-1.
			wantCenters: [][2]float64{{5, 9}, {5, -1}},
		},
		{
			name: "right boundary wraps to left",
			x:    9, y: 5, r: 2,
			wantCenters: [][2]float64{{9, 5}, {-1, 5}},
		},
		{
			name: "corner wraps to three extra copies",
			x:    9, y: 9, r: 2,
			wantCenters: [][2]float64{{9, 9}, {-1, 9}, {9, -1}, {-1, -1}},
		},
		{
			name: "no wrap inside",
			x:    5, y: 5, r: 2,
			wantCenters: [][2]float64{{5, 5}},
		},
	}

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

			w2 := NewWorld(10, 10)
			w2.resetGrid(2 * SCALE)

			_, err := w2.AddCircle(tt.x, tt.y, tt.r)
			require.NoError(t, err)

			for _, obj := range w2.objects {
				w2.indexObject(obj)
			}

			plan, err := w2.buildRenderPlan(params)
			require.NoError(t, err)

			d := &fakePrimitiveDrawer{}
			drawCirclesFromPlan(d, plan, w2.W, w2.H, true, w.circleRadiusScaleFp)

			cmds := d.CommandsByName("AddCircle")
			require.Len(t, cmds, len(tt.wantCenters))

			// Collect centers (ignore radius for this test).
			got := make([][2]float64, 0, len(cmds))
			for _, c := range cmds {
				require.Len(t, c.Args, 3)
				got = append(got, [2]float64{c.Args[0], c.Args[1]})
			}

			// Order is deterministic with our shift generation and tile iteration for margin=0: single tile.
			require.ElementsMatch(t, tt.wantCenters, got)
		})
	}
}

// TestRender_ShiftOnlyOverBudget_DefersDirtyAndCatchesUpOnStop verifies render Shift Only Over Budget Defers Dirty And Catches Up On Stop.
func TestRender_ShiftOnlyOverBudget_DefersDirtyAndCatchesUpOnStop(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	_, err := w.AddPoint(5, 5)
	require.NoError(t, err)
	for _, obj := range w.objects {
		w.indexObject(obj)
	}

	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        4, // threshold=2
		MarginYPx:        4,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
		Options: &RenderOptions{
			Incremental: &IncrementalPolicy{
				AllowShiftOnly: true,
				RenderBudgetMs: 1, // 1ms budget
			},
		},
	}

	// First render (full) initializes state.
	d0 := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d0, params))
	require.True(t, w.renderState.initialized)

	// Pretend previous render was very slow => over budget for the next frame.
	w.renderState.lastRenderDurationNs = 10_000_000 // 10ms

	// Pan right by 1 unit => incremental shift candidate.
	params2 := params
	params2.CameraXWorldFp += 1 * SCALE

	d1 := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d1, params2))

	// Shift-only should call CopyShift but not redraw dirty rects.
	require.NotEmpty(t, d1.CommandsByName("CopyShift"))
	require.Empty(t, d1.CommandsByName("ClipRect"))
	require.NotEmpty(t, w.renderState.pendingDirty)

	// Now stop panning: dx=dy=0. This should trigger catch-up redraw of pendingDirty.
	w.renderState.lastRenderDurationNs = 0 // under budget

	params3 := params2 // same camera
	d2 := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d2, params3))

	require.NotEmpty(t, d2.CommandsByName("ClipRect"))
	require.NotEmpty(t, d2.CommandsByName("AddPoint"))
	require.Empty(t, w.renderState.pendingDirty)
}

// TestRender_CatchUpWhilePanning_WhenBackUnderBudget verifies render Catch Up While Panning When Back Under Budget.
func TestRender_CatchUpWhilePanning_WhenBackUnderBudget(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	_, err := w.AddPoint(5, 5)
	require.NoError(t, err)
	for _, obj := range w.objects {
		w.indexObject(obj)
	}

	policy := &IncrementalPolicy{
		AllowShiftOnly: true,
		RenderBudgetMs: 1,
	}

	base := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        4, // threshold=2
		MarginYPx:        4,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
		Options: &RenderOptions{
			Incremental: policy,
		},
	}

	// Initial full render.
	require.NoError(t, w.Render(&fakePrimitiveDrawer{}, base))

	// Frame 1: over budget => shift-only, pendingDirty accumulates.
	w.renderState.lastRenderDurationNs = 10_000_000 // 10ms

	p1 := base
	p1.CameraXWorldFp += 1 * SCALE

	d1 := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d1, p1))

	require.NotEmpty(t, d1.CommandsByName("CopyShift"))
	require.Empty(t, d1.CommandsByName("ClipRect"))
	require.NotEmpty(t, w.renderState.pendingDirty)

	// Frame 2: still panning, but now under budget => should shift + redraw (including pendingDirty).
	w.renderState.lastRenderDurationNs = 0

	p2 := p1
	p2.CameraXWorldFp += 1 * SCALE

	d2 := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d2, p2))

	require.NotEmpty(t, d2.CommandsByName("CopyShift"))
	require.NotEmpty(t, d2.CommandsByName("ClipRect"))
	require.NotEmpty(t, d2.CommandsByName("AddPoint"))
	require.Empty(t, w.renderState.pendingDirty, "pending dirty should be cleared after successful catch-up redraw")
}

// TestRender_CatchUpLimit_ReducesPendingDirtyGradually verifies render Catch Up Limit Reduces Pending Dirty Gradually.
func TestRender_CatchUpLimit_ReducesPendingDirtyGradually(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	_, err := w.AddPoint(5, 5)
	require.NoError(t, err)
	for _, obj := range w.objects {
		w.indexObject(obj)
	}

	policy := &IncrementalPolicy{
		AllowShiftOnly:   true,
		RenderBudgetMs:   1,
		MaxCatchUpAreaPx: 20, // very small budget
	}

	base := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 2,
		MarginXPx:        4,
		MarginYPx:        4, // canvasH = 2 + 8 = 10
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
		Options: &RenderOptions{
			Incremental: policy,
		},
	}

	// Full init
	require.NoError(t, w.Render(&fakePrimitiveDrawer{}, base))

	// Over budget => shift-only twice to accumulate pending dirty.
	w.renderState.lastRenderDurationNs = 10_000_000

	p1 := base
	p1.CameraXWorldFp += 1 * SCALE
	require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p1))
	require.NotEmpty(t, w.renderState.pendingDirty)

	w.renderState.lastRenderDurationNs = 10_000_000
	p2 := p1
	p2.CameraXWorldFp += 1 * SCALE
	require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p2))
	require.NotEmpty(t, w.renderState.pendingDirty)

	// Under budget now, but limit catch-up.
	w.renderState.lastRenderDurationNs = 0

	before := len(w.renderState.pendingDirty)
	require.Greater(t, before, 0)

	require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p2))
	after := len(w.renderState.pendingDirty)

	// With a tiny MaxCatchUpAreaPx we should not clear everything in one go.
	require.Greater(t, after, 0)
	require.Less(t, after, before)
}

// TestTakeCatchUpRects_RespectsAreaLimit verifies take Catch Up Rects Respects Area Limit.
func TestTakeCatchUpRects_RespectsAreaLimit(t *testing.T) {
	t.Parallel()

	pending := []RectPx{
		{X: 0, Y: 0, W: 10, H: 1}, // area 10
		{X: 0, Y: 1, W: 10, H: 2}, // area 20
		{X: 0, Y: 3, W: 10, H: 3}, // area 30
	}

	// Limit 25 => should take first (10) + second (20) would exceed => take only first.
	sel, rem := takeCatchUpRects(pending, 25)
	require.Equal(t, []RectPx{{X: 0, Y: 0, W: 10, H: 1}}, sel)
	require.Equal(t, []RectPx{
		{X: 0, Y: 1, W: 10, H: 2},
		{X: 0, Y: 3, W: 10, H: 3},
	}, rem)

	// Limit 30 => can take first(10) + second(20) exactly.
	sel, rem = takeCatchUpRects(pending, 30)
	require.Equal(t, []RectPx{
		{X: 0, Y: 0, W: 10, H: 1},
		{X: 0, Y: 1, W: 10, H: 2},
	}, sel)
	require.Equal(t, []RectPx{{X: 0, Y: 3, W: 10, H: 3}}, rem)

	// No limit => take all.
	sel, rem = takeCatchUpRects(pending, 0)
	require.Len(t, sel, 3)
	require.Empty(t, rem)
}

// TestPlanIncrementalPan_NoOp verifies plan Incremental Pan No Op.
func TestPlanIncrementalPan_NoOp(t *testing.T) {
	t.Parallel()

	plan, err := PlanIncrementalPan(200, 100, 50, 30, 0, 0)
	require.NoError(t, err)
	require.Equal(t, IncrementalNoOp, plan.Mode)
	require.Empty(t, plan.Dirty)
}

// TestPlanIncrementalPan_FullRedrawOnInvalidCanvas verifies plan Incremental Pan Full Redraw On Invalid Canvas.
func TestPlanIncrementalPan_FullRedrawOnInvalidCanvas(t *testing.T) {
	t.Parallel()

	_, err := PlanIncrementalPan(0, 100, 10, 10, 1, 0)
	require.ErrorIs(t, err, errInvalidCanvasSize)
}

// TestPlanIncrementalPan_FullRedrawOnTooLargeShift verifies plan Incremental Pan Full Redraw On Too Large Shift.
func TestPlanIncrementalPan_FullRedrawOnTooLargeShift(t *testing.T) {
	t.Parallel()

	plan, err := PlanIncrementalPan(100, 80, 40, 40, 100, 0)
	require.NoError(t, err)
	require.Equal(t, IncrementalFullRedraw, plan.Mode)

	plan, err = PlanIncrementalPan(100, 80, 40, 40, 0, -80)
	require.NoError(t, err)
	require.Equal(t, IncrementalFullRedraw, plan.Mode)
}

// TestPlanIncrementalPan_FullRedrawWhenMarginIsZeroAndDeltaNonZero verifies plan Incremental Pan Full Redraw When Margin Is Zero And Delta Non Zero.
func TestPlanIncrementalPan_FullRedrawWhenMarginIsZeroAndDeltaNonZero(t *testing.T) {
	t.Parallel()

	plan, err := PlanIncrementalPan(100, 80, 0, 20, 1, 0)
	require.NoError(t, err)
	require.Equal(t, IncrementalFullRedraw, plan.Mode)

	plan, err = PlanIncrementalPan(100, 80, 20, 0, 0, 1)
	require.NoError(t, err)
	require.Equal(t, IncrementalFullRedraw, plan.Mode)
}

// TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdX verifies plan Incremental Pan Full Redraw When Exceeds Threshold X.
func TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdX(t *testing.T) {
	t.Parallel()

	// marginX=20 => threshold=10, dx=11 => full redraw
	plan, err := PlanIncrementalPan(200, 100, 20, 20, 11, 0)
	require.NoError(t, err)
	require.Equal(t, IncrementalFullRedraw, plan.Mode)
}

// TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdY verifies plan Incremental Pan Full Redraw When Exceeds Threshold Y.
func TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdY(t *testing.T) {
	t.Parallel()

	// marginY=20 => threshold=10, dy=-11 => full redraw
	plan, err := PlanIncrementalPan(200, 100, 20, 20, 0, -11)
	require.NoError(t, err)
	require.Equal(t, IncrementalFullRedraw, plan.Mode)
}

// TestPlanIncrementalPan_Shift_LeftStripWhenDxPositive verifies plan Incremental Pan Shift Left Strip When Dx Positive.
func TestPlanIncrementalPan_Shift_LeftStripWhenDxPositive(t *testing.T) {
	t.Parallel()

	// marginX=40 => threshold=20, dx=5 => shift ok
	plan, err := PlanIncrementalPan(200, 100, 40, 40, 5, 0)
	require.NoError(t, err)
	require.Equal(t, IncrementalShift, plan.Mode)
	require.Equal(t, 5, plan.DxPx)
	require.Equal(t, 0, plan.DyPx)

	require.Equal(t, []RectPx{
		{X: 0, Y: 0, W: 6, H: 100},
	}, plan.Dirty)
}

// TestPlanIncrementalPan_Shift_RightStripWhenDxNegative verifies plan Incremental Pan Shift Right Strip When Dx Negative.
func TestPlanIncrementalPan_Shift_RightStripWhenDxNegative(t *testing.T) {
	t.Parallel()

	plan, err := PlanIncrementalPan(200, 100, 40, 40, -7, 0)
	require.NoError(t, err)
	require.Equal(t, IncrementalShift, plan.Mode)

	require.Equal(t, []RectPx{
		{X: 200 - 8, Y: 0, W: 8, H: 100},
	}, plan.Dirty)
}

// TestPlanIncrementalPan_Shift_TopStripWhenDyPositive verifies plan Incremental Pan Shift Top Strip When Dy Positive.
func TestPlanIncrementalPan_Shift_TopStripWhenDyPositive(t *testing.T) {
	t.Parallel()

	plan, err := PlanIncrementalPan(200, 100, 40, 40, 0, 9)
	require.NoError(t, err)
	require.Equal(t, IncrementalShift, plan.Mode)

	require.Equal(t, []RectPx{
		{X: 0, Y: 0, W: 200, H: 10},
	}, plan.Dirty)
}

// TestPlanIncrementalPan_Shift_BottomStripWhenDyNegative verifies plan Incremental Pan Shift Bottom Strip When Dy Negative.
func TestPlanIncrementalPan_Shift_BottomStripWhenDyNegative(t *testing.T) {
	t.Parallel()

	plan, err := PlanIncrementalPan(200, 100, 40, 40, 0, -9)
	require.NoError(t, err)
	require.Equal(t, IncrementalShift, plan.Mode)

	require.Equal(t, []RectPx{
		{X: 0, Y: 100 - 10, W: 200, H: 10},
	}, plan.Dirty)
}

// TestPlanIncrementalPan_Shift_DiagonalReturnsTwoDirtyRects verifies plan Incremental Pan Shift Diagonal Returns Two Dirty Rects.
func TestPlanIncrementalPan_Shift_DiagonalReturnsTwoDirtyRects(t *testing.T) {
	t.Parallel()

	plan, err := PlanIncrementalPan(200, 100, 40, 40, -6, 8)
	require.NoError(t, err)
	require.Equal(t, IncrementalShift, plan.Mode)

	// Overlap is allowed; we just require both strips exist.
	require.Len(t, plan.Dirty, 2)
	require.ElementsMatch(t, []RectPx{
		{X: 200 - 7, Y: 0, W: 7, H: 100}, // right strip
		{X: 0, Y: 0, W: 200, H: 9},       // top strip
	}, plan.Dirty)
}

// TestPlanIncrementalPan_OverdrawsDirtyStripsByOnePixel verifies plan Incremental Pan Overdraws Dirty Strips By One Pixel.
func TestPlanIncrementalPan_OverdrawsDirtyStripsByOnePixel(t *testing.T) {
	t.Parallel()

	plan, err := PlanIncrementalPan(200, 100, 40, 40, -7, 0)
	require.NoError(t, err)
	require.Equal(t, IncrementalShift, plan.Mode)

	// Right strip width should be abs(dx)+1 = 8.
	require.Equal(t, []RectPx{
		{X: 200 - 8, Y: 0, W: 8, H: 100},
	}, plan.Dirty)
}

// TestRender_PanSmall_UsesCopyShiftAndRendersOnlyDirtyStrips verifies render Pan Small Uses Copy Shift And Renders Only Dirty Strips.
func TestRender_PanSmall_UsesCopyShiftAndRendersOnlyDirtyStrips(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	_, err := w.AddPoint(5, 5)
	require.NoError(t, err)
	_, err = w.AddCircle(2, 2, 1)
	require.NoError(t, err)
	_, err = w.AddLine(9, 5, 1, 5)
	require.NoError(t, err)

	for _, obj := range w.objects {
		w.indexObject(obj)
	}

	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        4, // threshold=2
		MarginYPx:        4, // threshold=2
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
	}

	// First render initializes state (full redraw).
	d0 := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d0, params))

	// Pan right by 1 unit => dx=-1 => incremental shift expected.
	params2 := params
	params2.CameraXWorldFp += 1 * SCALE

	d := &fakePrimitiveDrawer{}
	err = w.Render(d, params2)
	require.NoError(t, err)

	// Must contain CopyShift for incremental path.
	require.NotEmpty(t, d.CommandsByName("CopyShift"))

	// All clip rects should be "small": width <= 1 for dx=-1 strip.
	clipCmds := d.CommandsByName("ClipRect")
	require.NotEmpty(t, clipCmds)
	for _, c := range clipCmds {
		wPx := int(c.Args[2])
		hPx := int(c.Args[3])
		require.LessOrEqual(t, wPx, 2)
		require.LessOrEqual(t, hPx, params2.CanvasHeightPx())
	}

	require.NotEmpty(t, d.CommandsByName("AddPoint"))
	require.NotEmpty(t, d.CommandsByName("AddCircle"))
	require.NotEmpty(t, d.CommandsByName("AddLine"))
}

// TestRender_PanTooLarge_FallsBackToFullRedraw verifies render Pan Too Large Falls Back To Full Redraw.
func TestRender_PanTooLarge_FallsBackToFullRedraw(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	_, err := w.AddPoint(5, 5)
	require.NoError(t, err)
	for _, obj := range w.objects {
		w.indexObject(obj)
	}

	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        4, // threshold=2
		MarginYPx:        4,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
	}

	d0 := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d0, params))

	// Pan right by 3 units => abs(dx)=3 > threshold(2) => full redraw expected.
	params2 := params
	params2.CameraXWorldFp += 3 * SCALE

	d := &fakePrimitiveDrawer{}
	err = w.Render(d, params2)
	require.NoError(t, err)

	// Full redraw should NOT call CopyShift.
	require.Empty(t, d.CommandsByName("CopyShift"))

	// Full redraw should clear the entire canvas.
	require.NotEmpty(t, d.CommandsByName("ClearAllTo"))

	// And should draw something (at least the point).
	// Depending on your implementation, it might be AddPoint or AddCircle/AddLine as well.
	require.NotEmpty(t, d.CommandsByName("AddPoint"))
}

// TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesPositive verifies world Delta Fixed To Canvas Px Remainder Accumulates Positive.
func TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesPositive(t *testing.T) {
	t.Parallel()

	// zoom=1: px = (deltaWorldFp * 1000) / 1e6
	// For deltaWorldFp=1, each step contributes 0 px with remainder,
	// and after 1000 steps it must become 1 px total.
	zoomFp := SCALE
	var rem int64
	sum := 0

	for i := 0; i < 1000; i++ {
		sum += worldDeltaFixedToCanvasPx(1, zoomFp, &rem)
	}

	require.Equal(t, 1, sum)
}

// TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesNegative verifies world Delta Fixed To Canvas Px Remainder Accumulates Negative.
func TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesNegative(t *testing.T) {
	t.Parallel()

	zoomFp := SCALE
	var rem int64
	sum := 0

	for i := 0; i < 1000; i++ {
		sum += worldDeltaFixedToCanvasPx(-1, zoomFp, &rem)
	}

	require.Equal(t, -1, sum)
}

// TestComputePanShiftPx_FirstCallRequiresFullRedraw verifies compute Pan Shift Px First Call Requires Full Redraw.
func TestComputePanShiftPx_FirstCallRequiresFullRedraw(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)

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

	_, _, err := w.ComputePanShiftPx(params)
	require.ErrorIs(t, err, errIncrementalStateNotReady)
}

// TestComputePanShiftPx_ZoomOrViewportChangeForcesFullRedraw verifies compute Pan Shift Px Zoom Or Viewport Change Forces Full Redraw.
func TestComputePanShiftPx_ZoomOrViewportChangeForcesFullRedraw(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)

	base := RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 80,
		MarginXPx:        25,
		MarginYPx:        20,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
	}
	require.NoError(t, w.CommitFullRedrawState(base))

	changed := base
	changed.CameraZoom = 2.0

	_, _, err := w.ComputePanShiftPx(changed)
	require.ErrorIs(t, err, errIncrementalZoomMismatch)

	changed2 := base
	changed2.ViewportWidthPx = 101

	_, _, err = w.ComputePanShiftPx(changed2)
	require.ErrorIs(t, err, errIncrementalZoomMismatch)
}

// TestComputePanShiftPx_PanRightShiftsImageLeft verifies compute Pan Shift Px Pan Right Shifts Image Left.
func TestComputePanShiftPx_PanRightShiftsImageLeft(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)

	params := RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 80,
		MarginXPx:        25,
		MarginYPx:        20,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
	}
	require.NoError(t, w.CommitFullRedrawState(params))

	// Move camera right by 1 world unit => world rect minX increases by 1 unit,
	// so content moves left by 1px at zoom=1 => image shift should be -1.
	params2 := params
	params2.CameraXWorldFp += 1 * SCALE

	dx, dy, err := w.ComputePanShiftPx(params2)
	require.NoError(t, err)
	require.Equal(t, -1, dx)
	require.Equal(t, 0, dy)
}

// TestComputePanShiftPx_PanUpShiftsImageDown verifies compute Pan Shift Px Pan Up Shifts Image Down.
func TestComputePanShiftPx_PanUpShiftsImageDown(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)

	params := RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 80,
		MarginXPx:        25,
		MarginYPx:        20,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
	}
	require.NoError(t, w.CommitFullRedrawState(params))

	// Move camera up by 1 world unit => world rect minY decreases by 1 unit,
	// so content moves down by 1px => image shift should be +1 in dy.
	params2 := params
	params2.CameraYWorldFp -= 1 * SCALE

	dx, dy, err := w.ComputePanShiftPx(params2)
	require.NoError(t, err)
	require.Equal(t, 0, dx)
	require.Equal(t, 1, dy)
}

// TestComputePanShiftPx_SubPixelPanAccumulatesToOnePixel verifies compute Pan Shift Px Sub Pixel Pan Accumulates To One Pixel.
func TestComputePanShiftPx_SubPixelPanAccumulatesToOnePixel(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)

	params := RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 80,
		MarginXPx:        25,
		MarginYPx:        20,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
	}
	require.NoError(t, w.CommitFullRedrawState(params))

	// Pan camera right by 0.001 world units (1 fixed-point) 1000 times.
	// At zoom=1 this should accumulate to a 1px content shift left, hence image shift -1.
	totalDx := 0
	p := params
	for i := 0; i < 1000; i++ {
		p.CameraXWorldFp += 1
		dx, dy, err := w.ComputePanShiftPx(p)
		require.NoError(t, err)
		require.Equal(t, 0, dy)
		totalDx += dx
	}

	require.Equal(t, -1, totalDx)
}

// TestTorusShortestLineSegments_TieCaseIsDeterministicAndSplits verifies torus Shortest Line Segments Tie Case Is Deterministic And Splits.
func TestTorusShortestLineSegments_TieCaseIsDeterministicAndSplits(t *testing.T) {
	t.Parallel()

	// World 10 units => 10000 fixed.
	worldW := 10 * SCALE
	worldH := 10 * SCALE

	// Tie-case along X: 1 -> 6 is exactly half world apart (dx = +5000).
	// Deterministic rule chooses negative delta representation (wrap is applied).
	l := Line{
		X1: 1 * SCALE, Y1: 5 * SCALE,
		X2: 6 * SCALE, Y2: 5 * SCALE,
	}

	segs := torusShortestLineSegments(l, worldW, worldH)

	// Expect two horizontal segments:
	// [6000..10000] and [0..1000] at y=5000.
	require.Len(t, segs, 2)

	// Direction is deterministic and follows the chosen negative-delta representation.
	require.Equal(t, lineSeg{x1: 1000, y1: 5000, x2: 0, y2: 5000}, segs[0])
	require.Equal(t, lineSeg{x1: 10000, y1: 5000, x2: 6000, y2: 5000}, segs[1])
}

// TestLines_NoWrap_TieCaseDoesNotWrap verifies lines No Wrap Tie Case Does Not Wrap.
func TestLines_NoWrap_TieCaseDoesNotWrap(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	// Tie-case along X: 1 -> 6 in world of 10.
	_, err := w.AddLine(1, 5, 6, 5)
	require.NoError(t, err)
	for _, obj := range w.objects {
		w.indexObject(obj)
	}

	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        0,
		MarginYPx:        0,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
		Options: &RenderOptions{
			DisableWrapScroll: true,
			Layers:            []RenderLayer{RenderLayerLines},
		},
	}

	d := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d, params))

	lines := d.CommandsByName("AddLine")
	require.Len(t, lines, 1)

	// At zoom=1 and margin=0, world==canvas, so pixels equal world units.
	require.Equal(t, []float64{1, 5, 6, 5}, lines[0].Args)
}

// TestDrawPointsFromPlan_DuplicatesAcrossTilesAndClips verifies draw Points From Plan Duplicates Across Tiles And Clips.
func TestDrawPointsFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) {
	t.Parallel()

	// World is 10x10 world units => 10000x10000 fixed.
	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	// Place a point near the origin so that expanded canvas (larger than world)
	// will require torus repetition and the point will appear in multiple tiles.
	id, err := w.AddPoint(1.0, 1.0) // (1000,1000)
	require.NoError(t, err)

	// Index only this object.
	w.indexObject(w.objects[id])

	// Choose viewport such that viewport==world in pixels at zoom=1:
	// - With zoom=1 (zoomFp=SCALE), 1 world unit maps to 1 px.
	// - world width=10 units => 10 px.
	// Use margin=2 px on each side => canvas 14x14 px => expanded world span 14 units > world.
	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        2,
		MarginYPx:        2,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
	}

	plan, err := w.buildRenderPlan(params)
	require.NoError(t, err)

	d := &fakePrimitiveDrawer{}
	drawPointsFromPlan(d, plan, true)

	// We expect 4 point copies:
	// (tx=0,ty=0), (tx=0,ty=1), (tx=1,ty=0), (tx=1,ty=1)
	// due to expanded rect spanning beyond world on both axes.
	wantNames := []string{
		"Save", "ClipRect", "AddPoint", "Fill", "Restore",
		"Save", "ClipRect", "AddPoint", "Fill", "Restore",
		"Save", "ClipRect", "AddPoint", "Fill", "Restore",
		"Save", "ClipRect", "AddPoint", "Fill", "Restore",
	}
	require.Equal(t, wantNames, d.CommandNames())

	pointRadiusPx := DefaultRenderStyle().PointRadiusPx

	// Command group 1: tile (offsetX=0, offsetY=0), clip should be (2,2,10,10), point at (3,3).
	{
		clip := requireDrawerCommandAt(t, d, 1)
		require.Equal(t, "ClipRect", clip.Name)
		requireCommandArgs(t, clip, 2, 2, 10, 10)

		pt := requireDrawerCommandAt(t, d, 2)
		require.Equal(t, "AddPoint", pt.Name)
		requireCommandArgs(t, pt, 3, 3, pointRadiusPx)
	}

	// Command group 2: tile (offsetX=0, offsetY=10000), clip (2,12,10,2), point at (3,13).
	{
		clip := requireDrawerCommandAt(t, d, 6)
		require.Equal(t, "ClipRect", clip.Name)
		requireCommandArgs(t, clip, 2, 12, 10, 2)

		pt := requireDrawerCommandAt(t, d, 7)
		require.Equal(t, "AddPoint", pt.Name)
		requireCommandArgs(t, pt, 3, 13, pointRadiusPx)
	}

	// Command group 3: tile (offsetX=10000, offsetY=0), clip (12,2,2,10), point at (13,3).
	{
		clip := requireDrawerCommandAt(t, d, 11)
		require.Equal(t, "ClipRect", clip.Name)
		requireCommandArgs(t, clip, 12, 2, 2, 10)

		pt := requireDrawerCommandAt(t, d, 12)
		require.Equal(t, "AddPoint", pt.Name)
		requireCommandArgs(t, pt, 13, 3, pointRadiusPx)
	}

	// Command group 4: tile (offsetX=10000, offsetY=10000), clip (12,12,2,2), point at (13,13).
	{
		clip := requireDrawerCommandAt(t, d, 16)
		require.Equal(t, "ClipRect", clip.Name)
		requireCommandArgs(t, clip, 12, 12, 2, 2)

		pt := requireDrawerCommandAt(t, d, 17)
		require.Equal(t, "AddPoint", pt.Name)
		requireCommandArgs(t, pt, 13, 13, pointRadiusPx)
	}
}

// TestDrawPointsFromPlan_SkipsTilesWithoutPoints verifies draw Points From Plan Skips Tiles Without Points.
func TestDrawPointsFromPlan_SkipsTilesWithoutPoints(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	// Add only a line, no points.
	id, err := w.AddLine(2, 2, 8, 2)
	require.NoError(t, err)
	w.indexObject(w.objects[id])

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

	plan, err := w.buildRenderPlan(params)
	require.NoError(t, err)

	d := &fakePrimitiveDrawer{}
	drawPointsFromPlan(d, plan, true)

	// No points => no drawing commands at all.
	require.Empty(t, d.Commands())
}

// TestWorldRender_PointsOnlyStageA verifies world Render Points Only Stage A.
func TestWorldRender_PointsOnlyStageA(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	_, err := w.AddPoint(5, 5)
	require.NoError(t, err)

	// Build index. In real UI it happens via IndexOnViewportChange.
	for _, obj := range w.objects {
		w.indexObject(obj)
	}

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

	d := &fakePrimitiveDrawer{}
	err = w.Render(d, params)
	require.NoError(t, err)

	// At least one point draw should happen.
	require.Contains(t, d.CommandNames(), "AddPoint")
}

// TestPoints_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld verifies points Wrap Copies Appear Inside Viewport When Viewport Equals World.
func TestPoints_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testing.T) {
	t.Parallel()

	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        0,
		MarginYPx:        0,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
		Options: &RenderOptions{
			Style: func() *RenderStyle {
				s := DefaultRenderStyle()
				s.PointRadiusPx = 2.0 // so that a point at 9 "spills" by 1 and needs a copy at -1
				return &s
			}(),
		},
	}

	type tc struct {
		name        string
		x, y        float64
		wantCenters [][2]float64
	}

	tests := []tc{
		{
			name:        "bottom boundary wraps to top",
			x:           5,
			y:           9,
			wantCenters: [][2]float64{{5, 9}, {5, -1}},
		},
		{
			name:        "right boundary wraps to left",
			x:           9,
			y:           5,
			wantCenters: [][2]float64{{9, 5}, {-1, 5}},
		},
		{
			name:        "corner wraps to three extra copies",
			x:           9,
			y:           9,
			wantCenters: [][2]float64{{9, 9}, {9, -1}, {-1, 9}, {-1, -1}},
		},
		{
			name:        "no wrap inside",
			x:           5,
			y:           5,
			wantCenters: [][2]float64{{5, 5}},
		},
	}

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

			w := NewWorld(10, 10)
			w.resetGrid(2 * SCALE)

			_, err := w.AddPoint(tt.x, tt.y)
			require.NoError(t, err)

			for _, obj := range w.objects {
				w.indexObject(obj)
			}

			plan, err := w.buildRenderPlan(params)
			require.NoError(t, err)

			d := &fakePrimitiveDrawer{}
			style := DefaultRenderStyle()
			style.PointRadiusPx = 2.0

			applyPointStyle(d, style)
			drawPointsFromPlanWithRadius(d, plan, w.W, w.H, style.PointRadiusPx, true)

			cmds := d.CommandsByName("AddPoint")
			require.Len(t, cmds, len(tt.wantCenters))

			got := make([][2]float64, 0, len(cmds))
			for _, c := range cmds {
				require.Len(t, c.Args, 3)
				got = append(got, [2]float64{c.Args[0], c.Args[1]})
			}

			require.ElementsMatch(t, tt.wantCenters, got)
		})
	}
}

// TestWorldRender_DrawsAllLayersInDefaultOrder verifies world Render Draws All Layers In Default Order.
func TestWorldRender_DrawsAllLayersInDefaultOrder(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	_, err := w.AddPoint(1, 1)
	require.NoError(t, err)
	_, err = w.AddCircle(2, 2, 1)
	require.NoError(t, err)
	_, err = w.AddLine(9, 5, 1, 5)
	require.NoError(t, err)

	for _, obj := range w.objects {
		w.indexObject(obj)
	}

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

	d := &fakePrimitiveDrawer{}
	err = w.Render(d, params)
	require.NoError(t, err)

	names := d.CommandNames()
	require.Contains(t, names, "AddPoint")
	require.Contains(t, names, "AddCircle")
	require.Contains(t, names, "AddLine")
}

// TestSmoke_DrawPointsAndCirclesFromSamePlan verifies smoke Draw Points And Circles From Same Plan.
func TestSmoke_DrawPointsAndCirclesFromSamePlan(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	_, err := w.AddPoint(1, 1)
	require.NoError(t, err)
	_, err = w.AddCircle(2, 2, 1)
	require.NoError(t, err)

	for _, obj := range w.objects {
		w.indexObject(obj)
	}

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

	plan, err := w.buildRenderPlan(params)
	require.NoError(t, err)

	d := &fakePrimitiveDrawer{}
	drawPointsFromPlan(d, plan, true)
	drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp)

	names := d.CommandNames()
	require.Contains(t, names, "AddPoint")
	require.Contains(t, names, "AddCircle")
}

// TestRender_AppliesStyleBeforeAddCommands_ForFirstItemInTile verifies render Applies Style Before Add Commands For First Item In Tile.
func TestRender_AppliesStyleBeforeAddCommands_ForFirstItemInTile(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	// Create a derived circle style so we can observe a style application transition.
	red := color.RGBA{R: 255, A: 255}
	styleID := w.AddStyleCircle(StyleOverride{FillColor: red})

	_, err := w.AddCircle(5, 5, 1, CircleWithStyleID(styleID), CircleWithPriority(100))
	require.NoError(t, err)

	for _, obj := range w.objects {
		w.indexObject(obj)
	}

	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        0,
		MarginYPx:        0,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
		Options: &RenderOptions{
			BackgroundColor: color.RGBA{A: 255},
		},
	}

	d := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d, params))

	cmds := d.Commands()

	iSetFill := indexOfFirstName(cmds, "SetFillColor")
	iAddCircle := indexOfFirstName(cmds, "AddCircle")
	require.NotEqual(t, -1, iSetFill)
	require.NotEqual(t, -1, iAddCircle)

	require.Less(t, iSetFill, iAddCircle, "style must be applied before AddCircle")
}

// TestRender_DoesNotReapplySameStyleAcrossMultipleObjects verifies render Does Not Reapply Same Style Across Multiple Objects.
func TestRender_DoesNotReapplySameStyleAcrossMultipleObjects(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	// Two lines with the same default line style and same priority.
	_, err := w.AddLine(1, 5, 9, 5, LineWithPriority(100))
	require.NoError(t, err)
	_, err = w.AddLine(1, 6, 9, 6, LineWithPriority(101)) // ensure deterministic order by priority
	require.NoError(t, err)

	for _, obj := range w.objects {
		w.indexObject(obj)
	}

	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        0,
		MarginYPx:        0,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
		Options: &RenderOptions{
			BackgroundColor: color.RGBA{A: 255},
		},
	}

	d := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d, params))

	// We expect style application at least once.
	setWidth := d.CommandsByName("SetLineWidth")
	require.NotEmpty(t, setWidth)

	// The key batching assertion: style setters should not be called twice *between* two AddLine calls.
	cmds := d.Commands()
	line1 := indexOfFirstName(cmds, "AddLine")
	require.NotEqual(t, -1, line1)

	line2 := indexOfNextName(cmds, "AddLine", line1+1)
	require.NotEqual(t, -1, line2)

	// Between line1 and line2 there must be no SetLineWidth / SetStrokeColor / SetDash / SetDashOffset,
	// because StyleID is the same and the renderer caches lastStyleID.
	for i := line1 + 1; i < line2; i++ {
		switch cmds[i].Name {
		case "SetLineWidth", "SetStrokeColor", "SetDash", "SetDashOffset", "SetFillColor":
			t.Fatalf("unexpected style setter %q between two AddLine commands at index %d", cmds[i].Name, i)
		}
	}
}

// TestRender_ReappliesStyleWhenStyleIDChanges verifies render Reapplies Style When Style ID Changes.
func TestRender_ReappliesStyleWhenStyleIDChanges(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)
	w.resetGrid(2 * SCALE)

	// Two circles, different derived fill colors => different StyleIDs.
	red := color.RGBA{R: 255, A: 255}
	green := color.RGBA{G: 255, A: 255}

	styleRed := w.AddStyleCircle(StyleOverride{FillColor: red})
	styleGreen := w.AddStyleCircle(StyleOverride{FillColor: green})

	_, err := w.AddCircle(4, 5, 1, CircleWithStyleID(styleRed), CircleWithPriority(100))
	require.NoError(t, err)
	_, err = w.AddCircle(6, 5, 1, CircleWithStyleID(styleGreen), CircleWithPriority(101))
	require.NoError(t, err)

	for _, obj := range w.objects {
		w.indexObject(obj)
	}

	params := RenderParams{
		ViewportWidthPx:  10,
		ViewportHeightPx: 10,
		MarginXPx:        0,
		MarginYPx:        0,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
		Options: &RenderOptions{
			BackgroundColor: color.RGBA{A: 255},
		},
	}

	d := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d, params))

	cmds := d.Commands()
	firstCircle := indexOfFirstName(cmds, "AddCircle")
	secondCircle := indexOfNextName(cmds, "AddCircle", firstCircle+1)
	require.NotEqual(t, -1, firstCircle)
	require.NotEqual(t, -1, secondCircle)

	// There must be at least one SetFillColor before each circle.
	// And importantly, we expect a SetFillColor BETWEEN the two circles due to style change.
	setBeforeFirst := lastIndexOfNameBefore(cmds, "SetFillColor", firstCircle)
	require.NotEqual(t, -1, setBeforeFirst)

	setBetween := indexOfFirstNameInRange(cmds, "SetFillColor", firstCircle+1, secondCircle)
	require.NotEqual(t, -1, setBetween, "expected style reapply (SetFillColor) between circles with different StyleIDs")
}

/* ---------- helper functions for fake command slices ---------- */

func indexOfFirstName(cmds []fakeDrawerCommand, name string) int {
	for i, c := range cmds {
		if c.Name == name {
			return i
		}
	}
	return -1
}

func indexOfNextName(cmds []fakeDrawerCommand, name string, start int) int {
	for i := start; i < len(cmds); i++ {
		if cmds[i].Name == name {
			return i
		}
	}
	return -1
}

func lastIndexOfNameBefore(cmds []fakeDrawerCommand, name string, before int) int {
	if before > len(cmds) {
		before = len(cmds)
	}
	for i := before - 1; i >= 0; i-- {
		if cmds[i].Name == name {
			return i
		}
	}
	return -1
}

func indexOfFirstNameInRange(cmds []fakeDrawerCommand, name string, start, end int) int {
	if start < 0 {
		start = 0
	}
	if end > len(cmds) {
		end = len(cmds)
	}
	for i := start; i < end; i++ {
		if cmds[i].Name == name {
			return i
		}
	}
	return -1
}

// rendererTestEnv groups the common mutable inputs used by renderer tests.
// The environment stores independent horizontal and vertical margins because
// the expanded canvas geometry is derived separately on each axis.
type rendererTestEnv struct {
	world  *World
	drawer *fakePrimitiveDrawer

	// Viewport origin and size in canvas pixel coordinates.
	viewportX int
	viewportY int
	viewportW int
	viewportH int

	// Independent margins around the viewport in canvas pixels.
	marginXPx int
	marginYPx int

	// Final expanded canvas size in pixels.
	// In the default setup:
	//   canvasW = viewportW + 2*marginXPx
	//   canvasH = viewportH + 2*marginYPx
	canvasW int
	canvasH int

	// Camera center in fixed-point world coordinates.
	cameraX int
	cameraY int

	// Camera zoom in fixed-point representation, if needed by renderer internals.
	zoomFp int
}

// newRendererTestEnv returns a baseline renderer test environment.
// The default margins are derived independently from viewport width and height.
func newRendererTestEnv() *rendererTestEnv {
	viewportW := 100
	viewportH := 80

	marginXPx := viewportW / 4
	marginYPx := viewportH / 4

	return &rendererTestEnv{
		world:     NewWorld(10, 10),
		drawer:    &fakePrimitiveDrawer{},
		viewportX: marginXPx,
		viewportY: marginYPx,
		viewportW: viewportW,
		viewportH: viewportH,
		marginXPx: marginXPx,
		marginYPx: marginYPx,
		canvasW:   viewportW + 2*marginXPx,
		canvasH:   viewportH + 2*marginYPx,
		cameraX:   5 * SCALE,
		cameraY:   5 * SCALE,
		zoomFp:    SCALE,
	}
}

// setViewport resets viewport-dependent fields and recomputes margins
// using the default test formula:
//
//	marginXPx = viewportW / 4
//	marginYPx = viewportH / 4
func (env *rendererTestEnv) setViewport(viewportW, viewportH int) {
	env.viewportW = viewportW
	env.viewportH = viewportH

	env.marginXPx = viewportW / 4
	env.marginYPx = viewportH / 4

	env.viewportX = env.marginXPx
	env.viewportY = env.marginYPx

	env.canvasW = env.viewportW + 2*env.marginXPx
	env.canvasH = env.viewportH + 2*env.marginYPx
}

// setViewportAndMargins overrides viewport and margins explicitly.
// This is useful for edge cases where the expanded canvas geometry
// must be controlled exactly.
func (env *rendererTestEnv) setViewportAndMargins(viewportW, viewportH, marginXPx, marginYPx int) {
	env.viewportW = viewportW
	env.viewportH = viewportH

	env.marginXPx = marginXPx
	env.marginYPx = marginYPx

	env.viewportX = env.marginXPx
	env.viewportY = env.marginYPx

	env.canvasW = env.viewportW + 2*env.marginXPx
	env.canvasH = env.viewportH + 2*env.marginYPx
}

// viewportRect returns the viewport rectangle in canvas pixel coordinates.
func (env *rendererTestEnv) viewportRect() (x, y, w, h float64) {
	return float64(env.viewportX), float64(env.viewportY), float64(env.viewportW), float64(env.viewportH)
}

// canvasRect returns the full expanded canvas rectangle in canvas pixel coordinates.
func (env *rendererTestEnv) canvasRect() (x, y, w, h float64) {
	return 0, 0, float64(env.canvasW), float64(env.canvasH)
}

// worldMustAddPoint adds a point to the test world and fails the test on error.
func worldMustAddPoint(t *testing.T, w *World, x, y float64) {
	t.Helper()

	_, err := w.AddPoint(x, y)
	require.NoError(t, err)
}

// worldMustAddCircle adds a circle to the test world and fails the test on error.
func worldMustAddCircle(t *testing.T, w *World, x, y, r float64) {
	t.Helper()

	_, err := w.AddCircle(x, y, r)
	require.NoError(t, err)
}

// worldMustAddLine adds a line to the test world and fails the test on error.
func worldMustAddLine(t *testing.T, w *World, x1, y1, x2, y2 float64) {
	t.Helper()

	_, err := w.AddLine(x1, y1, x2, y2)
	require.NoError(t, err)
}

// requireNoDrawerCommands asserts that the renderer produced no drawing commands.
func requireNoDrawerCommands(t *testing.T, d *fakePrimitiveDrawer) {
	t.Helper()

	require.Empty(t, d.Commands())
}

// requireStrokeCommandAt returns a command and asserts that it is Stroke.
func requireStrokeCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
	t.Helper()

	cmd := requireDrawerCommandAt(t, d, index)
	requireCommandName(t, cmd, "Stroke")
	return cmd
}

// requireFillCommandAt returns a command and asserts that it is Fill.
func requireFillCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
	t.Helper()

	cmd := requireDrawerCommandAt(t, d, index)
	requireCommandName(t, cmd, "Fill")
	return cmd
}

// requireAddPointCommandAt returns a command and asserts that it is AddPoint.
func requireAddPointCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
	t.Helper()

	cmd := requireDrawerCommandAt(t, d, index)
	requireCommandName(t, cmd, "AddPoint")
	return cmd
}

// requireAddLineCommandAt returns a command and asserts that it is AddLine.
func requireAddLineCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
	t.Helper()

	cmd := requireDrawerCommandAt(t, d, index)
	requireCommandName(t, cmd, "AddLine")
	return cmd
}

// requireAddCircleCommandAt returns a command and asserts that it is AddCircle.
func requireAddCircleCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
	t.Helper()

	cmd := requireDrawerCommandAt(t, d, index)
	requireCommandName(t, cmd, "AddCircle")
	return cmd
}

// requireSingleClipRectOnCommand asserts that the command was issued under exactly one clip rect.
func requireSingleClipRectOnCommand(t *testing.T, cmd fakeDrawerCommand, x, y, w, h float64) {
	t.Helper()

	requireCommandClipRects(t, cmd, fakeClipRect{
		X: x,
		Y: y,
		W: w,
		H: h,
	})
}

// rendererTestCase is a generic table-driven renderer test scaffold.
// Replace invoke with the real renderer call once the renderer exists.
type rendererTestCase struct {
	name string

	// setup prepares the world and optional environment overrides.
	setup func(t *testing.T, env *rendererTestEnv)

	// invoke calls the renderer under test.
	invoke func(t *testing.T, env *rendererTestEnv)

	// verify checks the produced fake drawer log.
	verify func(t *testing.T, env *rendererTestEnv)
}

func runRendererTestCases(t *testing.T, cases []rendererTestCase) {
	t.Helper()

	for _, tc := range cases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			env := newRendererTestEnv()

			if tc.setup != nil {
				tc.setup(t, env)
			}

			require.NotNil(t, tc.invoke, "renderer test case must define invoke")
			require.NotNil(t, tc.verify, "renderer test case must define verify")

			tc.invoke(t, env)
			tc.verify(t, env)
		})
	}
}

// TestRenderer_Template_PointCases is a scaffold for future point renderer tests.
func TestRenderer_Template_PointCases(t *testing.T) {
	t.Parallel()

	runRendererTestCases(t, []rendererTestCase{
		{
			name: "point fully inside viewport",
			setup: func(t *testing.T, env *rendererTestEnv) {
				worldMustAddPoint(t, env.world, 5, 5)
			},
			invoke: func(t *testing.T, env *rendererTestEnv) {
				t.Skip("replace with actual renderer call")
			},
			verify: func(t *testing.T, env *rendererTestEnv) {
				requireNoDrawerCommands(t, env.drawer)
			},
		},
		{
			name: "point visible only in horizontal margin copy",
			setup: func(t *testing.T, env *rendererTestEnv) {
				env.setViewport(160, 40)
				worldMustAddPoint(t, env.world, 0.1, 5)
			},
			invoke: func(t *testing.T, env *rendererTestEnv) {
				t.Skip("replace with actual renderer call")
			},
			verify: func(t *testing.T, env *rendererTestEnv) {
				requireNoDrawerCommands(t, env.drawer)
			},
		},
		{
			name: "point visible only in vertical margin copy",
			setup: func(t *testing.T, env *rendererTestEnv) {
				env.setViewport(40, 160)
				worldMustAddPoint(t, env.world, 5, 0.1)
			},
			invoke: func(t *testing.T, env *rendererTestEnv) {
				t.Skip("replace with actual renderer call")
			},
			verify: func(t *testing.T, env *rendererTestEnv) {
				requireNoDrawerCommands(t, env.drawer)
			},
		},
		{
			name: "point duplicated across torus corner with independent margins",
			setup: func(t *testing.T, env *rendererTestEnv) {
				env.setViewportAndMargins(120, 60, 30, 10)
				worldMustAddPoint(t, env.world, 0.1, 0.1)
			},
			invoke: func(t *testing.T, env *rendererTestEnv) {
				t.Skip("replace with actual renderer call")
			},
			verify: func(t *testing.T, env *rendererTestEnv) {
				requireNoDrawerCommands(t, env.drawer)
			},
		},
	})
}

// TestRenderer_Template_LineCases is a scaffold for future line renderer tests.
func TestRenderer_Template_LineCases(t *testing.T) {
	t.Parallel()

	runRendererTestCases(t, []rendererTestCase{
		{
			name: "line fully inside viewport",
			setup: func(t *testing.T, env *rendererTestEnv) {
				worldMustAddLine(t, env.world, 2, 2, 8, 2)
			},
			invoke: func(t *testing.T, env *rendererTestEnv) {
				t.Skip("replace with actual renderer call")
			},
			verify: func(t *testing.T, env *rendererTestEnv) {
				requireNoDrawerCommands(t, env.drawer)
			},
		},
		{
			name: "line wrap copy across x edge",
			setup: func(t *testing.T, env *rendererTestEnv) {
				env.setViewport(160, 40)
				worldMustAddLine(t, env.world, 9, 5, 1, 5)
			},
			invoke: func(t *testing.T, env *rendererTestEnv) {
				t.Skip("replace with actual renderer call")
			},
			verify: func(t *testing.T, env *rendererTestEnv) {
				requireNoDrawerCommands(t, env.drawer)
			},
		},
		{
			name: "line wrap copy across y edge",
			setup: func(t *testing.T, env *rendererTestEnv) {
				env.setViewport(40, 160)
				worldMustAddLine(t, env.world, 5, 9, 5, 1)
			},
			invoke: func(t *testing.T, env *rendererTestEnv) {
				t.Skip("replace with actual renderer call")
			},
			verify: func(t *testing.T, env *rendererTestEnv) {
				requireNoDrawerCommands(t, env.drawer)
			},
		},
		{
			name: "line tie case uses deterministic wrapped representation",
			setup: func(t *testing.T, env *rendererTestEnv) {
				worldMustAddLine(t, env.world, 1, 5, 6, 5)
			},
			invoke: func(t *testing.T, env *rendererTestEnv) {
				t.Skip("replace with actual renderer call")
			},
			verify: func(t *testing.T, env *rendererTestEnv) {
				requireNoDrawerCommands(t, env.drawer)
			},
		},
	})
}

// TestRenderer_Template_CircleCases is a scaffold for future circle renderer tests.
func TestRenderer_Template_CircleCases(t *testing.T) {
	t.Parallel()

	runRendererTestCases(t, []rendererTestCase{
		{
			name: "circle fully inside viewport",
			setup: func(t *testing.T, env *rendererTestEnv) {
				worldMustAddCircle(t, env.world, 5, 5, 1)
			},
			invoke: func(t *testing.T, env *rendererTestEnv) {
				t.Skip("replace with actual renderer call")
			},
			verify: func(t *testing.T, env *rendererTestEnv) {
				requireNoDrawerCommands(t, env.drawer)
			},
		},
		{
			name: "circle duplicated across horizontal edge",
			setup: func(t *testing.T, env *rendererTestEnv) {
				env.setViewport(160, 40)
				worldMustAddCircle(t, env.world, 0.2, 5, 0.5)
			},
			invoke: func(t *testing.T, env *rendererTestEnv) {
				t.Skip("replace with actual renderer call")
			},
			verify: func(t *testing.T, env *rendererTestEnv) {
				requireNoDrawerCommands(t, env.drawer)
			},
		},
		{
			name: "circle duplicated across vertical edge",
			setup: func(t *testing.T, env *rendererTestEnv) {
				env.setViewport(40, 160)
				worldMustAddCircle(t, env.world, 5, 0.2, 0.5)
			},
			invoke: func(t *testing.T, env *rendererTestEnv) {
				t.Skip("replace with actual renderer call")
			},
			verify: func(t *testing.T, env *rendererTestEnv) {
				requireNoDrawerCommands(t, env.drawer)
			},
		},
		{
			name: "circle duplicated across corner with asymmetric margins",
			setup: func(t *testing.T, env *rendererTestEnv) {
				env.setViewportAndMargins(120, 60, 30, 10)
				worldMustAddCircle(t, env.world, 0.2, 0.2, 0.5)
			},
			invoke: func(t *testing.T, env *rendererTestEnv) {
				t.Skip("replace with actual renderer call")
			},
			verify: func(t *testing.T, env *rendererTestEnv) {
				requireNoDrawerCommands(t, env.drawer)
			},
		},
	})
}

type pointRadiusTheme struct {
	id     string
	radius float64
}

func (t pointRadiusTheme) ID() string   { return t.id }
func (t pointRadiusTheme) Name() string { return t.id }

func (t pointRadiusTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (t pointRadiusTheme) BackgroundImage() image.Image { return nil }

func (t pointRadiusTheme) BackgroundTileMode() BackgroundTileMode     { return BackgroundTileNone }
func (t pointRadiusTheme) BackgroundScaleMode() BackgroundScaleMode   { return BackgroundScaleNone }
func (t pointRadiusTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld }

func (t pointRadiusTheme) PointStyle() Style {
	return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: t.radius}
}
func (t pointRadiusTheme) LineStyle() Style {
	return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (t pointRadiusTheme) CircleStyle() Style {
	return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}

func (t pointRadiusTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
	return StyleOverride{}, false
}
func (t pointRadiusTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
	return StyleOverride{}, false
}
func (t pointRadiusTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
	return StyleOverride{}, false
}

// TestRender_ThemeChange_AppliesWithoutReindex_UsesLatestObjectStyles verifies render Theme Change Applies Without Reindex Uses Latest Object Styles.
func TestRender_ThemeChange_AppliesWithoutReindex_UsesLatestObjectStyles(t *testing.T) {
	t.Parallel()

	w := NewWorld(10, 10)

	// Build index once.
	w.IndexOnViewportChange(100, 100, 1.0)

	// Theme A: point radius 2
	w.SetTheme(pointRadiusTheme{id: "A", radius: 2})

	_, err := w.AddPoint(5, 5)
	require.NoError(t, err)

	// Ensure the point is actually present in grid (it will be, because Add triggers rebuild via index state).
	// Render once.
	params := RenderParams{
		ViewportWidthPx:  100,
		ViewportHeightPx: 100,
		MarginXPx:        0,
		MarginYPx:        0,
		CameraXWorldFp:   5 * SCALE,
		CameraYWorldFp:   5 * SCALE,
		CameraZoom:       1.0,
	}

	d1 := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d1, params))

	p1 := d1.CommandsByName("AddPoint")
	require.NotEmpty(t, p1)
	r1 := p1[0].Args[2]
	require.Equal(t, 2.0, r1)

	// Theme B: point radius 7. Change theme, but DO NOT reindex.
	w.SetTheme(pointRadiusTheme{id: "B", radius: 7})

	d2 := &fakePrimitiveDrawer{}
	require.NoError(t, w.Render(d2, params))

	p2 := d2.CommandsByName("AddPoint")
	require.NotEmpty(t, p2)
	r2 := p2[0].Args[2]
	require.Equal(t, 7.0, r2)
}
