package world

import (
	"errors"
	"image"
	"image/color"
	"sort"
	"sync"
	"time"
)

// RenderLayer identifies one drawing pass.
type RenderLayer int

const (
	RenderLayerPoints RenderLayer = iota
	RenderLayerCircles
	RenderLayerLines
)

const (
	drawPlanSinglePassClipEnabled = false

	// best value according to BenchmarkDrawPlanSinglePass_Lines_GG
	maxLineSegmentsPerStroke = 32
)

// RenderOptions controls which layers are rendered and their order.
// If Layers is empty, the default order is: Points, Circles, Lines.
type RenderOptions struct {
	Layers []RenderLayer
	Style  *RenderStyle
	// Incremental controls incremental pan behavior. If nil, defaults are used.
	Incremental *IncrementalPolicy
	// DisableWrapScroll controls whether the world is treated as a torus (false)
	// or as a bounded plane without wrap (true).
	// Default is false.
	DisableWrapScroll bool

	// BackgroundColor is used to clear full redraw and dirty regions.
	// If nil, default background is opaque black.
	BackgroundColor color.Color
}

var (
	errInvalidViewportSize = errors.New("render: invalid viewport size")
	errInvalidMargins      = errors.New("render: invalid margins")
	errNilDrawer           = errors.New("render: nil drawer")
)

// RenderParams describes one render request coming from the UI layer.
//
// Camera coordinates are expressed in world fixed-point units and point to the
// center of the visible viewport. Margins are expressed in canvas pixels and
// extend the rendered area around the viewport on each axis independently.
//
// The final canvas size is derived from viewport size and margins:
//
//	canvasWidthPx  = viewportWidthPx  + 2*marginXPx
//	canvasHeightPx = viewportHeightPx + 2*marginYPx
type RenderParams struct {
	ViewportWidthPx  int
	ViewportHeightPx int

	MarginXPx int
	MarginYPx int

	CameraXWorldFp int
	CameraYWorldFp int

	CameraZoom float64

	// Optional render options. If nil, defaults are used.
	Options *RenderOptions

	// Used for various debugging purposes
	Debug bool
}

// CanvasWidthPx returns the full expanded canvas width in pixels.
func (p RenderParams) CanvasWidthPx() int { return p.ViewportWidthPx + 2*p.MarginXPx }

// CanvasHeightPx returns the full expanded canvas height in pixels.
func (p RenderParams) CanvasHeightPx() int { return p.ViewportHeightPx + 2*p.MarginYPx }

// CameraZoomFp converts the UI-facing zoom value into the package fixed-point form.
func (p RenderParams) CameraZoomFp() (int, error) {
	return CameraZoomToWorldFixed(p.CameraZoom)
}

// ExpandedCanvasWorldRect returns the world-space half-open rectangle covered by
// the full expanded canvas around the camera center.
//
// The returned rectangle is expressed in fixed-point world coordinates and is not
// wrapped into [0, W) x [0, H). It may extend beyond world bounds on either axis;
// torus normalization and tiling are handled later by the renderer pipeline.
func (p RenderParams) ExpandedCanvasWorldRect() (Rect, error) {
	zoomFp, err := p.CameraZoomFp()
	if err != nil {
		return Rect{}, err
	}

	return expandedCanvasWorldRect(
		p.CameraXWorldFp,
		p.CameraYWorldFp,
		p.CanvasWidthPx(),
		p.CanvasHeightPx(),
		zoomFp,
	), nil
}

// Validate checks whether the render request is internally consistent.
// Camera coordinates are intentionally not restricted here because the renderer
// is expected to normalize them through torus wrap.
func (p RenderParams) Validate() error {
	if p.ViewportWidthPx <= 0 || p.ViewportHeightPx <= 0 {
		return errInvalidViewportSize
	}
	if p.MarginXPx < 0 || p.MarginYPx < 0 {
		return errInvalidMargins
	}

	if _, err := p.CameraZoomFp(); err != nil {
		return err
	}

	if p.CanvasWidthPx() <= 0 || p.CanvasHeightPx() <= 0 {
		return errInvalidViewportSize
	}

	return nil
}

// expandedCanvasWorldRect computes the world-space half-open rectangle covered by
// a full expanded canvas centered on the camera.
//
// The rectangle is returned in fixed-point world coordinates and is not wrapped.
// A later renderer step is expected to tile and normalize it against torus bounds.
func expandedCanvasWorldRect(
	cameraXWorldFp, cameraYWorldFp int,
	canvasWidthPx, canvasHeightPx int,
	zoomFp int,
) Rect {
	if canvasWidthPx <= 0 || canvasHeightPx <= 0 {
		panic("expandedCanvasWorldRect: invalid canvas size")
	}
	if zoomFp <= 0 {
		panic("expandedCanvasWorldRect: invalid zoom")
	}

	worldWidthFp := PixelSpanToWorldFixed(canvasWidthPx, zoomFp)
	worldHeightFp := PixelSpanToWorldFixed(canvasHeightPx, zoomFp)

	minX := cameraXWorldFp - worldWidthFp/2
	minY := cameraYWorldFp - worldHeightFp/2

	return Rect{
		minX: minX,
		maxX: minX + worldWidthFp,
		minY: minY,
		maxY: minY + worldHeightFp,
	}
}

