package webui

import (
	"strconv"

	"kjol/wasmruntime"
	"kjol/wasmruntime/vdom"
)

// Port of jsruntime/uikit/Tutorial.tsx — a guided tour: it dims the page, cuts a
// spotlight hole around the step's target element, and floats a popover card with
// step navigation.
//
// Tutorial is an ENGINE, not a pure function. It owns everything the tour needs to
// know that a render cannot: where the target is (a measurement), how big the card
// is (another measurement), which frame the animation is on, and which timers are in
// flight. Create it ONCE, outside your render function:
//
//	tour := webui.NewTutorial(webui.TutorialOptions{Steps: []webui.TutorialStep{
//	    {Title: "Filters", Target: "#filters", Placement: webui.PlacementRight,
//	     Content: func() *vdom.VNode { return Text("Narrow the table down here.") }},
//	    {Title: "Export",  Target: "#export-btn",
//	     Content: func() *vdom.VNode { return Text("…then export what is left.") }},
//	}})
//
//	return func() *vdom.VNode {
//	    return Div(page(), tour.StartButton(0, ""), tour.Render())
//	}
//
// How a step is staged, and why in that order:
//
//  1. The step's Target is a CSS selector — QuerySelector finds an element the tour
//     does not own — resolved after a short delay, so a route transition or a layout
//     settling does not hand us a stale box.
//  2. The target is measured and smooth-scrolled to the centre of the viewport.
//  3. The spotlight is a `position: fixed` box sized to that rect plus padding, with
//     `box-shadow: 0 0 0 9999px rgba(0,0,0,0.5)`: the enormous spread IS the page
//     dimming, and the box is the hole in it. Because its geometry is written with
//     SetStyle onto an element that declares `transition: all 100ms`, the hole
//     ANIMATES from one target to the next instead of teleporting.
//  4. The card is placed with ComputePosition (the same engine the floating layer
//     uses — no hand-rolled second copy) and revealed. It re-positions on scroll
//     (capture, because scroll does not bubble) and resize.
//
// Nothing here is browser-only in the Go sense: every host call stubs out natively,
// so on the server the tour is simply never active, Render emits an empty portal, and
// no measurement, listener or timer exists. There is no black screen to SSR.
type Tutorial struct {
	opts TutorialOptions

	active *vdom.Signal[bool]
	// index is the live step; displayed is the step currently PAINTED. They differ
	// only during a step transition, which double-buffers the card: the content is
	// swapped at the midpoint of the cross-fade.
	index     *vdom.Signal[int]
	displayed *vdom.Signal[int]
	// placement is the RESOLVED placement (after ComputePosition may have flipped it).
	// The arrow's side is a class/style decision, so it has to go through a render —
	// hence a signal, written only when it actually changes.
	placement *vdom.Signal[string]
	showArrow *vdom.Signal[bool]
	// spotlight is true when the step's target resolved AND has been measured; false
	// means the flat-dim fallback (also the state a step with no target renders, and
	// the only state the server can produce).
	spotlight *vdom.Signal[bool]

	popoverRef *vdom.Ref
	contentRef *vdom.Ref
	overlayRef *vdom.Ref

	// Everything below is plain state, deliberately NOT signals: it feeds imperative
	// style writes that run on every scroll frame, and a signal write there would
	// re-render the whole app per frame.
	rect            wasmruntime.Rect
	radius          float64
	positioned      bool
	transitioning   bool
	revealed        bool // the overlay's dim has faded in on the current overlay node
	firstAppearance bool

	unsubs      []wasmruntime.Unsub
	targetTimer int
	stepTimer   int
	arrowTimer  int
}

// TutorialStep is one stop on the tour.
type TutorialStep struct {
	Title string
	// Content is called on every render, so content that reads signals stays live.
	Content func() *vdom.VNode
	// Target is a CSS selector for the element to spotlight. Empty means the step has
	// no anchor: the page dims flat and the card centres in the viewport.
	Target string
	// Placement is one of the Placement* constants; default bottom. It may be flipped
	// if the card does not fit on that side.
	Placement string
	// Offset is the gap between target and card in px; default 16.
	Offset float64

	OnEnter func()
	OnLeave func()
}

