// Port of jsruntime/uikit/Tooltips.tsx, rebuilt on the Floating controller (floating.go).
//
// The first Go port degraded the tooltip into a pure-CSS `group-hover` reveal:
// static placement classes instead of measurement, no flip/shift, no portal, no
// open delay, no close grace period, an arrow drawn from the REQUESTED placement
// (so it pointed the wrong way after a flip — except it could never flip), and a
// `tabindex="0"` on every wrapper, which put every tooltip in the tab order. All of
// that is restored/fixed here on top of Floating: the bubble is portaled to
// document.body, measured, flipped and shifted, opens after 200ms of hover, and
// stays up for a grace period so the cursor can cross the gap and select text in it.
//
// Deliberate deviations from the TSX, and why:
//
//   - The arrow is Floating.Arrow (a rotated square pinned to the panel's edge whose
//     offset along that edge is written imperatively) rather than the TSX's
//     border-triangle utility classes. Those classes pin the arrow to a fixed
//     fraction of the PANEL (`left-1/2`), so as soon as shift slides the panel away
//     from its trigger the arrow visibly detaches from it. Floating.Arrow tracks the
//     trigger, and takes its SIDE from the RESOLVED placement, so it still points at
//     the trigger after a flip.
//   - One close delay (100ms — the TSX's trigger-leave value) covers both
//     trigger-leave and panel-leave; the TSX used 50ms for panel-leave. Floating owns
//     the hover bridge and has a single HoverCloseDelay, and 100ms is the more
//     forgiving of the two, which is the whole point of the grace period.
//   - The trigger element is built here instead of with Floating.Trigger. A tooltip
//     wraps arbitrary content — usually a <button> — so the trigger can be neither a
//     <button> itself (nested buttons are invalid) nor carry Floating.Trigger's
//     Enter/Space keydown toggle: that handler sits on the wrapper, DOM keydown
//     bubbles up from the wrapped button, and its preventDefault would swallow the
//     button's own Enter activation. The hover/close TIMERS still come from Floating
//     (hoverEnter / hoverLeave), and the panel keeps Floating's half of the bridge —
//     nothing about the bridge is reimplemented here.
//   - Tooltips are Standalone: they never join the single-open manager. In the TSX a
//     tooltip evicted whatever popover or menu was open — including the one its own
//     trigger lived inside, which yanked the trigger out from under the cursor.
package webui

import "kjol/vdom"

const tooltipCls = "bg-neutral-800 text-white text-sm px-2.5 py-1.5 rounded-default shadow-lg max-w-80 relative"

// Defaults from the TSX: TOOLTIP_DEFAULT_OFFSET = 8, delay ?? 200, close after 100ms.
const (
	tooltipOffset     = 8
	tooltipOpenDelay  = 200
	tooltipCloseDelay = 100
	// The rotated square is ~11px across the diagonal and pokes ~6px out of the
	// panel — the same silhouette as the TSX's 6px border triangle.
	tooltipArrowSize = 8
)

// Tooltip trigger modes (TooltipProps.Trigger).
const (
	TooltipTriggerHover = "hover"
	TooltipTriggerFocus = "focus"
)

// TooltipProps configures NewTooltip.
type TooltipProps struct {
	// Trigger is TooltipTriggerHover (default) or TooltipTriggerFocus. A focus
	// tooltip does not open on hover; it opens when anything inside it takes focus.
	// Both are keyboard-reachable — focusin/focusout bubble out of the wrapped
	// element, so no tabindex is needed on the wrapper.
	Trigger string

	// Placement defaults to "top". Any Placement* constant works; the resolved
	// placement may differ after a flip (the arrow follows it).
	Placement string

	// Offset is the gap between trigger and bubble in px; 0 means the TSX default, 8.
	Offset float64

	// Delay is the hover open delay in ms; 0 means the TSX default, 200. Pass a
	// negative value for no delay (Go cannot tell an unset 0 from a deliberate one).
	Delay int

	// Class goes on the trigger wrapper, not the bubble.
	Class string
}

// Tooltip is a live tooltip: a Floating plus the trigger wrapper around it.
//
// It is a CONTROLLER, not a render function, because a Floating holds refs, timers
// and open state that must survive across renders. Build it once, next to your
// signals, and call Render inside the render closure:
//
//	tip := webui.NewHoverTooltip(webui.PlacementTop, "")
//	return func() *vdom.VNode {
//	    return tip.Render(vdom.Text("Deletes the row"), webui.Button(...))
//	}
//
// (This is the API change from the old free function `Tooltip(props, children...)`,
// which could not hold state and so could only ever be CSS.)
type Tooltip struct {
	f       *Floating
	trigger string
	class   string
}

