<!--
    Component: Popover
    Anchored popover on the native Popover API (top-layer, light-dismiss, ESC,
    focus return handled by the browser). Placement is computed by the pure
    `popover-position` module and applied as fixed coordinates; it repositions
    on scroll/resize/content-resize. `open` is two-way bindable. The trigger is
    consumer-rendered via the `trigger` snippet, which spreads a props object
    (an attachment captures the trigger element; `popovertarget` wires the
    native invoker). `children` receives `close()` to dismiss programmatically.
-->
<script lang="ts">
import { cn } from '$shared/lib';
import type { Snippet } from 'svelte';
import { createAttachmentKey } from 'svelte/attachments';
import {
    type Align,
    type Side,
    computePosition,
} from './popover-position';

interface Props {
    /**
     * Open state (two-way bindable)
     * @default false
     */
    open?: boolean;
    /**
     * Preferred side
     * @default 'bottom'
     */
    side?: Side;
    /**
     * Cross-axis alignment
     * @default 'center'
     */
    align?: Align;
    /**
     * Gap between trigger and content (px)
     * @default 0
     */
    sideOffset?: number;
    /**
     * CSS classes applied to the content element
     */
    class?: string;
    /**
     * ARIA role for the content
     * @default 'dialog'
     */
    role?: 'dialog' | 'menu' | 'listbox';
    /**
     * Trigger snippet — spread the provided props onto your trigger element
     */
    trigger: Snippet<[Record<string, unknown>]>;
    /**
     * Content snippet — receives `close()` for programmatic dismissal
     */
    children: Snippet<[{ close: () => void }]>;
}

let {
    open = $bindable(false),
    side = 'bottom',
    align = 'center',
    sideOffset = 0,
    class: className,
    role = 'dialog',
    trigger,
    children,
}: Props = $props();

const uid = $props.id();
const contentId = `popover-${uid}`;

let triggerEl: HTMLElement | undefined = $state();
let contentEl: HTMLElement | undefined = $state();
/**
 * Side actually used after flip. Seeded from the `side` prop; the authoritative
 * value is written by updatePosition() on every open, so the seed only matters
 * for the closed state (hence the intentional state_referenced_locally warning).
 */
let resolvedSide = $state(side);

/**
 * True once updatePosition has applied coordinates for the current open.
 * Gates visibility so the content never paints at its pre-positioned (0,0)
 * top-layer default before the first measurement.
 */
let positioned = $state(false);

/**
 * Actual DOM open state, driven by the `toggle` event. Source of truth for
 * whether the browser currently shows the popover; `open` is the public binding.
 */
let shown = $state(false);

/**
 * Stable attachment that captures the consumer's trigger element for measuring.
 * Created once so spreading reactive `triggerProps` doesn't re-run it.
 */
const attachKey = createAttachmentKey();
const attachTrigger = (node: HTMLElement) => {
    triggerEl = node;
    return () => {
        if (triggerEl === node) {
            triggerEl = undefined;
        }
    };
};

const triggerProps = $derived({
    popovertarget: contentId,
    'aria-haspopup': role,
    'aria-expanded': open,
    'aria-controls': contentId,
    [attachKey]: attachTrigger,
});

/**
 * Recompute and apply the fixed-position coordinates.
 */
function updatePosition(): void {
    if (!triggerEl || !contentEl) {
        return;
    }
    const result = computePosition({
        triggerRect: triggerEl.getBoundingClientRect(),
        contentRect: { width: contentEl.offsetWidth, height: contentEl.offsetHeight },
        viewport: { width: window.innerWidth, height: window.innerHeight },
        side,
        align,
        sideOffset,
    });
    resolvedSide = result.side;
    contentEl.style.left = `${result.x}px`;
    contentEl.style.top = `${result.y}px`;
    positioned = true;
}

/**
 * Mirror the `toggle` event into our state.
 */
function onToggle(event: ToggleEvent): void {
    shown = event.newState === 'open';
    open = shown;
    if (!shown) {
        positioned = false;
    }
}

/**
 * Programmatic dismiss for the content snippet.
 */
function close(): void {
    open = false;
}

// state -> browser: open the popover when `open` flips true and it isn't shown,
// and close it when `open` flips false while shown. `shown` (from toggle) breaks
// the loop so we never call show/hide redundantly.
$effect(() => {
    const el = contentEl;
    if (!el) {
        return;
    }
    if (open && !shown) {
        el.showPopover();
    } else if (!open && shown) {
        el.hidePopover();
    }
});

// Position while shown; reposition on scroll/resize/content-resize; auto-clean.
$effect(() => {
    if (!shown || !contentEl || !triggerEl) {
        return;
    }
    updatePosition();
    const observer = new ResizeObserver(() => updatePosition());
    observer.observe(contentEl);
    const onScroll = () => updatePosition();
    window.addEventListener('scroll', onScroll, true);
    window.addEventListener('resize', onScroll);
    return () => {
        observer.disconnect();
        window.removeEventListener('scroll', onScroll, true);
        window.removeEventListener('resize', onScroll);
    };
});
</script>

{@render trigger(triggerProps)}

<!--
    inset:auto + margin:0 neutralize the UA popover stylesheet (which sets
    inset:0; margin:auto to center it) so the JS-applied left/top win.
    visibility is hidden until updatePosition runs (see `positioned`).
-->
<div
    bind:this={contentEl}
    id={contentId}
    popover="auto"
    {role}
    data-side={resolvedSide}
    data-state={shown ? 'open' : 'closed'}
    ontoggle={onToggle}
    style={`position: fixed; inset: auto; margin: 0;${positioned ? '' : ' visibility: hidden;'}`}
    class={cn(
        'opacity-0 scale-95 transition-discrete transition-all duration-fast',
        'starting:opacity-0 starting:scale-95',
        '[&:popover-open]:opacity-100 [&:popover-open]:scale-100',
        'data-[side=top]:origin-bottom data-[side=bottom]:origin-top',
        className,
    )}
>
    {@render children({ close })}
</div>