// TutorialOptions configures a Tutorial. The zero value is usable (no steps).
type TutorialOptions struct {
	Steps []TutorialStep
	// SpotlightPadding is how far the cutout is inflated past the target; default 8.
	SpotlightPadding float64
	// OnEnd fires after the tour ends, however it ended (Finish, ✕, Escape, a click
	// on the dim, or End()).
	OnEnd func()
	// Class is appended to the popover card.
	Class string
}

// TSX constants: _TUTORIAL_ANIMATION_DURATION, the popover's default offset, and the
// beat we wait before measuring a target (long enough for a route transition or a
// layout to settle, short enough not to be seen).
const (
	tutorialAnimMS      = 100
	tutorialHalfMS      = tutorialAnimMS / 2
	tutorialSettleMS    = 50
	tutorialTargetMS    = 50
	tutorialDefaultPad  = 8.0
	tutorialDefaultOff  = 16.0
	tutorialViewportPad = 16.0
	// tutorialFallbackRadius is the TSX's initial borderRadius, used when
	// --radius-default is unreadable (and on the server).
	tutorialFallbackRadius = 3.2
)

// Declared styles. Each is CONSTANT across renders, which is what lets the imperative
// writes below survive reconciliation (the reconciler only re-sets an attribute whose
// declared value changed). They encode the pre-measurement state — the state the
// server renders and the browser paints for one frame before the first measurement.
const (
	// The card: laid out (so it can be measured) but not painted, and faded/scaled
	// down ready for the enter animation. No top/left transition yet — the first
	// appearance must not slide in from 0,0.
	tutorialPopoverStyle = "top:0;left:0;visibility:hidden;opacity:0;transform:scale(0.95);transition:opacity 100ms cubic-bezier(0.4, 0, 0.2, 1), transform 100ms cubic-bezier(0.4, 0, 0.2, 1)"
	// From the first step change on, top/left transition too, so the card SLIDES
	// between targets. Written imperatively when the first transition starts.
	tutorialPopoverSlide = "opacity 100ms cubic-bezier(0.4, 0, 0.2, 1), transform 100ms cubic-bezier(0.4, 0, 0.2, 1), top 100ms cubic-bezier(0.4, 0, 0.2, 1), left 100ms cubic-bezier(0.4, 0, 0.2, 1)"
	// The card's inner content, cross-faded on a step change (out, swap, in).
	tutorialContentStyle = "opacity:1;transition:opacity 50ms ease-out"
	// The spotlight: a zero-size box with a transparent 9999px shadow. The geometry
	// and the shadow's alpha are both written imperatively; `transition: all` is what
	// animates the hole between targets.
	tutorialSpotlightStyle  = "left:0;top:0;width:0;height:0;box-shadow:0 0 0 9999px rgba(0, 0, 0, 0);transition:all 100ms cubic-bezier(0.4, 0, 0.2, 1)"
	tutorialSpotlightShadow = "0 0 0 9999px rgba(0, 0, 0, 0.5)"
	// The no-target fallback: a flat dim that fades in.
	tutorialBackdropStyle = "opacity:0;transition:opacity 100ms ease-out"
)

// Tailwind classes, verbatim from the TSX.
const (
	tutorialPopoverClass   = "fixed z-200 bg-surface rounded-default shadow-lg border border-line max-w-sm"
	tutorialSpotlightClass = "fixed z-150 pointer-events-none rounded-default"
	tutorialCatcherClass   = "fixed inset-0 -z-10 cursor-pointer"
	tutorialBackdropClass  = "fixed inset-0 bg-black/50 z-150"
	tutorialArrowClass     = "absolute w-0 h-0"
)