// Render draws the current world state onto the expanded canvas represented by drawer.
//
// Stage A implementation is expected to perform a full redraw of the entire
// expanded canvas. Incremental scrolling and canvas shifting are intentionally
// left for later stages.
//
// The renderer must treat the camera as looking at the center of the viewport,
// not the center of the full expanded canvas.
//
// The renderer performs three passes (layers) in a configurable order.
// The render plan (tiling + candidates + clips) is built once and reused.
func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error {
	if drawer == nil {
		return errNilDrawer
	}
	if err := params.Validate(); err != nil {
		return err
	}

	var bg color.Color = color.RGBA{A: 255} // default black

	if params.Options != nil && params.Options.BackgroundColor != nil {
		bg = params.Options.BackgroundColor
	} else {
		tc := w.Theme().BackgroundColor()
		if alphaNonZero(tc) {
			bg = tc
		}
	}

	allowWrap := params.Options == nil || !params.Options.DisableWrapScroll

	defer func() {
		if !params.Debug {
			return
		}
		drawer.AddLine(
			float64(params.MarginXPx),
			float64(params.MarginYPx),
			float64(params.MarginXPx+params.ViewportWidthPx),
			float64(params.MarginYPx))
		drawer.AddLine(
			float64(params.MarginXPx),
			float64(params.MarginYPx),
			float64(params.MarginXPx),
			float64(params.MarginYPx+params.ViewportHeightPx))
		drawer.AddLine(
			float64(params.MarginXPx+params.ViewportWidthPx),
			float64(params.MarginYPx),
			float64(params.MarginXPx+params.ViewportWidthPx),
			float64(params.MarginYPx+params.ViewportHeightPx))
		drawer.AddLine(
			float64(params.MarginXPx),
			float64(params.MarginYPx+params.ViewportHeightPx),
			float64(params.MarginXPx+params.ViewportWidthPx),
			float64(params.MarginYPx+params.ViewportHeightPx))
	}()

	startTs := time.Now()
	defer func() {
		// record dtRender for future overload heuristics
		w.renderState.lastRenderDurationNs = time.Since(startTs).Nanoseconds()
	}()

	policy := DefaultIncrementalPolicy()
	if params.Options != nil && params.Options.Incremental != nil {
		policy = *params.Options.Incremental
	}

	// Helper: draw one dirty rect with outer clip, using an already prepared dirtyPlan.
	// IMPORTANT: dirtyPlan must be built from the FULL set of dirty rects (union),
	// not from a single rect, to avoid missing primitives on diagonal pans.
	drawDirtyRect := func(dirtyPlan RenderPlan, r RectPx) error {
		if r.W <= 0 || r.H <= 0 {
			return nil
		}

		drawer.Save()
		drawer.ResetClip()
		drawer.ClipRect(float64(r.X), float64(r.Y), float64(r.W), float64(r.H))

		// Clear + background in the same clip.
		drawer.ClearRectTo(r.X, r.Y, r.W, r.H, bg)
		w.drawBackground(drawer, params, r)

		// Draw with outer clip only; do not rebuild plan per-rect.
		// isDirtyPass MUST be true here.
		w.drawPlanSinglePass(drawer, dirtyPlan, allowWrap, drawPlanSinglePassClipEnabled, true)

		drawer.Restore()
		return nil
	}

	// --- Try incremental path first when state is initialized and geometry matches ---
	dxPx, dyPx, derr := w.ComputePanShiftPx(params)
	if derr == nil {
		inc, perr := PlanIncrementalPan(
			params.CanvasWidthPx(),
			params.CanvasHeightPx(),
			params.MarginXPx,
			params.MarginYPx,
			dxPx,
			dyPx,
		)
		if perr != nil {
			return perr
		}

		switch inc.Mode {
		case IncrementalNoOp:
			// If we accumulated dirty regions during shift-only frames, redraw them now (bounded).
			if len(w.renderState.pendingDirty) > 0 {
				toDraw, remaining := takeCatchUpRects(w.renderState.pendingDirty, policy.MaxCatchUpAreaPx)
				w.renderState.pendingDirty = remaining

				if len(toDraw) == 0 {
					return nil
				}

				plan, err := w.buildRenderPlan(params)
				if err != nil {
					return err
				}

				// Build once for the whole set of catch-up rects (union), then clip per rect.
				catchUpPlan := planRestrictedToDirtyRects(plan, toDraw)

				for _, r := range toDraw {
					if err := drawDirtyRect(catchUpPlan, r); err != nil {
						return err
					}
				}
			}
			return nil

		case IncrementalShift:
			// Move existing pending dirty rects together with the backing image shift.
			if len(w.renderState.pendingDirty) > 0 {
				moved := make([]RectPx, 0, len(w.renderState.pendingDirty))
				for _, r := range w.renderState.pendingDirty {
					if rr, ok := shiftAndClipRectPx(r, inc.DxPx, inc.DyPx, params.CanvasWidthPx(), params.CanvasHeightPx()); ok {
						moved = append(moved, rr)
					}
				}
				w.renderState.pendingDirty = moved
			}
			// Shift backing pixels first.
			drawer.CopyShift(inc.DxPx, inc.DyPx)

			overBudget := false
			if policy.AllowShiftOnly && policy.RenderBudgetMs > 0 {
				budgetNs := int64(policy.RenderBudgetMs) * 1_000_000
				if w.renderState.lastRenderDurationNs > budgetNs {
					overBudget = true
				}
			}
			if overBudget {
				// Shift-only: defer drawing; remember newly exposed strips.
				if len(inc.Dirty) > 0 {
					w.renderState.pendingDirty = append(w.renderState.pendingDirty, inc.Dirty...)
				}
				return nil
			}

			// Under budget: draw newly exposed strips immediately, plus bounded catch-up.
			dirtyToDraw := inc.Dirty

			// Additionally redraw a bounded portion of deferred dirty regions.
			if len(w.renderState.pendingDirty) > 0 {
				catchUp, remaining := takeCatchUpRects(w.renderState.pendingDirty, policy.MaxCatchUpAreaPx)
				dirtyToDraw = append(dirtyToDraw, catchUp...)
				w.renderState.pendingDirty = remaining
			}

			if len(dirtyToDraw) == 0 {
				return nil
			}

			plan, err := w.buildRenderPlan(params)
			if err != nil {
				return err
			}

			// Build once for the union of all dirty rects.
			dirtyPlan := planRestrictedToDirtyRects(plan, dirtyToDraw)

			// Draw per-rect with outer clip; background/clear done inside helper.
			for _, r := range dirtyToDraw {
				if err := drawDirtyRect(dirtyPlan, r); err != nil {
					return err
				}
			}
			return nil

		case IncrementalFullRedraw:
			// Fall through to full redraw below.
		default:
			panic("render: unknown incremental mode")
		}
	}

	// --- Full redraw path ---
	plan, err := w.buildRenderPlan(params)
	if err != nil {
		return err
	}

	drawer.ClearAllTo(bg)
	w.drawBackground(drawer, params, RectPx{X: 0, Y: 0, W: params.CanvasWidthPx(), H: params.CanvasHeightPx()})
	w.drawPlanSinglePass(drawer, plan, allowWrap, drawPlanSinglePassClipEnabled, true)
	return w.CommitFullRedrawState(params)
}

// ForceFullRedrawNext resets internal incremental renderer state.
// After this call, the next Render() will use the full redraw path and
// re-initialize incremental state.
func (w *World) ForceFullRedrawNext() {
	w.renderState.Reset()
}

// WorldTile describes one torus tile contribution for an unwrapped world rect.
//
// Rect is the portion of the unwrapped rect mapped into the canonical world domain
// [0, worldWidthFp) x [0, worldHeightFp) as a half-open rectangle.
// OffsetX/OffsetY are the world-space tile offsets (multiples of world width/height)
// that map this canonical rect back into the unwrapped coordinate space.
type WorldTile struct {
	Rect    Rect
	OffsetX int
	OffsetY int
}

// tileWorldRect splits an unwrapped world-space rect into a set of tiles,
// each mapped into the canonical world domain [0, worldWidthFp) x [0, worldHeightFp).
//
// Unlike splitByWrap, this function does NOT collapse spans wider than the world.
// If rect spans multiple world widths/heights, it returns multiple tiles.
// The returned tiles are ordered by increasing tile X index, then by increasing tile Y index.
func tileWorldRect(rect Rect, worldWidthFp, worldHeightFp int) []WorldTile {
	if worldWidthFp <= 0 || worldHeightFp <= 0 {
		panic("tileWorldRect: non-positive world size")
	}

	width := rect.maxX - rect.minX
	height := rect.maxY - rect.minY
	if width <= 0 || height <= 0 {
		return nil
	}

	// Determine which torus tiles the rect intersects.
	// Since rect is half-open, use (max-1) for inclusive end.
	minTileX := floorDiv(rect.minX, worldWidthFp)
	maxTileX := floorDiv(rect.maxX-1, worldWidthFp)
	minTileY := floorDiv(rect.minY, worldHeightFp)
	maxTileY := floorDiv(rect.maxY-1, worldHeightFp)

	out := make([]WorldTile, 0, (maxTileX-minTileX+1)*(maxTileY-minTileY+1))

	for tx := minTileX; tx <= maxTileX; tx++ {
		tileBaseX := tx * worldWidthFp
		segMinX := max(rect.minX, tileBaseX)
		segMaxX := min(rect.maxX, tileBaseX+worldWidthFp)
		if segMinX >= segMaxX {
			continue
		}

		localMinX := segMinX - tileBaseX
		localMaxX := segMaxX - tileBaseX

		for ty := minTileY; ty <= maxTileY; ty++ {
			tileBaseY := ty * worldHeightFp
			segMinY := max(rect.minY, tileBaseY)
			segMaxY := min(rect.maxY, tileBaseY+worldHeightFp)
			if segMinY >= segMaxY {
				continue
			}

			localMinY := segMinY - tileBaseY
			localMaxY := segMaxY - tileBaseY

			out = append(out, WorldTile{
				Rect: Rect{
					minX: localMinX, maxX: localMaxX,
					minY: localMinY, maxY: localMaxY,
				},
				OffsetX: tileBaseX,
				OffsetY: tileBaseY,
			})
		}
	}

	return out
}

