package webui

import (
	"strings"

	"kjol/wasmruntime"
)

// The floating-position engine: given where the trigger is, how big the panel is,
// and how big the viewport is, work out where to put the panel. Pure math over
// structs — no DOM, no browser — so it tests natively and runs identically on the
// server (where it simply never gets called, because nothing is open during SSR).
//
// This replaces the hand-rolled positioning that the TSX kit had in four divergent
// copies (Floating.tsx, Tutorial.tsx, Menu's Submenu, DatePicker's dropdown). It
// deliberately FIXES three flaws in the original rather than reproducing them:
//
//  1. The original shifted on BOTH axes, so a tall panel that did not fit got
//     clamped up into its own trigger — covering the thing you clicked. Shift here
//     is cross-axis only (what floating-ui does): the main axis is the flip's job.
//  2. The original had no arrow offset, so a shifted panel kept its arrow pinned to
//     its own centre and the arrow visibly detached from the trigger. Here the
//     arrow tracks the trigger's centre, clamped to stay on the panel.
//  3. The original could not tell a caller how much room there actually was, so a
//     long menu near the viewport edge just overflowed. MaxHeight / MaxWidth report
//     the space available on the main axis so the panel can scroll instead.
//
// All coordinates are viewport coordinates (getBoundingClientRect + position:fixed
// speak the same language), so there is no scroll compensation anywhere. Adding
// scrollX/scrollY here would be a bug, not a fix.

// Placement values: a base side, optionally with a cross-axis alignment.
const (
	PlacementTop         = "top"
	PlacementTopStart    = "top-start"
	PlacementTopEnd      = "top-end"
	PlacementBottom      = "bottom"
	PlacementBottomStart = "bottom-start"
	PlacementBottomEnd   = "bottom-end"
	PlacementLeft        = "left"
	PlacementLeftStart   = "left-start"
	PlacementLeftEnd     = "left-end"
	PlacementRight       = "right"
	PlacementRightStart  = "right-start"
	PlacementRightEnd    = "right-end"
)

// PositionOptions tunes ComputePosition. The zero value is not useful; use
// DefaultPositionOptions and override.
type PositionOptions struct {
	Placement string  // one of the Placement* constants; default bottom-start
	Offset    float64 // gap between trigger and panel, in px
	Flip      bool    // move to the opposite side when this side does not fit
	Shift     bool    // slide along the cross axis to stay in the viewport
	Padding   float64 // keep this far from the viewport edge (flip fit-check + shift clamp)

	// ArrowSize is the arrow's full width/height in px. Zero means no arrow, and
	// ArrowOffset is left at 0.
	ArrowSize float64
	// ArrowPadding keeps the arrow this far from the panel's corners, so it never
	// pokes out of a rounded edge.
	ArrowPadding float64
}

// DefaultPositionOptions matches the TSX defaults (bottom-start, 4px offset, flip
// and shift on, 8px viewport padding).
func DefaultPositionOptions() PositionOptions {
	return PositionOptions{
		Placement:    PlacementBottomStart,
		Offset:       4,
		Flip:         true,
		Shift:        true,
		Padding:      8,
		ArrowPadding: 4,
	}
}

// Position is where to put the panel, in viewport coordinates.
type Position struct {
	Top, Left float64

	// Placement is the RESOLVED placement — after any flip. This is consumer-visible:
	// an arrow must be drawn on the side opposite the resolved base, not the
	// requested one, or a flipped tooltip points away from what it describes.
	Placement string

	// ArrowOffset is the arrow's centre along the panel's cross axis, measured from
	// the panel's own top-left: an x for top/bottom placements, a y for left/right.
	// Zero when ArrowSize is 0.
	ArrowOffset float64

	// MaxHeight / MaxWidth are the space available on the main axis at the resolved
	// placement, for a panel that should scroll rather than overflow. Only the
	// main-axis one is set; the other is 0, meaning unconstrained.
	MaxHeight, MaxWidth float64
}

// ComputePosition places `floating` against `trigger` inside `vp`.
//
// The caller must have measured a real, laid-out panel: to know where a panel goes
// you must first know how big it is, which means rendering it (invisibly) and
// measuring it. See Floating.reposition, which does exactly that dance.
func ComputePosition(trigger, floating wasmruntime.Rect, vp wasmruntime.Size, o PositionOptions) Position {
	if o.Placement == "" {
		o.Placement = PlacementBottomStart
	}
	base, align := splitPlacement(o.Placement)

	top, left := mainAxis(base, trigger, floating, o.Offset)
	crossAxis(base, align, trigger, floating, &top, &left)

	if o.Flip {
		base = flip(base, top, left, trigger, floating, vp, o, &top, &left)
	}
	if o.Shift {
		shiftCrossAxis(base, floating, vp, o.Padding, &top, &left)
	}

	pos := Position{Top: top, Left: left, Placement: joinPlacement(base, align)}
	pos.MaxHeight, pos.MaxWidth = available(base, trigger, vp, o)
	if o.ArrowSize > 0 {
		pos.ArrowOffset = arrowOffset(base, trigger, floating, top, left, o)
	}
	return pos
}