// The arrow is two stacked CSS triangles: a 9px one in the border colour, and an 8px
// white one on top of it, which is what makes a bordered arrow out of two borders.
// Both are keyed by the RESOLVED base placement and hang off the OPPOSITE edge of the
// card — a card placed above its target carries its arrow on its bottom edge, pointing
// down at it.
var tutorialArrowOuter = map[string]string{
	"top":    "bottom:-9px;left:50%;transform:translateX(-50%);border-left:9px solid transparent;border-right:9px solid transparent;border-top:9px solid #e5e5e5;",
	"bottom": "top:-9px;left:50%;transform:translateX(-50%);border-left:9px solid transparent;border-right:9px solid transparent;border-bottom:9px solid #e5e5e5;",
	"left":   "right:-9px;top:50%;transform:translateY(-50%);border-top:9px solid transparent;border-bottom:9px solid transparent;border-left:9px solid #e5e5e5;",
	"right":  "left:-9px;top:50%;transform:translateY(-50%);border-top:9px solid transparent;border-bottom:9px solid transparent;border-right:9px solid #e5e5e5;",
}

var tutorialArrowInner = map[string]string{
	"top":    "bottom:-8px;left:50%;transform:translateX(-50%);border-left:8px solid transparent;border-right:8px solid transparent;border-top:8px solid white;",
	"bottom": "top:-8px;left:50%;transform:translateX(-50%);border-left:8px solid transparent;border-right:8px solid transparent;border-bottom:8px solid white;",
	"left":   "right:-8px;top:50%;transform:translateY(-50%);border-top:8px solid transparent;border-bottom:8px solid transparent;border-left:8px solid white;",
	"right":  "left:-8px;top:50%;transform:translateY(-50%);border-top:8px solid transparent;border-bottom:8px solid transparent;border-right:8px solid white;",
}

const tutorialArrowTransition = "width:0;height:0;transition:all 100ms cubic-bezier(0.4, 0, 0.2, 1);"

// NewTutorial creates a tour engine. Call it once, outside your render function.
func NewTutorial(o TutorialOptions) *Tutorial {
	if o.SpotlightPadding == 0 {
		o.SpotlightPadding = tutorialDefaultPad
	}
	return &Tutorial{
		opts:            o,
		active:          vdom.NewSignal(false),
		index:           vdom.NewSignal(0),
		displayed:       vdom.NewSignal(0),
		placement:       vdom.NewSignal(PlacementBottom),
		showArrow:       vdom.NewSignal(false),
		spotlight:       vdom.NewSignal(false),
		popoverRef:      vdom.NewRef(),
		contentRef:      vdom.NewRef(),
		overlayRef:      vdom.NewRef(),
		firstAppearance: true,
	}
}

// SetSteps replaces the tour's steps (for a tour whose content depends on data that
// arrives later). Ends the tour if it is running.
func (t *Tutorial) SetSteps(steps []TutorialStep) {
	if t.active.Get() {
		t.End()
	}
	t.opts.Steps = steps
}

// IsActive, CurrentStep and TotalSteps are the tour's public state (the TSX's
// TutorialContextValue). Safe to read during render.
func (t *Tutorial) IsActive() bool   { return t.active.Get() }
func (t *Tutorial) CurrentStep() int { return t.index.Get() }
func (t *Tutorial) TotalSteps() int  { return len(t.opts.Steps) }

func (t *Tutorial) step(i int) *TutorialStep {
	if i < 0 || i >= len(t.opts.Steps) {
		return nil
	}
	return &t.opts.Steps[i]
}

// ---- driving the tour ----

// Start begins the tour at stepIndex (clamped). Starting an already-running tour just
// jumps to that step.
func (t *Tutorial) Start(stepIndex int) {
	if len(t.opts.Steps) == 0 {
		return
	}
	i := clampIndex(stepIndex, len(t.opts.Steps))
	if t.active.Get() {
		t.GoTo(i)
		return
	}

	t.rect = wasmruntime.Rect{}
	t.positioned = false
	t.transitioning = false
	t.revealed = false
	t.firstAppearance = true

	t.index.Set(i)
	t.displayed.Set(i)
	t.placement.Set(PlacementBottom)
	t.showArrow.Set(false)
	t.spotlight.Set(false)
	t.active.Set(true)

	if s := t.step(i); s != nil && s.OnEnter != nil {
		s.OnEnter()
	}
	// The overlay and card do not exist yet — the signal writes only *scheduled* a
	// render. Measure and animate once they do.
	wasmruntime.AfterRender(t.mounted)
}