// tileWorldRectNoWrap returns 0..1 tiles for a bounded world (no wrap).
// It intersects the expanded unwrapped rect with the canonical world [0..W)x[0..H).
func tileWorldRectNoWrap(worldRect Rect, W, H int) []WorldTile {
	ix0 := max(worldRect.minX, 0)
	iy0 := max(worldRect.minY, 0)
	ix1 := min(worldRect.maxX, W)
	iy1 := min(worldRect.maxY, H)

	if ix0 >= ix1 || iy0 >= iy1 {
		return nil
	}

	return []WorldTile{
		{
			Rect:    Rect{minX: ix0, maxX: ix1, minY: iy0, maxY: iy1},
			OffsetX: 0,
			OffsetY: 0,
		},
	}
}

// isEmptyRectPx reports whether r covers no canvas pixels.
func isEmptyRectPx(r RectPx) bool {
	return r.W <= 0 || r.H <= 0
}

// intersectRectPx returns the intersection of two half-open canvas rectangles.
func intersectRectPx(a, b RectPx) (RectPx, bool) {
	ax2 := a.X + a.W
	ay2 := a.Y + a.H
	bx2 := b.X + b.W
	by2 := b.Y + b.H

	x1 := max(a.X, b.X)
	y1 := max(a.Y, b.Y)
	x2 := min(ax2, bx2)
	y2 := min(ay2, by2)

	w := x2 - x1
	h := y2 - y1
	if w <= 0 || h <= 0 {
		return RectPx{}, false
	}

	return RectPx{X: x1, Y: y1, W: w, H: h}, true
}

// RenderPlan describes the full expanded-canvas redraw plan for one RenderParams.
// It is a pure description: it does not execute any drawing.
type RenderPlan struct {
	CanvasWidthPx  int
	CanvasHeightPx int

	ZoomFp int

	// WorldRect is the unwrapped world-space rect covered by the expanded canvas.
	WorldRect Rect

	// Tiles are ordered in the same order as produced by tileWorldRect:
	// increasing tile X index, then increasing tile Y index.
	Tiles []TileDrawPlan
}

// TileDrawPlan describes how to draw one torus tile contribution.
type TileDrawPlan struct {
	Tile WorldTile

	// Clip rect on the expanded canvas in pixel coordinates.
	// It is half-open in spirit: [ClipX, ClipX+ClipW) x [ClipY, ClipY+ClipH).
	ClipX int
	ClipY int
	ClipW int
	ClipH int

	// Candidates are unique per tile (deduped by ID).
	Candidates []MapItem
}

// worldSpanFixedToCanvasPx converts a world fixed-point span into a canvas pixel span
// for the given fixed-point zoom. The conversion is truncating (floor).
func worldSpanFixedToCanvasPx(spanWorldFp, zoomFp int) int {
	// spanWorldFp can be negative in some internal cases, but for clip computations
	// we always pass non-negative spans.
	return (spanWorldFp * zoomFp) / (SCALE * SCALE)
}

// buildRenderPlan builds a full expanded-canvas redraw plan.
//
// It assumes the world grid is already built (IndexOnViewportChange called).
// The plan contains per-tile clip rectangles and per-tile candidate lists
// from the spatial index.
func (w *World) buildRenderPlan(params RenderParams) (RenderPlan, error) {
	if err := params.Validate(); err != nil {
		return RenderPlan{}, err
	}

	zoomFp, err := params.CameraZoomFp()
	if err != nil {
		return RenderPlan{}, err
	}

	worldRect, err := params.ExpandedCanvasWorldRect()
	if err != nil {
		return RenderPlan{}, err
	}

	allowWrap := params.Options == nil || !params.Options.DisableWrapScroll
	var tiles []WorldTile
	if allowWrap {
		tiles = tileWorldRect(worldRect, w.W, w.H)
	} else {
		tiles = tileWorldRectNoWrap(worldRect, w.W, w.H)
	}

	// Query candidates per tile.
	batches, err := w.collectCandidatesForTiles(tiles)
	if err != nil {
		return RenderPlan{}, err
	}

	planTiles := make([]TileDrawPlan, 0, len(batches))

	for _, batch := range batches {
		tile := batch.Tile

		// Convert the tile's canonical rect + offsets into the unwrapped segment.
		segMinX := tile.Rect.minX + tile.OffsetX
		segMaxX := tile.Rect.maxX + tile.OffsetX
		segMinY := tile.Rect.minY + tile.OffsetY
		segMaxY := tile.Rect.maxY + tile.OffsetY

		// Map that segment into expanded canvas pixel coordinates relative to worldRect.minX/minY.
		clipX := worldSpanFixedToCanvasPx(segMinX-worldRect.minX, zoomFp)
		clipY := worldSpanFixedToCanvasPx(segMinY-worldRect.minY, zoomFp)
		clipX2 := worldSpanFixedToCanvasPx(segMaxX-worldRect.minX, zoomFp)
		clipY2 := worldSpanFixedToCanvasPx(segMaxY-worldRect.minY, zoomFp)

		clipW := clipX2 - clipX
		clipH := clipY2 - clipY

		planTiles = append(planTiles, TileDrawPlan{
			Tile:       tile,
			ClipX:      clipX,
			ClipY:      clipY,
			ClipW:      clipW,
			ClipH:      clipH,
			Candidates: batch.Items,
		})
	}

	return RenderPlan{
		CanvasWidthPx:  params.CanvasWidthPx(),
		CanvasHeightPx: params.CanvasHeightPx(),
		ZoomFp:         zoomFp,
		WorldRect:      worldRect,
		Tiles:          planTiles,
	}, nil
}

var (
	errGridNotBuilt = errors.New("render: grid not built; call IndexOnViewportChange first")
)

// TileCandidates binds one torus tile to the list of unique grid candidates
// that intersect the tile rectangle.
//
// Items are not guaranteed to be truly visible; the grid is a coarse spatial index.
// Exact visibility tests are performed later in the renderer pipeline.
type TileCandidates struct {
	Tile  WorldTile
	Items []MapItem
}

// collectCandidatesForTiles queries the world grid for each tile rectangle
// and returns per-tile unique candidate lists.
//
// Deduplication is performed per tile (by MapItem.ID()) to avoid duplicates caused by
// bbox indexing into multiple cells. Dedup across tiles is intentionally NOT performed.
func (w *World) collectCandidatesForTiles(tiles []WorldTile) ([]TileCandidates, error) {
	if w.grid == nil || w.rows <= 0 || w.cols <= 0 || w.cellSize <= 0 {
		return nil, errGridNotBuilt
	}

	out := make([]TileCandidates, 0, len(tiles))
	for _, tile := range tiles {
		items := w.collectCandidatesForTile(tile.Rect)
		out = append(out, TileCandidates{
			Tile:  tile,
			Items: items,
		})
	}
	return out, nil
}

// collectCandidatesForTile returns a unique set of grid candidates for a single
// canonical-world tile rectangle [0..W) x [0..H).
//
// The rectangle must be half-open and expressed in fixed-point world coordinates.
func (w *World) collectCandidatesForTile(r Rect) []MapItem {
	// Empty rect => no candidates.
	if r.maxX <= r.minX || r.maxY <= r.minY {
		return nil
	}

	// Map rect to cell ranges using the same half-open conventions as indexing:
	// the last included cell is computed from (max-1).
	colStart := w.worldToCellX(r.minX)
	colEnd := w.worldToCellX(r.maxX - 1)

	rowStart := w.worldToCellY(r.minY)
	rowEnd := w.worldToCellY(r.maxY - 1)

	// Start a new epoch for this tile dedupe.
	w.candSeenResetIfOverflow()

	// Reuse result buffer.
	out := w.scratchCandidates[:0]

	for row := rowStart; row <= rowEnd; row++ {
		for col := colStart; col <= colEnd; col++ {
			cell := w.grid[row][col]
			for _, item := range cell {
				id := item.ID()
				if w.candSeenMark(id) {
					continue
				}
				out = append(out, item)
			}
		}
	}

	// Store back the reusable buffer (keep capacity).
	w.scratchCandidates = out[:0]

	// IMPORTANT:
	// We must return a stable slice to the caller (plan stores it).
	// Returning `out` directly would be overwritten on the next tile.
	//
	// So: copy out into a freshly allocated slice OR into a plan-level scratch pool.
	// For Step 1 we keep correctness: allocate exactly once per tile.
	// Step 3 will remove this allocation by making plan own a pooled backing store.
	res := make([]MapItem, len(out))
	copy(res, out)
	return res
}