// NewTooltip creates a tooltip controller. Call it once, OUTSIDE the render
// function.
func NewTooltip(p TooltipProps) *Tooltip {
	delay := p.Delay
	switch {
	case delay == 0:
		delay = tooltipOpenDelay
	case delay < 0:
		delay = 0
	}
	offset := p.Offset
	if offset == 0 {
		offset = tooltipOffset
	}

	trigger := pick(p.Trigger, TooltipTriggerHover)

	return &Tooltip{
		trigger: trigger,
		class:   p.Class,
		f: NewFloating(FloatingOptions{
			Placement:    pick(p.Placement, PlacementTop),
			Offset:       offset,
			ArrowSize:    tooltipArrowSize,
			ArrowPadding: 6,
			// OpenOnHover is set for BOTH modes: it is what gives the PANEL its half
			// of the hover bridge (enter cancels the pending close, leave reschedules
			// it), which is what lets the cursor land on the bubble and select text in
			// it. The trigger's half is wired in Render — a focus tooltip wires only
			// focusin/focusout there, so it still does not open on hover.
			OpenOnHover:     true,
			HoverDelay:      delay,
			HoverCloseDelay: tooltipCloseDelay,
			// A tooltip is not a dialog: it must not evict the menu or popover it is
			// hovered inside (which would take its own trigger with it).
			Standalone: true,
		}),
	}
}

// NewHoverTooltip is NewTooltip in hover mode (the TSX's default variant).
// Placement defaults to "top"; class goes on the trigger wrapper.
func NewHoverTooltip(placement, class string) *Tooltip {
	return NewTooltip(TooltipProps{Trigger: TooltipTriggerHover, Placement: placement, Class: class})
}

// NewFocusTooltip is NewTooltip in focus mode: it opens when the wrapped content
// takes focus, not on hover.
func NewFocusTooltip(placement, class string) *Tooltip {
	return NewTooltip(TooltipProps{Trigger: TooltipTriggerFocus, Placement: placement, Class: class})
}

// Floating exposes the underlying controller (placement, open state, Dispose...).
func (t *Tooltip) Floating() *Floating { return t.f }

// IsOpen reports whether the bubble is showing.
func (t *Tooltip) IsOpen() bool { return t.f.IsOpen() }

// Hide closes the bubble now — e.g. when the thing it describes is removed.
func (t *Tooltip) Hide() { t.f.Hide() }

// Dispose closes the bubble and drops every listener and timer it owns.
func (t *Tooltip) Dispose() { t.f.Dispose() }

// Render draws the trigger wrapper around children, plus the (portaled) bubble
// holding content.
//
// content is passed per render rather than stored on the props so it can depend on
// signals — and so the same *VNode is never handed to the reconciler twice.
//
// Call this on EVERY render, in the same slot: when the tooltip is closed the panel
// is an empty portal, not a missing child, which is what keeps the sibling indexes
// the reconciler diffs by stable.
func (t *Tooltip) Render(content *vdom.VNode, children ...*vdom.VNode) *vdom.VNode {
	// The TSX wraps a hover trigger in `<span style="display:inline-block">` and a
	// focus trigger in a plain `<div>`. Same here — the wrapper is what gets measured,
	// so it has to shrink-wrap the trigger rather than stretch across the line box.
	tag, class := "span", cx("inline-block", t.class)
	if t.trigger == TooltipTriggerFocus {
		tag, class = "div", t.class
	}

	mods := []vdom.Mod{
		vdom.WithRef(t.f.triggerRef),
		vdom.Attr("class", class),
	}
	if t.trigger == TooltipTriggerHover {
		// The controller's timers, not ours: hoverEnter opens after Delay, hoverLeave
		// schedules the close that the panel's own mouseenter can cancel.
		mods = append(mods,
			vdom.On(vdom.EVENT_MOUSEENTER, t.f.hoverEnter),
			vdom.On(vdom.EVENT_MOUSELEAVE, t.f.hoverLeave),
		)
	}
	// focusin/focusout BUBBLE (focus/blur do not), so focusing the wrapped button or
	// input opens the tooltip without the wrapper needing a tabindex of its own —
	// which is how the keyboard reaches it now that the old port's unconditional
	// tabindex="0" (and the tab-order pollution it caused) is gone.
	mods = append(mods,
		vdom.On(vdom.EVENT_FOCUSIN, t.f.Show),
		// Not Hide: leaving on the grace timer means tabbing away, or blurring to grab
		// the bubble with the mouse, does not snatch the text out from under you.
		vdom.On(vdom.EVENT_FOCUSOUT, t.f.hoverLeave),
	)

	mods = kids(mods, children)
	mods = append(mods, t.panel(content))
	return vdom.El(tag, mods...)
}

// panel is the bubble: role=tooltip, portaled, with the arrow last so it paints over
// the bubble's own background.
func (t *Tooltip) panel(content *vdom.VNode) *vdom.VNode {
	return t.f.Panel(
		FloatingPanelProps{Role: "tooltip", Class: tooltipCls},
		content,
		// The arrow's side comes from the RESOLVED placement (Floating.Arrow reads
		// it), so a tooltip that flipped from top to bottom points UP at its trigger
		// instead of down at nothing — the bug in the old tooltipArrowCls, which was
		// handed the requested placement.
		t.f.Arrow("bg-neutral-800"),
	)
}