// End stops the tour.
func (t *Tutorial) End() {
	if !t.active.Get() {
		return
	}
	if s := t.step(t.index.Get()); s != nil && s.OnLeave != nil {
		s.OnLeave()
	}
	t.teardown()

	t.rect = wasmruntime.Rect{}
	t.positioned = false
	t.transitioning = false
	t.revealed = false
	t.firstAppearance = true

	t.active.Set(false)
	t.index.Set(0)
	t.displayed.Set(0)
	t.showArrow.Set(false)
	t.spotlight.Set(false)

	if t.opts.OnEnd != nil {
		t.opts.OnEnd()
	}
}

// Next advances, or ends the tour on the last step.
func (t *Tutorial) Next() {
	if !t.active.Get() {
		return
	}
	if i := t.index.Get(); i < len(t.opts.Steps)-1 {
		t.GoTo(i + 1)
		return
	}
	t.End()
}

// Previous steps back (a no-op on the first step).
func (t *Tutorial) Previous() {
	if !t.active.Get() {
		return
	}
	if i := t.index.Get(); i > 0 {
		t.GoTo(i - 1)
	}
}

// GoTo jumps to a step, running the leave/enter hooks and the transition choreography.
//
// The ORDER here is load-bearing, and it is the TSX's:
//
//  1. The target is re-resolved on a timer FIRST, so the new rect is in hand by the
//     time the card asks where to go.
//  2. The card fades its content out, swaps the step at the midpoint of the fade,
//     re-positions (now with top/left transitions on, so it slides), fades back in,
//     and only then re-shows the arrow.
//
// While all that is in flight, `transitioning` suppresses scroll/resize
// re-positioning: the card is mid-slide and must not be yanked by a scroll frame
// (including the smooth scroll this very step just started).
func (t *Tutorial) GoTo(stepIndex int) {
	if !t.active.Get() || len(t.opts.Steps) == 0 {
		return
	}
	i := clampIndex(stepIndex, len(t.opts.Steps))
	cur := t.index.Get()
	if i == cur {
		return
	}

	if s := t.step(cur); s != nil && s.OnLeave != nil {
		s.OnLeave()
	}
	t.index.Set(i)
	if s := t.step(i); s != nil && s.OnEnter != nil {
		s.OnEnter()
	}

	// 1. the new target.
	wasmruntime.ClearTimeout(t.targetTimer)
	t.targetTimer = wasmruntime.SetTimeout(tutorialTargetMS, t.resolveTarget)

	// 2. the card. A jump that lands mid-transition updates the index (and the target)
	//    but does not restart the choreography — the in-flight swap reads the LIVE
	//    index, so it lands on the newest step anyway.
	if t.transitioning {
		return
	}
	t.transitioning = true
	t.firstAppearance = false
	wasmruntime.SetStyle(t.popoverRef, "transition", tutorialPopoverSlide)
	t.showArrow.Set(false)
	wasmruntime.SetStyle(t.contentRef, "opacity", "0")

	t.stepTimer = wasmruntime.SetTimeout(tutorialHalfMS, func() {
		t.stepTimer = 0
		if !t.active.Get() {
			t.transitioning = false
			return
		}
		t.displayed.Set(t.index.Get()) // swap at the midpoint of the fade
		wasmruntime.AfterRender(func() {
			// The new content is in the DOM: measure it and let the card slide.
			t.updatePosition()
			t.stepTimer = wasmruntime.SetTimeout(tutorialSettleMS, func() {
				wasmruntime.SetStyle(t.contentRef, "opacity", "1")
				t.stepTimer = wasmruntime.SetTimeout(tutorialHalfMS, func() {
					t.stepTimer = 0
					t.transitioning = false
					t.showArrow.Set(true)
				})
			})
		})
	})
}