// drawKind is used only for stable tie-breaking when priorities are equal.
type drawKind int

const (
	drawKindLine drawKind = iota
	drawKindCircle
	drawKindPoint
)

// drawItem is the normalized per-tile render record used for stable ordering.
//
// Each instance stores exactly one primitive payload together with the sort key
// that drawPlanSinglePass uses before issuing final drawer commands.
type drawItem struct {
	kind     drawKind
	priority int
	id       PrimitiveID
	styleID  StyleID

	// Exactly one of these is set.
	p Point
	c Circle
	l Line
}

// drawPlanSinglePass renders a plan using a single ordered pass per tile.
// Items in each tile are sorted by (Priority asc, Kind asc, ID asc) for determinism.
//
// allowWrap controls torus behavior:
// - true: circles/points produce wrap copies, lines use torus-shortest segments
// - false: no copies, lines drawn directly as stored
// tileClipEnabled controls whether per-tile ClipRect is applied.
// When an outer clip is already set (e.g. dirty rect), disable tile clips for speed.
func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allowWrap bool, tileClipEnabled bool, isDirtyPass bool) {
	var lastStyleID StyleID = StyleIDInvalid
	var lastStyle Style

	applyStyle := func(styleID StyleID) {
		if styleID == lastStyleID {
			return
		}
		s, ok := w.styles.Get(styleID)
		if !ok {
			panic("render: unknown style ID")
		}

		if s.FillColor != nil {
			drawer.SetFillColor(s.FillColor)
		}
		if s.StrokeColor != nil {
			drawer.SetStrokeColor(s.StrokeColor)
		}
		drawer.SetLineWidth(s.StrokeWidthPx)
		if len(s.StrokeDashes) > 0 {
			drawer.SetDash(s.StrokeDashes...)
		} else {
			drawer.SetDash()
		}
		drawer.SetDashOffset(s.StrokeDashOffset)

		lastStyleID = styleID
		lastStyle = s
	}

	for _, td := range plan.Tiles {
		if td.ClipW <= 0 || td.ClipH <= 0 {
			continue
		}

		// Per-tile clip is optional. When outer-clip is used (dirty rect),
		// tileClipEnabled must be false to avoid resetting the outer clip.
		if tileClipEnabled {
			drawer.Save()
			drawer.ResetClip()
			drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH))
		}

		items := w.scratchDrawItems[:0]
		if cap(items) < len(td.Candidates) {
			items = make([]drawItem, 0, len(td.Candidates))
		}

		for _, it := range td.Candidates {
			id := it.ID()
			cur, ok := w.objects[id]
			if !ok {
				continue
			}

			switch v := cur.(type) {
			case Point:
				items = append(items, drawItem{
					kind:     drawKindPoint,
					priority: v.Priority,
					id:       v.Id,
					styleID:  v.StyleID,
					p:        v,
				})
			case Circle:
				items = append(items, drawItem{
					kind:     drawKindCircle,
					priority: v.Priority,
					id:       v.Id,
					styleID:  v.StyleID,
					c:        v,
				})
			case Line:
				items = append(items, drawItem{
					kind:     drawKindLine,
					priority: v.Priority,
					id:       v.Id,
					styleID:  v.StyleID,
					l:        v,
				})
			default:
				panic("render: unknown map item type")
			}
		}

		if len(items) == 0 {
			if tileClipEnabled {
				drawer.Restore()
			}
			w.scratchDrawItems = items[:0]
			continue
		}

		sort.Slice(items, func(i, j int) bool {
			a, b := items[i], items[j]
			if a.priority != b.priority {
				return a.priority < b.priority
			}
			if a.kind != b.kind {
				return a.kind < b.kind
			}
			return a.id < b.id
		})

		// If this is not a dirty pass (full redraw), keep the old behavior for lines:
		// stroke per segment. This is usually faster for gg on huge scenes.
		if !isDirtyPass {
			for i := 0; i < len(items); i++ {
				di := items[i]
				applyStyle(di.styleID)

				switch di.kind {
				case drawKindPoint:
					w.drawPointInTile(drawer, plan, td, di.p, allowWrap, lastStyle)
				case drawKindCircle:
					w.drawCircleInTile(drawer, plan, td, di.c, allowWrap, lastStyle)
				case drawKindLine:
					// Old behavior: drawLineInTile includes Stroke() per segment.
					w.drawLineInTile(drawer, plan, td, di.l, allowWrap)
				default:
					panic("render: unknown draw kind")
				}
			}
		} else {
			// Dirty pass: batch lines to reduce overhead while panning.
			inLineRun := false
			var lineRunStyleID StyleID
			lineSegCount := 0

			flushLineRun := func() {
				if !inLineRun {
					return
				}
				drawer.Stroke()
				inLineRun = false
				lineSegCount = 0
			}

			for i := 0; i < len(items); i++ {
				di := items[i]

				if inLineRun {
					if di.kind != drawKindLine || di.styleID != lineRunStyleID {
						flushLineRun()
					}
				}

				switch di.kind {
				case drawKindLine:
					if !inLineRun {
						lineRunStyleID = di.styleID
						applyStyle(lineRunStyleID)
						inLineRun = true
					} else {
						// style matches by construction; keep style state valid if code changes later
						applyStyle(di.styleID)
					}

					added := w.drawLineInTilePath(drawer, plan, td, di.l, allowWrap)
					lineSegCount += added

					if lineSegCount >= maxLineSegmentsPerStroke {
						drawer.Stroke()
						lineSegCount = 0
						// keep run active
					}

				case drawKindPoint:
					flushLineRun()
					applyStyle(di.styleID)
					w.drawPointInTile(drawer, plan, td, di.p, allowWrap, lastStyle)

				case drawKindCircle:
					flushLineRun()
					applyStyle(di.styleID)
					w.drawCircleInTile(drawer, plan, td, di.c, allowWrap, lastStyle)

				default:
					flushLineRun()
					panic("render: unknown draw kind")
				}
			}

			flushLineRun()
		}

		if tileClipEnabled {
			drawer.Restore()
		}

		// Reuse buffer for next tile.
		w.scratchDrawItems = items[:0]
	}
}

// lineSeg is one canonical segment (endpoints in [0..W) x [0..H)) to be drawn.
// It represents part of the torus-shortest polyline for a Line primitive after wrap splitting.
type lineSeg struct {
	x1, y1 int
	x2, y2 int
}

// drawPointInTile draws point marker copies that intersect the tile.
// lastStyle is already applied; it provides PointRadiusPx.
func (w *World) drawPointInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, p Point, allowWrap bool, lastStyle Style) {
	rPx := lastStyle.PointRadiusPx
	if rPx <= 0 {
		// Nothing visible.
		return
	}

	// Convert screen radius to world-fixed conservatively.
	rWorldFp := PixelSpanToWorldFixed(int(rPx+0.999999), plan.ZoomFp)

	var shifts []wrapShift
	if allowWrap {
		shifts = pointWrapShifts(p, rWorldFp, w.W, w.H)
	} else {
		shifts = []wrapShift{{dx: 0, dy: 0}}
	}

	for _, s := range shifts {
		if allowWrap && !pointCopyIntersectsTile(p, rWorldFp, s.dx, s.dy, td.Tile) {
			continue
		}

		px := worldSpanFixedToCanvasPx((p.X+td.Tile.OffsetX+s.dx)-plan.WorldRect.minX, plan.ZoomFp)
		py := worldSpanFixedToCanvasPx((p.Y+td.Tile.OffsetY+s.dy)-plan.WorldRect.minY, plan.ZoomFp)

		drawer.AddPoint(float64(px), float64(py), rPx)

		fill := alphaNonZero(lastStyle.FillColor)
		stroke := alphaNonZero(lastStyle.StrokeColor)

		if fill {
			drawer.Fill()
		}
		if stroke {
			// Stroke must be last when both are present.
			drawer.Stroke()
		}
	}
}