// mainAxis positions the panel on the axis it is offset along — the only axis the
// base side controls.
func mainAxis(base string, t, f wasmruntime.Rect, offset float64) (top, left float64) {
	switch base {
	case "top":
		top = t.Top() - f.Height - offset
	case "bottom":
		top = t.Bottom() + offset
	case "left":
		left = t.Left() - f.Width - offset
	case "right":
		left = t.Right() + offset
	}
	return
}

// crossAxis aligns the panel across the base side: start/center/end.
func crossAxis(base, align string, t, f wasmruntime.Rect, top, left *float64) {
	if base == "top" || base == "bottom" {
		switch align {
		case "start":
			*left = t.Left()
		case "end":
			*left = t.Right() - f.Width
		default:
			*left = t.CenterX() - f.Width/2
		}
		return
	}
	switch align {
	case "start":
		*top = t.Top()
	case "end":
		*top = t.Bottom() - f.Height
	default:
		*top = t.CenterY() - f.Height/2
	}
}

// flip moves the panel to the opposite side when it does not fit on this one — but
// only if the opposite side actually fits. If neither side fits we keep the
// original: flipping into an equally bad position just makes it harder to predict.
// The cross-axis alignment is preserved (bottom-start flips to top-start).
func flip(base string, top, left float64, t, f wasmruntime.Rect, vp wasmruntime.Size, o PositionOptions, outTop, outLeft *float64) string {
	pad := o.Padding
	fits := func(start, size, limit float64) bool { return start >= pad && start+size <= limit-pad }

	switch base {
	case "bottom":
		if !fits(top, f.Height, vp.Height) {
			if alt := t.Top() - f.Height - o.Offset; fits(alt, f.Height, vp.Height) {
				*outTop = alt
				return "top"
			}
		}
	case "top":
		if !fits(top, f.Height, vp.Height) {
			if alt := t.Bottom() + o.Offset; fits(alt, f.Height, vp.Height) {
				*outTop = alt
				return "bottom"
			}
		}
	case "right":
		if !fits(left, f.Width, vp.Width) {
			if alt := t.Left() - f.Width - o.Offset; fits(alt, f.Width, vp.Width) {
				*outLeft = alt
				return "left"
			}
		}
	case "left":
		if !fits(left, f.Width, vp.Width) {
			if alt := t.Right() + o.Offset; fits(alt, f.Width, vp.Width) {
				*outLeft = alt
				return "right"
			}
		}
	}
	return base
}

// shiftCrossAxis slides the panel along the CROSS axis to keep it on screen. It
// deliberately does not touch the main axis: clamping there is what let the
// original push a panel on top of its own trigger.
func shiftCrossAxis(base string, f wasmruntime.Rect, vp wasmruntime.Size, pad float64, top, left *float64) {
	if base == "top" || base == "bottom" {
		*left = clamp(*left, pad, vp.Width-f.Width-pad)
		return
	}
	*top = clamp(*top, pad, vp.Height-f.Height-pad)
}

// available reports the room between the trigger and the viewport edge on the
// resolved side, so a panel too big for it can scroll instead of overflowing.
func available(base string, t wasmruntime.Rect, vp wasmruntime.Size, o PositionOptions) (maxHeight, maxWidth float64) {
	gap := o.Offset + o.Padding
	switch base {
	case "top":
		return max(t.Top()-gap, 0), 0
	case "bottom":
		return max(vp.Height-t.Bottom()-gap, 0), 0
	case "left":
		return 0, max(t.Left()-gap, 0)
	case "right":
		return 0, max(vp.Width-t.Right()-gap, 0)
	}
	return 0, 0
}

// arrowOffset points the arrow at the trigger's centre, in panel-local coordinates,
// clamped so it stays on the panel even when shift has slid the panel away from the
// trigger. When the trigger is entirely off past the panel's edge the arrow sits at
// the clamp limit — visibly at the corner, which is the honest answer.
func arrowOffset(base string, t, f wasmruntime.Rect, top, left float64, o PositionOptions) float64 {
	half := o.ArrowSize / 2
	if base == "top" || base == "bottom" {
		lo := o.ArrowPadding + half
		hi := f.Width - o.ArrowPadding - half
		return clamp(t.CenterX()-left, lo, max(lo, hi))
	}
	lo := o.ArrowPadding + half
	hi := f.Height - o.ArrowPadding - half
	return clamp(t.CenterY()-top, lo, max(lo, hi))
}

func splitPlacement(p string) (base, align string) {
	base, align, found := strings.Cut(p, "-")
	if !found {
		align = "center"
	}
	switch base {
	case "top", "bottom", "left", "right":
	default:
		base = "bottom"
	}
	return base, align
}

func joinPlacement(base, align string) string {
	if align == "" || align == "center" {
		return base
	}
	return base + "-" + align
}

// OppositeSide is the side an arrow lives on: a panel placed above its trigger has
// its arrow on the bottom edge, pointing down at it.
func OppositeSide(placement string) string {
	base, _ := splitPlacement(placement)
	switch base {
	case "top":
		return "bottom"
	case "bottom":
		return "top"
	case "left":
		return "right"
	default:
		return "left"
	}
}

func clamp(v, lo, hi float64) float64 {
	if v < lo {
		return lo
	}
	if v > hi {
		return hi
	}
	return v
}