// Dispose ends the tour and removes every listener and timer.
func (t *Tutorial) Dispose() {
	t.teardown()
	if t.active.Get() {
		t.active.Set(false)
	}
}

func (t *Tutorial) teardown() {
	for _, un := range t.unsubs {
		un()
	}
	t.unsubs = nil
	wasmruntime.ClearTimeout(t.targetTimer)
	wasmruntime.ClearTimeout(t.stepTimer)
	wasmruntime.ClearTimeout(t.arrowTimer)
	t.targetTimer, t.stepTimer, t.arrowTimer = 0, 0, 0
}

// mounted runs once the overlay and card are in the DOM.
func (t *Tutorial) mounted() {
	// The spotlight's corners should match the app's theme, so read the radius token
	// rather than guessing. CSSVarPx resolves rem against the root font size.
	t.radius = wasmruntime.CSSVarPx("--radius-default")
	if t.radius <= 0 {
		t.radius = tutorialFallbackRadius
	}

	t.unsubs = append(t.unsubs,
		wasmruntime.OnDocument(vdom.EVENT_KEYDOWN, false, t.onKeydown),
		// capture=true: `scroll` does not bubble, so this is the only way to hear a
		// scroll inside a nested container the target happens to live in.
		wasmruntime.OnWindow(vdom.EVENT_SCROLL, true, func(vdom.Event) { t.onViewportChange() }),
		wasmruntime.OnWindow(vdom.EVENT_RESIZE, false, func(vdom.Event) { t.onViewportChange() }),
	)

	t.writeOverlay() // no rect yet: the flat dim, fading in
	t.targetTimer = wasmruntime.SetTimeout(tutorialTargetMS, t.resolveTarget)

	// Position on the next frame, reveal on the one after: the initial (faded, scaled)
	// style must be COMMITTED before the target lands, or there is nothing for the
	// browser to interpolate from and the card just appears.
	wasmruntime.RAF(func() {
		t.updatePosition()
		t.positioned = true
		wasmruntime.RAF(func() {
			wasmruntime.SetStyle(t.popoverRef, "opacity", "1")
			wasmruntime.SetStyle(t.popoverRef, "transform", "scale(1)")
			t.arrowTimer = wasmruntime.SetTimeout(tutorialAnimMS, func() {
				t.arrowTimer = 0
				if t.active.Get() {
					t.showArrow.Set(true)
				}
			})
		})
	})
}

// onKeydown: Escape ends the tour, ArrowRight/Enter advance, ArrowLeft goes back.
func (t *Tutorial) onKeydown(ev vdom.Event) {
	switch ev.Key() {
	case vdom.KEY_ESCAPE:
		t.End()
	case vdom.KEY_ARROW_RIGHT, vdom.KEY_ENTER:
		t.Next()
	case vdom.KEY_ARROW_LEFT:
		t.Previous()
	}
}

// ---- the target ----

// targetRef finds the current step's target in the document. The selector is
// re-queried on every measurement rather than cached, so a target that is re-rendered
// (a virtualised row, a re-keyed panel) does not leave the tour pointing at a detached
// node.
func (t *Tutorial) targetRef() *vdom.Ref {
	s := t.step(t.index.Get())
	if s == nil || s.Target == "" {
		return nil
	}
	r := wasmruntime.QuerySelector(s.Target)
	if !r.Mounted() {
		return nil
	}
	return r
}

// resolveTarget runs once per step, on a short delay: measure the target and bring it
// into view. The smooth scroll is deliberately NOT repeated on every scroll frame (the
// TSX did, which fought the user for the scroll position); it belongs to entering a
// step.
func (t *Tutorial) resolveTarget() {
	t.targetTimer = 0
	if !t.active.Get() {
		return
	}
	r := t.targetRef()
	if r == nil {
		t.rect = wasmruntime.Rect{}
		t.applyOverlay()
		return
	}
	t.rect = wasmruntime.Measure(r)
	wasmruntime.ScrollIntoView(r, true, wasmruntime.ScrollBlockCenter)
	t.applyOverlay()
	if !t.transitioning && t.positioned {
		t.updatePosition()
	}
}