func (w *World) drawCircleInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, c Circle, allowWrap bool, lastStyle Style) {
	var shifts []wrapShift
	effRadius := circleRadiusEffFp(c.Radius, w.circleRadiusScaleFp)
	if allowWrap {
		shifts = circleWrapShiftsInto(w.scratchWrapShifts, c.X, c.Y, effRadius, w.W, w.H)
	} else {
		var one [1]wrapShift
		one[0] = wrapShift{dx: 0, dy: 0}
		shifts = one[:]
	}

	rPx := worldSpanFixedToCanvasPx(effRadius, plan.ZoomFp)

	for _, s := range shifts {
		if allowWrap && !circleCopyIntersectsTile(c.X, c.Y, effRadius, s.dx, s.dy, td.Tile, w.W, w.H) {
			continue
		}

		cxPx := worldSpanFixedToCanvasPx((c.X+td.Tile.OffsetX+s.dx)-plan.WorldRect.minX, plan.ZoomFp)
		cyPx := worldSpanFixedToCanvasPx((c.Y+td.Tile.OffsetY+s.dy)-plan.WorldRect.minY, plan.ZoomFp)

		fill := alphaNonZero(lastStyle.FillColor)
		stroke := alphaNonZero(lastStyle.StrokeColor)

		switch {
		case fill && stroke:
			// gg consumes the current path on Fill/Stroke, so we must draw twice:
			// once for fill, then again for stroke.
			drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
			drawer.Fill()

			drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
			drawer.Stroke()

		case fill:
			drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
			drawer.Fill()

		case stroke:
			drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx))
			drawer.Stroke()

		default:
			// neither visible => nothing
		}
	}
	w.scratchWrapShifts = shifts[:0]
}

