// Port of jsruntime/uikit/Popovers.tsx, rebuilt on the Floating controller (floating.go).
//
// The first Go port dropped everything that made a popover a popover: measured
// placement (it used static absolute utilities off a `relative inline-block`
// wrapper), the portal, the single-open manager, outside-click / Escape dismissal,
// and — for HoverPopover — the hover bridge, which it faked with Tailwind's
// `group-hover`. That fake only worked because the panel was a DOM descendant of the
// hovered wrapper. The panel is now PORTALED to document.body, so it is not a
// descendant of anything the cursor is over and CSS :hover cannot reach it: the
// reveal MUST be the timer-based bridge in Floating (OpenOnHover + HoverDelay /
// HoverCloseDelay), which holds the panel open while the cursor crosses the gap and
// while it rests on the panel itself. That is what NewHoverPopover wires up.
//
// Shape of the port: the TSX's <Popover> element was only a carrier for a Solid
// context, which Go has no equivalent of, so it becomes a CONTROLLER instead — the
// state (open, refs, timers, resolved placement) has to outlive a render, so it
// cannot be created inside one. There is no wrapper element left: the trigger sits
// where you put it and the panel is portaled to the body, so nothing needs to
// contain them both.
//
//	pop := webui.NewPopover(webui.PopoverProps{Placement: webui.PlacementBottomEnd})
//	return func() *vdom.VNode {
//	    return vdom.Div(
//	        pop.Trigger(webui.PopoverTriggerProps{Class: "btn"}, vdom.Text("Filters")),
//	        pop.Content(webui.PopoverContentProps{Class: "p-3 w-64"}, body()...),
//	    )
//	}
package webui

import "kjol/wasmruntime/vdom"

const popoverCls = "bg-surface rounded-default shadow-lg border border-line"

// TSX defaults: offset ?? 8, hoverDelay ?? 0, hoverCloseDelay ?? 150.
const popoverOffset = 8

// PopoverProps configures NewPopover.
type PopoverProps struct {
	// Placement defaults to "bottom-start"; the resolved placement may differ after
	// a flip (read it with Placement()).
	Placement string

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

	// Standalone opts out of the single-open manager: this popover neither closes,
	// nor is closed by, other floatings. Set it for a popover nested inside another
	// floating (a popover opened from a menu item), which would otherwise close its
	// own parent the moment it opened.
	Standalone bool

	// OnOpenChange is called whenever the panel opens or closes — for callers that
	// mirror the state (the popover owns it either way; there is no `Open` input,
	// because a controller that both owns state and takes it is a race).
	OnOpenChange func(bool)
}

// HoverPopoverProps configures NewHoverPopover. It is PopoverProps plus the two
// hover timings (the TSX's HoverPopoverProps extends PopoverProps the same way).
type HoverPopoverProps struct {
	Placement    string
	Offset       float64
	Standalone   bool
	OnOpenChange func(bool)

	// HoverDelay is how long the cursor must rest on the trigger before the panel
	// opens (default 0 — the TSX default). HoverCloseDelay is the grace period after
	// the cursor leaves the trigger OR the panel: the bridge across the gap between
	// them (default 150ms).
	HoverDelay      int
	HoverCloseDelay int
}

// Popover is a live popover — the controller that NewPopover and NewHoverPopover
// both return. Build it once, alongside your signals, NOT inside a render function
// (a Floating rebuilt every frame would lose its refs, its timers and its open
// state, i.e. it would never open).
type Popover struct{ f *Floating }

// NewPopover creates a click-to-open popover.
func NewPopover(p PopoverProps) *Popover {
	return &Popover{f: NewFloating(FloatingOptions{
		Placement:    pick(p.Placement, PlacementBottomStart),
		Offset:       pickOffset(p.Offset, popoverOffset),
		Standalone:   p.Standalone,
		OnOpenChange: p.OnOpenChange,
	})}
}

// NewHoverPopover creates a hover-to-open popover.
//
// The hover bridge lives in Floating and is wired on BOTH the trigger and the panel
// (Trigger/Panel install it when OpenOnHover is set), so the panel survives the
// cursor's trip across the gap and stays up while the cursor is on it — the thing
// the old CSS group-hover version could not do once the panel was portaled out of
// the wrapper.
func NewHoverPopover(p HoverPopoverProps) *Popover {
	return &Popover{f: NewFloating(FloatingOptions{
		Placement:       pick(p.Placement, PlacementBottomStart),
		Offset:          pickOffset(p.Offset, popoverOffset),
		Standalone:      p.Standalone,
		OnOpenChange:    p.OnOpenChange,
		OpenOnHover:     true,
		HoverDelay:      p.HoverDelay,
		HoverCloseDelay: p.HoverCloseDelay, // 0 → NewFloating's 150ms
	})}
}

// Floating exposes the underlying controller (Reposition, Dispose, ...).
func (p *Popover) Floating() *Floating { return p.f }

// IsOpen reports the current state; Placement is the placement AFTER any flip.
func (p *Popover) IsOpen() bool      { return p.f.IsOpen() }
func (p *Popover) Placement() string { return p.f.Placement() }

// Show, Hide and Toggle drive the panel from outside (a keyboard shortcut, a
// "close" button inside the panel, a route change).
func (p *Popover) Show()   { p.f.Show() }
func (p *Popover) Hide()   { p.f.Hide() }
func (p *Popover) Toggle() { p.f.Toggle() }

// Dispose closes the panel and drops every listener and timer it owns.
func (p *Popover) Dispose() { p.f.Dispose() }

// PopoverTriggerProps configures Trigger. The Open/OnToggle fields the old port had
// are gone: the Popover owns its open state now, and the trigger it renders drives
// it (click for NewPopover, hover/focus for NewHoverPopover).
type PopoverTriggerProps struct {
	Class string
	Title string
	// OnClick runs in addition to the open/close (for a hover popover, whose trigger
	// is not a toggle, it is the only thing a click does).
	OnClick func()
}

// Trigger renders the <button> the panel is anchored to and measured against.
func (p *Popover) Trigger(t PopoverTriggerProps, children ...*vdom.VNode) *vdom.VNode {
	return p.f.Trigger(FloatingTriggerProps{
		Class:   t.Class,
		Title:   t.Title,
		OnClick: t.OnClick,
	}, children...)
}

// PopoverContentProps configures Content. The Open/Placement fields the old port had
// are gone: open state and placement belong to the Popover.
type PopoverContentProps struct {
	Class string
}

// Content renders the floating panel, portaled to document.body and positioned by
// measurement once it is in the DOM.
//
// Call it on EVERY render, in the same slot. It no longer returns nil when closed —
// Floating.Panel renders an empty portal instead, so the panel's slot in the parent's
// child list never disappears (the reconciler diffs children by index; a child that
// vanishes shifts every sibling after it).
func (p *Popover) Content(c PopoverContentProps, children ...*vdom.VNode) *vdom.VNode {
	return p.f.Panel(FloatingPanelProps{Class: cx(popoverCls, c.Class)}, children...)
}

// pickOffset is pick for a float64 offset: 0 means "unset, use the default".
func pickOffset(v, def float64) float64 {
	if v == 0 {
		return def
	}
	return v
}