// onViewportChange re-measures on scroll and resize. Both the spotlight and the card
// follow the target — imperatively, so a scroll frame costs two measurements and a few
// style writes rather than a re-render of the app.
func (t *Tutorial) onViewportChange() {
	if !t.active.Get() {
		return
	}
	if r := t.targetRef(); r != nil {
		t.rect = wasmruntime.Measure(r)
	} else {
		t.rect = wasmruntime.Rect{}
	}
	t.applyOverlay()
	if !t.transitioning && t.positioned {
		t.updatePosition()
	}
}

// hasSpotlight reports whether there is a measured target to cut a hole around. A step
// with no target — or one whose selector matched nothing, or nothing laid out — falls
// back to the flat dim. On the server this is always false, which is why SSR ships a
// tour that is simply not running rather than a black screen.
func (t *Tutorial) hasSpotlight() bool {
	s := t.step(t.index.Get())
	return s != nil && s.Target != "" && !t.rect.Empty()
}

// ---- the overlay ----

// applyOverlay reconciles the overlay with the current measurement. Flipping between
// the cutout and the flat dim swaps the rendered node, so the styles have to be
// (re)written once that render has committed; when nothing flips, the write is direct.
func (t *Tutorial) applyOverlay() {
	has := t.hasSpotlight()
	if t.spotlight.Get() != has {
		t.spotlight.Set(has)
		t.revealed = false // a new node: it must fade its dim in again
		wasmruntime.AfterRender(t.writeOverlay)
		return
	}
	t.writeOverlay()
}

// writeOverlay writes the overlay's geometry, then fades its dim in the first time it
// runs on a given node. Every property of the mode NOT in use is removed, so a node the
// reconciler reused across a flip cannot keep a stale inline `left` and drag the flat
// backdrop off screen.
func (t *Tutorial) writeOverlay() {
	if !t.active.Get() || !t.overlayRef.Mounted() {
		return
	}
	if t.spotlight.Get() {
		pad := t.opts.SpotlightPadding
		wasmruntime.RemoveStyle(t.overlayRef, "opacity")
		wasmruntime.SetStyle(t.overlayRef, "left", px(t.rect.Left()-pad))
		wasmruntime.SetStyle(t.overlayRef, "top", px(t.rect.Top()-pad))
		wasmruntime.SetStyle(t.overlayRef, "width", px(t.rect.Width+pad*2))
		wasmruntime.SetStyle(t.overlayRef, "height", px(t.rect.Height+pad*2))
		wasmruntime.SetStyle(t.overlayRef, "border-radius", px(t.radius))
	} else {
		for _, p := range []string{"left", "top", "width", "height", "border-radius", "box-shadow"} {
			wasmruntime.RemoveStyle(t.overlayRef, p)
		}
	}

	if t.revealed {
		t.revealOverlay() // idempotent; keeps the dim applied on a freshly swapped node
		return
	}
	t.revealed = true
	// Double rAF, for the same reason the modal needs one: the transparent initial
	// state has to be committed a frame before the dim lands, or it does not fade.
	wasmruntime.RAF(func() { wasmruntime.RAF(t.revealOverlay) })
}

func (t *Tutorial) revealOverlay() {
	if !t.active.Get() || !t.overlayRef.Mounted() {
		return
	}
	if t.spotlight.Get() {
		wasmruntime.SetStyle(t.overlayRef, "box-shadow", tutorialSpotlightShadow)
		return
	}
	wasmruntime.SetStyle(t.overlayRef, "opacity", "1")
}

// ---- the card ----