func (w *World) drawLineInTilePath(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, l Line, allowWrap bool) int {
	segs := w.scratchLineSegs[:0]
	tmp := w.scratchLineSegsTmp[:0]
	if cap(segs) < 4 {
		segs = make([]lineSeg, 0, 4)
	}
	if cap(tmp) < 4 {
		tmp = make([]lineSeg, 0, 4)
	}

	if allowWrap {
		segs, tmp = torusShortestLineSegmentsInto(segs, tmp, l, w.W, w.H)
	} else {
		var one [1]lineSeg
		one[0] = lineSeg{x1: l.X1, y1: l.Y1, x2: l.X2, y2: l.Y2}
		segs = one[:]
	}

	for _, s := range segs {
		x1 := worldSpanFixedToCanvasPx((s.x1+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp)
		y1 := worldSpanFixedToCanvasPx((s.y1+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp)
		x2 := worldSpanFixedToCanvasPx((s.x2+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp)
		y2 := worldSpanFixedToCanvasPx((s.y2+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp)

		drawer.AddLine(float64(x1), float64(y1), float64(x2), float64(y2))
	}

	w.scratchLineSegs = segs[:0]
	w.scratchLineSegsTmp = tmp[:0]

	return len(segs)
}

func (w *World) drawLineInTile(drawer PrimitiveDrawer, plan RenderPlan, td TileDrawPlan, l Line, allowWrap bool) {
	w.drawLineInTilePath(drawer, plan, td, l, allowWrap)
	drawer.Stroke()
}

var (
	errInvalidCanvasSize = errors.New("incremental: invalid canvas size")
)

// IncrementalMode describes how the renderer should update the backing image.
type IncrementalMode int

const (
	// IncrementalNoOp means no visual change is needed (dx=0 and dy=0).
	IncrementalNoOp IncrementalMode = iota

	// IncrementalShift means the backing image can be shifted and only dirty rects must be redrawn.
	IncrementalShift

	// IncrementalFullRedraw means the change is too large/unsafe for shifting and needs a full redraw.
	IncrementalFullRedraw
)

// RectPx is an integer rectangle in canvas pixel coordinates.
// Semantics are half-open: [X, X+W) x [Y, Y+H).
type RectPx struct {
	X, Y int
	W, H int
}

// IncrementalPolicy is a placeholder for future incremental tuning.
// It is intentionally not used in C2; we only fix geometry-based thresholding now.
type IncrementalPolicy struct {
	// CoalesceUpdates indicates "latest wins" behavior (drop intermediate updates).
	// This will be implemented later; kept here as a placeholder to lock the API shape.
	CoalesceUpdates bool

	// AllowShiftOnly allows a temporary mode where the backing image is shifted
	// but dirty rects are not redrawn immediately under overload.
	AllowShiftOnly bool

	// RenderBudgetMs can be used later to compare dtRender against a budget and decide degradation.
	RenderBudgetMs int

	// MaxCatchUpAreaPx limits how many pixels of deferred dirty regions we redraw per frame.
	// 0 means "no limit".
	MaxCatchUpAreaPx int
}

// IncrementalPlan is the output of pure incremental planning.
// It does not perform any drawing. It only describes what should happen.
type IncrementalPlan struct {
	Mode IncrementalMode

	// Shift to apply to the backing image in canvas pixels.
	// Positive dx shifts the existing image to the right (exposing a dirty strip on the left).
	// Positive dy shifts the existing image down (exposing a dirty strip on the top).
	DxPx int
	DyPx int

	// Dirty rects to redraw after shifting (in canvas pixel coordinates).
	// Rects may overlap; overlapping is allowed and simplifies planning.
	Dirty []RectPx
}

// PlanIncrementalPan computes whether the renderer can update by shifting the backing image
// and redrawing only exposed strips, or must fall back to a full redraw.
//
// Threshold rule (per-axis):
//   - If abs(dxPx) > marginXPx/2 => full redraw
//   - If abs(dyPx) > marginYPx/2 => full redraw
//
// Additional safety rules:
//   - If abs(dxPx) >= canvasW or abs(dyPx) >= canvasH => full redraw
//
// Returned dirty rects follow the chosen shift direction:
//
//	dxPx > 0 => dirty strip on the left  (width=dxPx)
//	dxPx < 0 => dirty strip on the right (width=-dxPx)
//	dyPx > 0 => dirty strip on the top   (height=dyPx)
//	dyPx < 0 => dirty strip on the bottom(height=-dyPx)
func PlanIncrementalPan(
	canvasW, canvasH int,
	marginXPx, marginYPx int,
	dxPx, dyPx int,
) (IncrementalPlan, error) {
	if canvasW <= 0 || canvasH <= 0 {
		return IncrementalPlan{}, errInvalidCanvasSize
	}
	if marginXPx < 0 || marginYPx < 0 {
		return IncrementalPlan{}, errors.New("incremental: invalid margins")
	}

	// No movement => no work.
	if dxPx == 0 && dyPx == 0 {
		return IncrementalPlan{Mode: IncrementalNoOp, DxPx: 0, DyPx: 0, Dirty: nil}, nil
	}

	adx := abs(dxPx)
	ady := abs(dyPx)

	// Too large shift can’t be represented as "shift + stripes".
	if adx >= canvasW || ady >= canvasH {
		return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
	}

	// Thresholds: per axis, independently.
	// Using integer division: margin/2 truncates down, which is fine and deterministic.
	thrX := marginXPx / 2
	thrY := marginYPx / 2

	if (thrX > 0 && adx > thrX) || (thrY > 0 && ady > thrY) {
		return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
	}

	// If margin is 0, thr is 0, and any non-zero delta should force full redraw
	// (because we have no buffer area to shift into).
	if marginXPx == 0 && dxPx != 0 {
		return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
	}
	if marginYPx == 0 && dyPx != 0 {
		return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
	}

	dirty := make([]RectPx, 0, 2)

	// Horizontal exposed strip with 1px overdraw to avoid seams.
	if dxPx > 0 {
		// Image moved right => left strip is exposed.
		w := min(dxPx+1, canvasW) // overdraw 1px into already-valid area
		dirty = append(dirty, RectPx{X: 0, Y: 0, W: w, H: canvasH})
	} else if dxPx < 0 {
		// Image moved left => right strip is exposed.
		w := min((-dxPx)+1, canvasW)
		dirty = append(dirty, RectPx{X: canvasW - w, Y: 0, W: w, H: canvasH})
	}

	// Vertical exposed strip with 1px overdraw to avoid seams.
	if dyPx > 0 {
		// Image moved down => top strip is exposed.
		h := min(dyPx+1, canvasH)
		dirty = append(dirty, RectPx{X: 0, Y: 0, W: canvasW, H: h})
	} else if dyPx < 0 {
		// Image moved up => bottom strip is exposed.
		h := min((-dyPx)+1, canvasH)
		dirty = append(dirty, RectPx{X: 0, Y: canvasH - h, W: canvasW, H: h})
	}

	// Filter out any zero/negative rects defensively.
	out := dirty[:0]
	for _, r := range dirty {
		if r.W <= 0 || r.H <= 0 {
			continue
		}
		out = append(out, r)
	}

	return IncrementalPlan{
		Mode:  IncrementalShift,
		DxPx:  dxPx,
		DyPx:  dyPx,
		Dirty: out,
	}, nil
}

// shiftAndClipRectPx moves r by the supplied pixel delta and clips it to the
// current canvas bounds.
func shiftAndClipRectPx(r RectPx, dx, dy, canvasW, canvasH int) (RectPx, bool) {
	n := RectPx{X: r.X + dx, Y: r.Y + dy, W: r.W, H: r.H}
	inter, ok := intersectRectPx(n, RectPx{X: 0, Y: 0, W: canvasW, H: canvasH})
	return inter, ok
}

// planRestrictedToDirtyRects returns a new plan that contains only tile draw entries
// whose clip rectangles intersect any dirty rect. Each intersected area becomes its own
// TileDrawPlan entry with the clip replaced by the intersection.
//
// This makes drawing functions naturally render only the dirty areas.
func planRestrictedToDirtyRects(plan RenderPlan, dirty []RectPx) RenderPlan {
	if len(dirty) == 0 {
		return RenderPlan{
			CanvasWidthPx:  plan.CanvasWidthPx,
			CanvasHeightPx: plan.CanvasHeightPx,
			ZoomFp:         plan.ZoomFp,
			WorldRect:      plan.WorldRect,
			Tiles:          nil,
		}
	}

	outTiles := make([]TileDrawPlan, 0)

	for _, td := range plan.Tiles {
		if td.ClipW <= 0 || td.ClipH <= 0 {
			continue
		}

		tileClip := RectPx{X: td.ClipX, Y: td.ClipY, W: td.ClipW, H: td.ClipH}

		for _, dr := range dirty {
			if isEmptyRectPx(dr) {
				continue
			}

			inter, ok := intersectRectPx(tileClip, dr)
			if !ok {
				continue
			}

			outTiles = append(outTiles, TileDrawPlan{
				Tile:       td.Tile,
				ClipX:      inter.X,
				ClipY:      inter.Y,
				ClipW:      inter.W,
				ClipH:      inter.H,
				Candidates: td.Candidates,
			})
		}
	}

	return RenderPlan{
		CanvasWidthPx:  plan.CanvasWidthPx,
		CanvasHeightPx: plan.CanvasHeightPx,
		ZoomFp:         plan.ZoomFp,
		WorldRect:      plan.WorldRect,
		Tiles:          outTiles,
	}
}

// takeCatchUpRects selects a subset of pending rects whose total area does not exceed maxAreaPx.
// It returns (selected, remaining). If maxAreaPx <= 0, it selects all.
func takeCatchUpRects(pending []RectPx, maxAreaPx int) (selected []RectPx, remaining []RectPx) {
	if len(pending) == 0 {
		return nil, nil
	}
	if maxAreaPx <= 0 {
		// No limit.
		all := append([]RectPx(nil), pending...)
		return all, nil
	}

	selected = make([]RectPx, 0, len(pending))
	remaining = make([]RectPx, 0)

	used := 0
	for _, r := range pending {
		if r.W <= 0 || r.H <= 0 {
			continue
		}
		area := r.W * r.H
		if area <= 0 {
			continue
		}

		// If we cannot fit the whole rect, we stop (simple, deterministic).
		// (We do not split rectangles here to keep logic simple.)
		if used+area > maxAreaPx {
			remaining = append(remaining, r)
			continue
		}

		selected = append(selected, r)
		used += area
	}

	// Also keep any rects we skipped due to invalid size (none) and those that didn't fit.
	// Note: remaining preserves original order among non-selected entries.
	return selected, remaining
}

var (
	errIncrementalZoomMismatch    = errors.New("incremental: zoom/viewport/margins changed; full redraw required")
	errIncrementalStateNotReady   = errors.New("incremental: state not initialized; full redraw required")
	errIncrementalInvalidZoomFp   = errors.New("incremental: invalid zoom")
	errIncrementalInvalidCanvasPx = errors.New("incremental: invalid canvas size")
)

// rendererIncrementalState stores the minimum state needed for incremental pan.
type rendererIncrementalState struct {
	initialized bool

	// Last render geometry key.
	lastZoomFp int

	lastViewportW int
	lastViewportH int
	lastMarginX   int
	lastMarginY   int
	lastCanvasW   int
	lastCanvasH   int

	// Last unwrapped expanded world rect used for rendering.
	lastWorldRect Rect

	// Remainders in numerator space to make world->px conversion stable across many small pans.
	// We keep them per axis and update them during conversion.
	remXNum int64
	remYNum int64

	// Last measured render duration (nanoseconds). Used for overload heuristics.
	lastRenderDurationNs int64

	// Pending dirty areas accumulated during shift-only frames.
	// These are in current canvas pixel coordinates.
	pendingDirty []RectPx
}

// Reset clears incremental state, forcing next frame to use full redraw.
func (s *rendererIncrementalState) Reset() {
	*s = rendererIncrementalState{}
}

// incrementalKeyFromParams extracts the geometry key that must match for incremental pan.
func incrementalKeyFromParams(params RenderParams, zoomFp int) (vw, vh, mx, my, cw, ch, z int) {
	vw = params.ViewportWidthPx
	vh = params.ViewportHeightPx
	mx = params.MarginXPx
	my = params.MarginYPx
	cw = params.CanvasWidthPx()
	ch = params.CanvasHeightPx()
	z = zoomFp
	return
}

// worldDeltaFixedToCanvasPx converts a world-fixed delta into a pixel delta using zoomFp,
// carrying a signed remainder in numerator space to avoid cumulative drift.
//
// The conversion is:
//
//	px = floor((deltaWorldFp*zoomFp + rem) / (SCALE*SCALE))
//
// and rem is updated to the exact remainder.
//
// This function works for negative deltas too and uses floor division semantics.
func worldDeltaFixedToCanvasPx(deltaWorldFp int, zoomFp int, remNum *int64) int {
	if zoomFp <= 0 {
		panic("worldDeltaFixedToCanvasPx: invalid zoom")
	}

	den := int64(SCALE) * int64(SCALE)
	num := int64(deltaWorldFp)*int64(zoomFp) + *remNum

	q, r := floorDivRem64(num, den)
	*remNum = r
	return int(q)
}

// floorDivRem64 returns (q,r) such that:
//
//	q = floor(a / b), r = a - q*b
//
// with b > 0 and r in [0, b) for a>=0, or r in (-b, 0] for a<0 (signed remainder).
func floorDivRem64(a, b int64) (q int64, r int64) {
	if b <= 0 {
		panic("floorDivRem64: non-positive divisor")
	}

	q = a / b
	r = a % b
	if r != 0 && a < 0 {
		q--
		r = a - q*b
	}
	return q, r
}

// ComputePanShiftPx computes the pixel shift that must be applied to the existing backing image
// when ONLY camera pan changed (no zoom/viewport/margins changes).
//
// Returned dxPx/dyPx are shifts to apply to the already rendered image:
//
//	dxPx > 0 => shift image right
//	dxPx < 0 => shift image left
//
// This function updates internal incremental state when possible.
// If it returns an error, the caller should fall back to a full redraw and call
// CommitFullRedrawState afterward.
func (w *World) ComputePanShiftPx(params RenderParams) (dxPx, dyPx int, err error) {
	zoomFp, zerr := params.CameraZoomFp()
	if zerr != nil {
		return 0, 0, zerr
	}
	if zoomFp <= 0 {
		return 0, 0, errIncrementalInvalidZoomFp
	}

	canvasW := params.CanvasWidthPx()
	canvasH := params.CanvasHeightPx()
	if canvasW <= 0 || canvasH <= 0 {
		return 0, 0, errIncrementalInvalidCanvasPx
	}

	newRect, rerr := params.ExpandedCanvasWorldRect()
	if rerr != nil {
		return 0, 0, rerr
	}

	s := &w.renderState

	// First call: no prior state => must full redraw.
	if !s.initialized {
		return 0, 0, errIncrementalStateNotReady
	}

	vw, vh, mx, my, cw, ch, z := incrementalKeyFromParams(params, zoomFp)
	if s.lastZoomFp != z ||
		s.lastViewportW != vw || s.lastViewportH != vh ||
		s.lastMarginX != mx || s.lastMarginY != my ||
		s.lastCanvasW != cw || s.lastCanvasH != ch {
		return 0, 0, errIncrementalZoomMismatch
	}

	// Compute how much the unwrapped world rect moved.
	dMinX := newRect.minX - s.lastWorldRect.minX
	dMinY := newRect.minY - s.lastWorldRect.minY

	// Convert world movement to pixel movement of the world content.
	// If world rect moved +X (camera moved right), content appears shifted left,
	// so the old image must be shifted left: shiftPx = -deltaPx.
	deltaPxX := worldDeltaFixedToCanvasPx(dMinX, zoomFp, &s.remXNum)
	deltaPxY := worldDeltaFixedToCanvasPx(dMinY, zoomFp, &s.remYNum)

	dxPx = -deltaPxX
	dyPx = -deltaPxY

	// Update stored rect for the next incremental computation.
	s.lastWorldRect = newRect

	return dxPx, dyPx, nil
}

// CommitFullRedrawState updates incremental state after a full redraw.
// Call this after you finish a full Render() that draws the entire expanded canvas.
func (w *World) CommitFullRedrawState(params RenderParams) error {
	zoomFp, err := params.CameraZoomFp()
	if err != nil {
		return err
	}
	if zoomFp <= 0 {
		return errIncrementalInvalidZoomFp
	}

	rect, err := params.ExpandedCanvasWorldRect()
	if err != nil {
		return err
	}

	s := &w.renderState
	vw, vh, mx, my, cw, ch, z := incrementalKeyFromParams(params, zoomFp)

	s.initialized = true
	s.lastZoomFp = z
	s.lastViewportW = vw
	s.lastViewportH = vh
	s.lastMarginX = mx
	s.lastMarginY = my
	s.lastCanvasW = cw
	s.lastCanvasH = ch
	s.lastWorldRect = rect

	// Reset remainders on a full redraw to avoid stale accumulation when geometry changes.
	s.remXNum = 0
	s.remYNum = 0

	s.pendingDirty = nil

	return nil
}

func (w *World) drawBackground(drawer PrimitiveDrawer, params RenderParams, rect RectPx) {
	if gd, ok := drawer.(*GGDrawer); ok {
		if gd.drawBackgroundFast(w, params, rect) {
			return
		}
	}
	th := w.Theme()
	bgImg := th.BackgroundImage()
	if bgImg == nil {
		return
	}

	canvasW := params.CanvasWidthPx()
	canvasH := params.CanvasHeightPx()

	// Clamp rect to canvas.
	if rect.W <= 0 || rect.H <= 0 {
		return
	}
	if rect.X < 0 {
		rect.W += rect.X
		rect.X = 0
	}
	if rect.Y < 0 {
		rect.H += rect.Y
		rect.Y = 0
	}
	if rect.X+rect.W > canvasW {
		rect.W = canvasW - rect.X
	}
	if rect.Y+rect.H > canvasH {
		rect.H = canvasH - rect.Y
	}
	if rect.W <= 0 || rect.H <= 0 {
		return
	}

	imgB := bgImg.Bounds()
	imgW := imgB.Dx()
	imgH := imgB.Dy()
	if imgW <= 0 || imgH <= 0 {
		return
	}

	tileMode := th.BackgroundTileMode()
	anchor := th.BackgroundAnchorMode()
	scaleMode := th.BackgroundScaleMode()

	// Compute scaled tile size.
	tileW, tileH := backgroundScaledSize(imgW, imgH, canvasW, canvasH, scaleMode)
	if tileW <= 0 || tileH <= 0 {
		return
	}

	offX, offY := w.backgroundAnchorOffsetPx(params, tileW, tileH, anchor)

	drawer.Save()
	drawer.ResetClip()
	drawer.ClipRect(float64(rect.X), float64(rect.Y), float64(rect.W), float64(rect.H))

	switch tileMode {
	case BackgroundTileNone:
		// Center image within full canvas (not within rect), then clip handles partial.
		// This is important so that dirty redraw matches full redraw.
		x := (canvasW-tileW)/2 + offX
		y := (canvasH-tileH)/2 + offY
		drawBackgroundOne(drawer, bgImg, x, y, imgW, imgH, tileW, tileH, scaleMode)

	case BackgroundTileRepeat:
		originX := offX
		originY := offY

		startX := floorDiv(rect.X-originX, tileW)*tileW + originX
		startY := floorDiv(rect.Y-originY, tileH)*tileH + originY

		for yy := startY; yy < rect.Y+rect.H; yy += tileH {
			for xx := startX; xx < rect.X+rect.W; xx += tileW {
				drawBackgroundOne(drawer, bgImg, xx, yy, imgW, imgH, tileW, tileH, scaleMode)
			}
		}

	default:
		// Fallback: behave like none.
		x := (canvasW-tileW)/2 + offX
		y := (canvasH-tileH)/2 + offY
		drawBackgroundOne(drawer, bgImg, x, y, imgW, imgH, tileW, tileH, scaleMode)
	}

	drawer.Restore()
}

// drawBackgroundOne draws one background-image instance at the requested
// canvas position, scaling it when needed.
func drawBackgroundOne(drawer PrimitiveDrawer, img image.Image, x, y, srcW, srcH, dstW, dstH int, scaleMode BackgroundScaleMode) {
	if scaleMode == BackgroundScaleNone && dstW == srcW && dstH == srcH {
		drawer.DrawImage(img, x, y)
		return
	}
	// For Fit/Fill, or if dst size differs, draw scaled.
	drawer.DrawImageScaled(img, x, y, dstW, dstH)
}

// backgroundScaledSize computes uniform scaled destination size for the background image.
// For None: returns source size.
// For Fit: fits inside canvas.
// For Fill: covers canvas.
func backgroundScaledSize(srcW, srcH, canvasW, canvasH int, mode BackgroundScaleMode) (int, int) {
	if srcW <= 0 || srcH <= 0 || canvasW <= 0 || canvasH <= 0 {
		return 0, 0
	}

	switch mode {
	case BackgroundScaleNone:
		return srcW, srcH

	case BackgroundScaleFit, BackgroundScaleFill:
		// Uniform scale: choose ratio based on min/max.
		// Use integer math to avoid float; keep it stable across frames.
		// We compute scale as rational and then round destination size.
		// Let scale = canvasW/srcW vs canvasH/srcH.
		// Fit uses min(scaleW, scaleH). Fill uses max(scaleW, scaleH).
		//
		// We'll compute dstW = round(srcW*scale), dstH = round(srcH*scale).
		// Using float64 here is acceptable: this is UI-only and deterministic enough, and we already use gg float.
		scaleW := float64(canvasW) / float64(srcW)
		scaleH := float64(canvasH) / float64(srcH)

		scale := scaleW
		if mode == BackgroundScaleFit {
			if scaleH < scale {
				scale = scaleH
			}
		} else {
			if scaleH > scale {
				scale = scaleH
			}
		}

		dstW := int(scale*float64(srcW) + 0.5)
		dstH := int(scale*float64(srcH) + 0.5)
		if dstW < 1 {
			dstW = 1
		}
		if dstH < 1 {
			dstH = 1
		}
		return dstW, dstH

	default:
		return srcW, srcH
	}
}

// backgroundAnchorOffsetPx computes a stable pixel offset for background anchoring.
// - Viewport anchor: offset is always 0 (background fixed to viewport/canvas pixels).
// - World anchor: offset depends on camera world position and zoom so that background moves with pan.
func (w *World) backgroundAnchorOffsetPx(params RenderParams, tileW, tileH int, anchor BackgroundAnchorMode) (int, int) {
	if anchor == BackgroundAnchorViewport {
		return 0, 0
	}

	zoomFp, err := params.CameraZoomFp()
	if err != nil || zoomFp <= 0 {
		return 0, 0
	}

	canvasW := params.CanvasWidthPx()
	canvasH := params.CanvasHeightPx()

	spanW := PixelSpanToWorldFixed(canvasW, zoomFp)
	spanH := PixelSpanToWorldFixed(canvasH, zoomFp)

	worldLeft := params.CameraXWorldFp - spanW/2
	worldTop := params.CameraYWorldFp - spanH/2

	pxX := worldSpanFixedToCanvasPx(worldLeft, zoomFp)
	pxY := worldSpanFixedToCanvasPx(worldTop, zoomFp)

	if tileW > 0 {
		pxX = -wrap(pxX, tileW)
	}
	if tileH > 0 {
		pxY = -wrap(pxY, tileH)
	}
	return pxX, pxY
}

func (w *World) candSeenResetIfOverflow() {
	w.candEpoch++
	if w.candEpoch != 0 {
		return
	}
	// overflow: reset stamp array
	for i := range w.candStamp {
		w.candStamp[i] = 0
	}
	w.candEpoch = 1
}

func (w *World) candSeenMark(id PrimitiveID) bool {
	// ensure stamp capacity
	uid := uint32(id)
	if int(uid) >= len(w.candStamp) {
		// grow to next power-ish
		n := len(w.candStamp)
		if n == 0 {
			n = 1024
		}
		for n <= int(uid) {
			n *= 2
		}
		ns := make([]uint32, n)
		copy(ns, w.candStamp)
		w.candStamp = ns
	}
	if w.candStamp[uid] == w.candEpoch {
		return true
	}
	w.candStamp[uid] = w.candEpoch
	return false
}

// RenderScheduler is a toolkit-agnostic example of render-request coalescing.
//
// It keeps at most one render in flight and always collapses intermediate
// requests to the latest RenderParams snapshot. The scheduler is intentionally
// not a background renderer: real UI integrations must still execute World.Render
// on the UI thread and should replace the goroutine hand-off in runOnUIThread
// with toolkit-specific scheduling primitives.
// RenderScheduler keeps the latest requested RenderParams and serializes renders.
type RenderScheduler struct {
	w      *World
	drawer PrimitiveDrawer

	// Protects fields below.
	mu sync.Mutex

	inFlight bool
	pending  bool
	latest   RenderParams
}

// RequestRender stores the latest params and schedules rendering.
// If a render is already in progress, it coalesces (drops intermediate requests).
func (s *RenderScheduler) RequestRender(params RenderParams) {
	s.mu.Lock()
	s.latest = params
	if s.inFlight {
		s.pending = true
		s.mu.Unlock()
		return
	}
	s.inFlight = true
	s.mu.Unlock()

	// Schedule on the UI thread/event loop. Replace this with your toolkit method.
	go s.runOnUIThread()
}

// runOnUIThread renders the latest known params and repeats if newer params
// arrived while the previous render was running.
//
// The example body uses a goroutine only as a placeholder. Real applications
// should run the body on their UI event loop.
func (s *RenderScheduler) runOnUIThread() {
	for {
		s.mu.Lock()
		params := s.latest
		s.mu.Unlock()

		s.w.ClampRenderParamsNoWrap(&params)
		_ = s.w.Render(s.drawer, params) // handle error in real code

		s.mu.Lock()
		if !s.pending {
			s.inFlight = false
			s.mu.Unlock()
			return
		}
		// There was a newer request while we were rendering. Loop and render latest.
		s.pending = false
		s.mu.Unlock()
	}
}

// RenderStyle describes visual parameters for renderer passes.
// It is intentionally screen-space oriented (pixels), since the renderer
// already projects world coordinates into canvas pixels.
type RenderStyle struct {
	// PointRadiusPx is the screen-space radius for Point markers.
	PointRadiusPx float64

	// PointFill is the fill color for points.
	PointFill color.Color

	// CircleFill is the fill color for circles.
	CircleFill color.Color

	// LineStroke is the stroke color for lines.
	LineStroke color.Color

	// LineWidthPx is the stroke width for lines.
	LineWidthPx float64

	// LineDash is the dash pattern for lines. Empty => solid.
	LineDash []float64

	// LineDashOffset is the dash phase for lines.
	LineDashOffset float64
}

// DefaultRenderStyle returns the default style used when UI does not provide one.
// Defaults are intentionally simple and stable for testing.
func DefaultRenderStyle() RenderStyle {
	return RenderStyle{
		PointRadiusPx: 2.0,
		PointFill:     color.White,

		CircleFill: color.White,

		LineStroke:     color.White,
		LineWidthPx:    2.0,
		LineDash:       nil,
		LineDashOffset: 0,
	}
}

// DefaultIncrementalPolicy returns the default incremental pan policy.
//
// The zero-friction default is conservative: no shift-only degradation, no
// render-budget heuristics, and no catch-up area cap.
func DefaultIncrementalPolicy() IncrementalPolicy {
	return IncrementalPolicy{
		CoalesceUpdates:  false,
		AllowShiftOnly:   false,
		RenderBudgetMs:   0,
		MaxCatchUpAreaPx: 0,
	}
}

// applyPointStyle configures drawer state for point rendering.
func applyPointStyle(drawer PrimitiveDrawer, style RenderStyle) {
	drawer.SetFillColor(style.PointFill)
}

// applyCircleStyle configures drawer state for circle rendering.
func applyCircleStyle(drawer PrimitiveDrawer, style RenderStyle) {
	drawer.SetFillColor(style.CircleFill)
}

// applyLineStyle configures drawer state for line rendering.
func applyLineStyle(drawer PrimitiveDrawer, style RenderStyle) {
	drawer.SetStrokeColor(style.LineStroke)
	drawer.SetLineWidth(style.LineWidthPx)
	drawer.SetDash(style.LineDash...)
	drawer.SetDashOffset(style.LineDashOffset)
}
