package world

import (
	"sort"
)

// PrimitiveKind identifies primitive types in hit-test results.
type PrimitiveKind uint8

const (
	KindLine PrimitiveKind = iota
	KindCircle
	KindPoint
)

// Hit describes one primitive that matches a hit-test query.
type Hit struct {
	ID       PrimitiveID
	Kind     PrimitiveKind
	Priority int
	StyleID  StyleID

	// DistanceSq is squared distance in world-fixed units to the primitive geometry (best-effort).
	// Used for tie-breaking (smaller is better).
	DistanceSq u128

	// Primitive world coordinates:
	// - Point: X,Y set
	// - Circle: X,Y,Radius set
	// - Line: X1,Y1,X2,Y2 set
	X, Y   int
	Radius int
	X1, Y1 int
	X2, Y2 int
}

// Default hit slop (in pixels) per primitive type.
const (
	DefaultHitSlopLinePx   = 6
	DefaultHitSlopCirclePx = 6
	DefaultHitSlopPointPx  = 8

	// If a circle's screen radius is below this threshold, treat it as point-like for hit testing.
	CirclePointLikeMinRadiusPx = 3
)

// HitTest finds primitives under cursor (in viewport pixel coordinates) with hit slop.
// The caller provides a buffer `out`. The returned slice aliases `out` (no allocations).
//
// If cap(out) is too small, it returns only the best hits by ranking:
//
//	Priority desc, Distance asc, Kind asc, ID asc.
//
// Notes:
// - cursorXPx/cursorYPx are relative to viewport top-left.
// - Works for wrap and no-wrap modes (based on params.Options.DisableWrapScroll).
func (w *World) HitTest(out []Hit, params *RenderParams, cursorXPx, cursorYPx int) ([]Hit, error) {
	if err := params.Validate(); err != nil {
		return nil, err
	}
	if w.grid == nil || w.rows == 0 || w.cols == 0 {
		return nil, errGridNotBuilt
	}

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

	allowWrap := true
	if params.Options != nil && params.Options.DisableWrapScroll {
		allowWrap = false
	}

	// Use clamped camera in no-wrap mode for consistency.
	camX := params.CameraXWorldFp
	camY := params.CameraYWorldFp
	if !allowWrap {
		camX, camY = ClampCameraNoWrapViewport(
			camX, camY,
			params.ViewportWidthPx, params.ViewportHeightPx,
			zoomFp,
			w.W, w.H,
		)
	}

	// Convert cursor viewport px to world-fixed coordinate (unwrapped relative to camera).
	worldPerPx := PixelSpanToWorldFixed(1, zoomFp)
	offXPx := cursorXPx - params.ViewportWidthPx/2
	offYPx := cursorYPx - params.ViewportHeightPx/2

	cursorX := camX + offXPx*worldPerPx
	cursorY := camY + offYPx*worldPerPx

	if allowWrap {
		cursorX = wrap(cursorX, w.W)
		cursorY = wrap(cursorY, w.H)
	} else {
		// Clamp cursor into world bounds to avoid weird negative coords in margins.
		cursorX = clamp(cursorX, 0, w.W-1)
		cursorY = clamp(cursorY, 0, w.H-1)
	}

	// Compute a conservative search bbox around cursor using max possible slop (px->world).
	// We use the maximum of default slops; per-object overrides are handled later.
	maxSlopPx := max(DefaultHitSlopLinePx, max(DefaultHitSlopCirclePx, DefaultHitSlopPointPx))
	maxSlopWorld := PixelSpanToWorldFixed(maxSlopPx, zoomFp)

	minX := cursorX - maxSlopWorld
	maxX := cursorX + maxSlopWorld + 1
	minY := cursorY - maxSlopWorld
	maxY := cursorY + maxSlopWorld + 1

	var rects []Rect
	if allowWrap {
		rects = splitByWrap(w.W, w.H, minX, maxX, minY, maxY)
	} else {
		// Clamp to world.
		minX = clamp(minX, 0, w.W)
		maxX = clamp(maxX, 0, w.W)
		minY = clamp(minY, 0, w.H)
		maxY = clamp(maxY, 0, w.H)
		if maxX <= minX || maxY <= minY {
			return out[:0], nil
		}
		rects = []Rect{{minX: minX, maxX: maxX, minY: minY, maxY: maxY}}
	}

	// Gather candidates from grid cells, dedupe by ID.
	cand := make(map[PrimitiveID]struct{}, 32)
	for _, r := range rects {
		colStart := w.worldToCellX(r.minX)
		colEnd := w.worldToCellX(r.maxX - 1)
		rowStart := w.worldToCellY(r.minY)
		rowEnd := w.worldToCellY(r.maxY - 1)

		for row := rowStart; row <= rowEnd; row++ {
			for col := colStart; col <= colEnd; col++ {
				cell := w.grid[row][col]
				for _, it := range cell {
					cand[it.ID()] = struct{}{}
				}
			}
		}
	}

	// Use caller buffer as backing store; keep only best cap(out) hits.
	out = out[:0]
	limit := cap(out)

	for id := range cand {
		cur, ok := w.objects[id]
		if !ok {
			continue
		}
		h, ok := w.hitOne(cur, cursorX, cursorY, zoomFp, allowWrap)
		if !ok {
			continue
		}

		if limit == 0 {
			// Caller provided zero-cap buffer; cannot store anything.
			continue
		}

		if len(out) < limit {
			out = append(out, h)
			continue
		}

		// Replace the worst hit if the new one is better.
		worstIdx := 0
		for i := 1; i < len(out); i++ {
			if hitLess(out[worstIdx], out[i]) {
				worstIdx = i // out[i] is worse than out[worstIdx]
			}
		}
		if hitLess(h, out[worstIdx]) {
			out[worstIdx] = h
		}
	}

	// Sort final hits by best-first order.
	sort.Slice(out, func(i, j int) bool {
		return hitLess(out[i], out[j])
	})

	return out, nil
}

// hitLess orders hits by:
// Priority desc, DistanceSq asc, Kind asc, ID asc.
func hitLess(a, b Hit) bool {
	if a.Priority != b.Priority {
		return a.Priority > b.Priority
	}
	if c := u128Cmp(a.DistanceSq, b.DistanceSq); c != 0 {
		return c < 0
	}
	if a.Kind != b.Kind {
		return a.Kind < b.Kind
	}
	return a.ID < b.ID
}

func (w *World) hitOne(it MapItem, cx, cy int, zoomFp int, allowWrap bool) (Hit, bool) {
	switch v := it.(type) {
	case Point:
		return hitPoint(v, cx, cy, zoomFp, allowWrap, w.W, w.H)

	case Circle:
		style, ok := w.styles.Get(v.StyleID)
		if !ok {
			// Unknown style should not happen; treat as no-hit rather than panic.
			return Hit{}, false
		}
		return hitCircle(v, circleRadiusEffFp(v.Radius, w.circleRadiusScaleFp), style, cx, cy, zoomFp, allowWrap, w.W, w.H)

	case Line:
		return hitLine(v, cx, cy, zoomFp, allowWrap, w.W, w.H)

	default:
		panic("HitTest: unknown map item type")
	}
}