// updatePosition measures the card, works out where it goes, and writes it. This is the
// only place the card's coordinates are decided, and it never goes through a signal: it
// runs on every scroll frame.
//
// The placement math is ComputePosition (position.go) — the same engine the floating
// layer uses. The TSX had its own fourth copy of it, which clamped on BOTH axes and so
// could push the card on top of the very thing it was pointing at; this one shifts on
// the cross axis only and flips on the main one.
func (t *Tutorial) updatePosition() {
	if !t.popoverRef.Mounted() {
		return
	}
	card := wasmruntime.Measure(t.popoverRef)
	if card.Empty() {
		return // not laid out yet; stay hidden rather than paint at 0,0
	}
	vp := wasmruntime.Viewport()

	step := t.step(t.index.Get())
	placement := PlacementBottom
	var top, left float64

	if step != nil && step.Target != "" && !t.rect.Empty() {
		offset := step.Offset
		if offset == 0 {
			offset = tutorialDefaultOff
		}
		pos := ComputePosition(t.rect, card, vp, PositionOptions{
			Placement: pick(step.Placement, PlacementBottom),
			Offset:    offset,
			Flip:      true,
			Shift:     true,
			Padding:   tutorialViewportPad,
		})
		top, left, placement = pos.Top, pos.Left, pos.Placement
	} else {
		// No target (or nothing measurable): centre the card in the viewport.
		top = (vp.Height - card.Height) / 2
		left = (vp.Width - card.Width) / 2
	}

	wasmruntime.SetStyle(t.popoverRef, "top", px(top))
	wasmruntime.SetStyle(t.popoverRef, "left", px(left))
	wasmruntime.SetStyle(t.popoverRef, "visibility", "visible")

	// The arrow's side is a rendered decision, so a flip has to go through a render.
	// Guarded on change, so this converges after one extra render instead of looping.
	if t.placement.Get() != placement {
		t.placement.Set(placement)
	}
}

// ---- rendering ----

// Render draws the tour — the overlay and the card — portaled to document.body, so no
// ancestor's `overflow: hidden` can clip the spotlight and no ancestor `transform` can
// re-root its `position: fixed`. When the tour is not running it renders an EMPTY
// portal rather than nothing, so its slot in the parent's child list (which the
// reconciler diffs by index) never disappears.
func (t *Tutorial) Render() *vdom.VNode {
	if !t.active.Get() || len(t.opts.Steps) == 0 {
		return vdom.Portal()
	}
	return vdom.Portal(t.overlay(), t.card())
}

// overlay is either the spotlight cutout (a hole in a 9999px shadow) or, with no target
// to cut around, the flat dim. Both start transparent and are faded in imperatively.
func (t *Tutorial) overlay() *vdom.VNode {
	if !t.spotlight.Get() {
		return vdom.Div(vdom.WithRef(t.overlayRef),
			vdom.Attr("class", tutorialBackdropClass),
			vdom.Attr("style", tutorialBackdropStyle),
			vdom.On(vdom.EVENT_CLICK, t.End),
		)
	}
	return vdom.Div(vdom.WithRef(t.overlayRef),
		vdom.Attr("class", tutorialSpotlightClass),
		vdom.Attr("style", tutorialSpotlightStyle),
		// The hole itself is pointer-events:none (you can still use what it spotlights);
		// this sits behind the whole page and ends the tour when the dim is clicked.
		vdom.Div(vdom.Attr("class", tutorialCatcherClass), vdom.On(vdom.EVENT_CLICK, t.End)),
	)
}

// card renders the popover: the two arrow triangles, then the content (header, body,
// controls, dots) in its own cross-faded wrapper.
//
// The arrows are always rendered, and merely hidden with display:none when they are not
// wanted. That is deliberate: the reconciler diffs children BY INDEX, so adding and
// removing them would shift the content div's index and force it to be rebuilt —
// discarding the imperative opacity mid-cross-fade.
func (t *Tutorial) card() *vdom.VNode {
	idx := clampIndex(t.displayed.Get(), len(t.opts.Steps))
	step := t.step(idx)
	total := len(t.opts.Steps)

	base, _ := splitPlacement(t.placement.Get())
	arrowVisible := t.showArrow.Get() && step != nil && step.Target != ""
	hidden := ""
	if !arrowVisible {
		hidden = "display:none;"
	}

	outer := vdom.Div(vdom.Attr("class", tutorialArrowClass),
		vdom.Attr("style", tutorialArrowOuter[base]+tutorialArrowTransition+hidden),
	)
	inner := vdom.Div(vdom.Attr("class", tutorialArrowClass),
		vdom.Attr("style", tutorialArrowInner[base]+tutorialArrowTransition+hidden),
	)

	return vdom.Div(vdom.WithRef(t.popoverRef),
		vdom.Attr("class", cx(tutorialPopoverClass, t.opts.Class)),
		vdom.Attr("style", tutorialPopoverStyle),
		outer,
		inner,
		t.content(step, idx, total),
	)
}

func (t *Tutorial) content(step *TutorialStep, idx, total int) *vdom.VNode {
	// Header: optional title + "X of N", and a close button.
	headerLeft := []vdom.Mod{vdom.Attr("class", "flex items-center gap-2")}
	if step != nil && step.Title != "" {
		headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "font-medium text-ink"),
			vdom.Text(step.Title)))
	}
	headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "text-ss text-ink-muted"),
		vdom.Text(strconv.Itoa(idx+1)+" of "+strconv.Itoa(total))))

	header := vdom.Div(vdom.Attr("class", "flex items-center justify-between p-4 pb-2"),
		vdom.Div(headerLeft...),
		vdom.Button(vdom.Attr("type", "button"),
			vdom.Attr("class", "cursor-pointer text-ink-faint bg-transparent border-0 p-0 leading-none transition-colors hover:text-ink-soft"),
			vdom.On(vdom.EVENT_CLICK, t.End),
			Icon("xmark", 18, ""),
		),
	)

	// Body.
	bodyMods := []vdom.Mod{vdom.Attr("class", "px-4 pb-4 text-sm text-ink-soft")}
	if step != nil && step.Content != nil {
		if c := step.Content(); c != nil {
			bodyMods = append(bodyMods, c)
		}
	}
	body := vdom.Div(bodyMods...)

	// Controls: Previous on the left (absent on the first step); Next/Finish on the right.
	leftMods := []vdom.Mod{}
	if idx > 0 {
		leftMods = append(leftMods, Button(ButtonProps{Color: ButtonWhite, Small: true, Text: "Previous", OnClick: t.Previous}))
	}
	advance := Button(ButtonProps{Color: ButtonBlue, Small: true, Text: "Next", OnClick: t.Next})
	if idx == total-1 {
		advance = Button(ButtonProps{Color: ButtonBlue, Small: true, Text: "Finish", OnClick: t.End})
	}
	controls := vdom.Div(vdom.Attr("class", "flex items-center justify-between px-4 pb-4 gap-2"),
		vdom.Div(leftMods...),
		vdom.Div(vdom.Attr("class", "flex gap-2"), advance),
	)

	contentMods := []vdom.Mod{
		vdom.WithRef(t.contentRef),
		vdom.Attr("style", tutorialContentStyle),
		header, body, controls,
	}

	// Step dots.
	if total > 1 {
		dotMods := []vdom.Mod{vdom.Attr("class", "flex justify-center gap-1.5 pb-3")}
		for i := 0; i < total; i++ {
			state := "bg-surface-strong"
			if i == idx {
				state = "bg-sky-600 scale-110"
			}
			dotMods = append(dotMods, vdom.Div(vdom.Attr("class", cx("w-2 h-2 rounded-full transition-all duration-300 ease-in-out", state))))
		}
		contentMods = append(contentMods, vdom.Div(dotMods...))
	}

	return vdom.Div(contentMods...)
}

// StartButton is the blue button that kicks the tour off at stepIndex. With no children
// it renders the default "Start Tutorial" label.
func (t *Tutorial) StartButton(stepIndex int, class string, children ...*vdom.VNode) *vdom.VNode {
	bp := ButtonProps{Color: ButtonBlue, Class: class, OnClick: func() { t.Start(stepIndex) }}
	if len(children) == 0 {
		bp.Text = "Start Tutorial"
	}
	return Button(bp, children...)
}
