import * as accordionMachine from "@zag-js/accordion";
import type { MachineInput } from "marko-zag";
import Icon from "../icon/icon.marko";
import { cn } from "#lib/utils.ts";
import { stripOwnProps } from "#lib/native-attrs.ts";
import { accordion as styles } from "./classes.ts";

export interface AccordionItem {
  value: string;
  title: string;
  content?: string;
}

export interface AccordionItemAttrs {
  value: string;
  title: string;
  content?: Marko.Body;
}

// Dual API: compound attr tags (`<@item>`) are the primary, Marko-idiomatic
// form; `items=` remains as sugar for the data-driven case. Both sources are
// merged into one normalized array below.
export type Input = MachineInput<"div", accordionMachine.Props> & {
  items?: AccordionItem[];
  item?: Marko.AttrTag<AccordionItemAttrs>;
  content?: Marko.Body<[AccordionItem]>;
  valueChange?: (value: string[]) => void;
};

// Normalize BOTH sources into ONE explicit common shape (mirrors
// radio-group.marko) so the render path below never branches on the source
// and needs no casts: `body` carries the attr-tag markup body, `text` the
// items= string content, and `source` the plain-data item handed to the
// `content` render prop. Attr tags win if both are present.
<const/itemTags=[...(input.item ?? [])].map((item) => ({
  value: item.value,
  title: item.title,
  body: item.content,
  text: undefined as string | undefined,
  source: {
    value: item.value,
    title: item.title
  }
}))/>
<const/normalizedItems=(itemTags.length > 0 ? itemTags : (input.items ?? []).map((item) => ({
  value: item.value,
  title: item.title,
  body: undefined as Marko.Body | undefined,
  text: item.content,
  source: item
})))/>

<zag/api=() => accordionMachine from=input/>

<const/nativeAttrs=() =>
  stripOwnProps(
    accordionMachine.splitProps(input)[1],
    "class",
    "valueChange",
    "items",
    "item",
    "content",
  )
>

// Zag's `getItemContentProps().hidden` flips to `true` in the SAME render
// that flips `data-state` to "closed" — there is no equivalent to Radix's
// Presence/animationend-gated unmount. `hidden` forces `display:none`, which
// yanks the panel out of layout before the browser paints a single frame of
// the `accordion-up` closing keyframe, so the close animation never plays
// (verified live: a MutationObserver on the content element shows
// `hidden`/`data-state=closed`/height=0 landing in one batch).
//
// Fix: track item values that are mid-close here at the root (keyed by
// value) and keep those panels unhidden/playing `data-state=closed` for one
// more animation cycle, cleared via an event-delegated `animationend`
// listener on the root element (mirrors zag's own `@zag-js/collapsible`
// `trackExitAnimation`/`animation.end`, which Radix's Accordion reuses
// internally — accordion's zag machine has no such tracking, so it's
// reproduced here at the component level).
<let/closingValues=[] as string[]>
// Tracks each item's expanded state as of the last render so the `<script>`
// below can detect an actual open->closed TRANSITION rather than firing for
// every item that simply starts out closed (which every non-default-open
// item does on first render — without this, `closingValues` would seed
// itself with the whole item list on mount).
<let/previouslyExpanded={} as Record<string, boolean>>
// Holds the fallback timer handle for whichever item is currently mid-close
// (keyed by value), so a SECOND close of the SAME item within the 1s
// fallback window can cancel the FIRST close's stale timer before
// scheduling its own. Without this, re-opening and re-closing an item
// inside that window leaves the old timer dangling; it fires on the
// original schedule and can clear `closingValues` for the item's CURRENT
// close cycle before that cycle's own `accordion-up` animation has actually
// finished playing, snapping the panel to `hidden` mid-animation (verified
// by code trace — the animation itself is normally <=300ms, well inside the
// 1s window, so consecutive closes roughly 800ms-1200ms apart hit this).
// Declared with `<let>` (Marko requires top-level bindings to go through a
// tag — a bare top-level `const` isn't valid template syntax), but the
// object itself is NEVER reassigned, only mutated in place (`fallbackTimers[
// value] = ...`, `delete fallbackTimers[value]`); Marko's reactivity is
// keyed off `<let>` REASSIGNMENT, not property mutation, so this stays a
// plain, non-reactive, per-instance mutable map — no re-render is triggered
// by touching its keys, and (being a `<let>`, not `<const>`) its identity
// persists across re-renders instead of resetting to `{}` every time.
<let/fallbackTimers={} as Record<string, ReturnType<typeof setTimeout>>>

// Client-only: measure each open panel's natural height and publish it as
// `--marko-accordion-content-height` (our own namespace — mirrors Radix's
// `--radix-accordion-content-height`, which is itself just a re-export of
// `--radix-collapsible-content-height` measured via ResizeObserver in
// `@radix-ui/react-collapsible`). globals.css's `marko-accordion-down`/
// `marko-accordion-up` keyframes (wired in via a repointed `--animate-
// accordion-down`/`-up` theme var — see globals.css) consume this var ahead of tw-animate-css's
// upstream fallback chain, so `height: 0 -> auto` becomes
// `height: 0 -> <measured px>` in every browser, not just ones with
// `calc-size()` support.
//
// Both the height publishing and the close-animation tracking are wired
// through a SINGLE root-level lifecycle using event delegation (a
// ResizeObserver per panel + a root `animationend` listener), rather than
// per-item refs/lifecycles inside the `<for>`. The root element is found by
// id (same technique select.marko uses for its focus-restoration effect)
// rather than a tag-variable ref, since Zag's own `id` is already the
// stable identity for this element. The height is written via
// `el.style.setProperty`, an imperative DOM write, never through a reactive
// Marko `style=` attribute, which would fight this effect on every
// re-render (same rule `positionerStyle` documents for popper positioners).
//
// The FIRST height publish is deferred with `setTimeout(0)` (double-deferred
// via microtask + macrotask), mirroring select.marko's documented rAF-timing
// note but using a macrotask instead of `requestAnimationFrame`: Marko
// batches its own DOM commit onto a microtask/next-frame boundary, so a
// synchronous write inside `onMount` lands before Marko's hydration walk has
// actually finished settling this subtree — verified live, the write
// succeeds and reads back correctly in the SAME task, then silently reverts
// once hydration catches up. `ResizeObserver`/`animationend` callbacks fire
// later and don't need the same deferral (they're already scheduled well
// after hydration).
<lifecycle
  onMount() {
    // `getRootProps().id` is typed `AttrString` (`string | undefined`) because
    // every Marko attribute is optional; Zag always fills it from the machine
    // scope. Narrow through the id so the lookup is a real string.
    const rootId = api().getRootProps().id;
    const root = rootId ? document.getElementById(rootId) : null;
    // Always return the same shape: an `onMount` that can return undefined
    // types Marko's merge onto `this` as `... | undefined`, and `this.cleanup`
    // stops existing. The no-root path returns a no-op cleanup instead.
    if (!root) return { cleanup: () => {} };

    const publishHeight = (el: HTMLElement) => {
      if (el.hidden) return;
      el.style.setProperty("--marko-accordion-content-height", `${el.scrollHeight}px`);
    };

    const panels = () => [...root.querySelectorAll<HTMLElement>('[data-slot="accordion-content"]')];

    const initialTimer = setTimeout(() => {
      for (const panel of panels()) publishHeight(panel);
    }, 0);

    const resizeObserver = new ResizeObserver((entries) => {
      for (const entry of entries) publishHeight(entry.target as HTMLElement);
    });
    for (const panel of panels()) resizeObserver.observe(panel);

    const onAnimationEnd = (event: AnimationEvent) => {
      const target = event.target as HTMLElement;
      if (target.dataset.slot !== "accordion-content") return;
      // Matches globals.css's renamed keyframes (`marko-accordion-down`/
      // `marko-accordion-up`, NOT the bare `accordion-down`/`accordion-up`
      // names) — see the comment above that @theme inline block for why the
      // rename was necessary (Tailwind's build silently drops same-named
      // top-level @keyframes overrides).
      if (event.animationName === "marko-accordion-down") {
        publishHeight(target);
      } else if (event.animationName === "marko-accordion-up") {
        const value = target.dataset.value;
        if (value) {
          // Cancel this item's fallback timer now that the real
          // animationend fired — otherwise it stays armed for up to 1s and
          // can misfire during a LATER close cycle of the same item (see
          // the note above `fallbackTimers`'s declaration).
          clearTimeout(fallbackTimers[value]);
          delete fallbackTimers[value];
          closingValues = closingValues.filter((v) => v !== value);
        }
      }
    };
    root.addEventListener("animationend", onAnimationEnd);

    return {
      cleanup: () => {
        clearTimeout(initialTimer);
        resizeObserver.disconnect();
        root.removeEventListener("animationend", onAnimationEnd);
        for (const value in fallbackTimers) clearTimeout(fallbackTimers[value]);
      },
    };
  }
  onDestroy() {
    this.cleanup?.();
  }
/>

<div ...nativeAttrs() ...api().getRootProps() data-slot="accordion" class=cn(styles.root, input.class)>
  <for|item| of=normalizedItems>
    <const/expanded=api().getItemState({ value: item.value }).expanded/>
    <const/itemContentProps=api().getItemContentProps({ value: item.value })/>
    <const/isClosing=closingValues.includes(item.value)/>
    <div
      ...api().getItemProps({ value: item.value })
      data-slot="accordion-item"
      class=styles.item
    >
      <h3 class=styles.header>
        <button type="button"
          ...api().getItemTriggerProps({ value: item.value })
          data-slot="accordion-trigger"
          class=styles.trigger
        >
          ${item.title}
          <Icon
            name="ChevronDown"
            data-slot="accordion-trigger-icon"
            class=styles.triggerIcon
          />
          <Icon
            name="ChevronUp"
            data-slot="accordion-trigger-icon"
            class=styles.triggerIconActive
          />
        </button>
      </h3>
      <div
        ...itemContentProps
        hidden=(itemContentProps.hidden && !isClosing)
        data-slot="accordion-content"
        data-value=item.value
        data-closing=(isClosing || undefined)
        class=styles.content
      >
        <div class=styles.contentInner>
          <if=item.body>
            <${item.body}/>
          </if>
          <else if=input.content>
            <${input.content}(item.source)/>
          </else>
          <else>${item.text}</else>
        </div>
      </div>
    </div>

    <script>
      // Re-runs per item whenever ITS expanded state changes. Only a real
      // open->closed TRANSITION (guarded by `previouslyExpanded`, not just
      // "currently closed" — every non-default-open item is closed on the
      // very first render) holds the panel visible for one more animation
      // cycle instead of letting `hidden` apply in the same render — see the
      // note above `closingValues` for why. The effect below's delegated
      // `animationend` listener removes the value from `closingValues` once
      // the `accordion-up` keyframe actually finishes playing.
      //
      // Safety net: if `animationend` never fires (animations disabled,
      // `prefers-reduced-motion`, a theme override with `animation: none`,
      // or a throttled/backgrounded tab that stalls rAF/animation delivery —
      // observed in this exact form during manual testing), a value stuck in
      // `closingValues` would keep its panel visible forever instead of
      // eventually settling to `hidden`. Clear it on a fixed timer as a
      // fallback; the real `animationend` path still wins when it fires
      // first, this is a backstop.
      //
      // The stale timer from a PRIOR close of this same item is explicitly
      // cancelled first — see the note above `fallbackTimers`'s declaration.
      // Without this, reopening and reclosing the item inside the 1s window
      // leaves that old timer armed; it fires on its original schedule and
      // can clear `closingValues` for THIS close cycle before this cycle's
      // own `accordion-up` animation has finished, snapping the panel to
      // `hidden` mid-animation.
      const wasExpanded = previouslyExpanded[item.value];
      if (wasExpanded && !expanded && !isClosing) {
        closingValues = [...closingValues, item.value];
        clearTimeout(fallbackTimers[item.value]);
        fallbackTimers[item.value] = setTimeout(() => {
          delete fallbackTimers[item.value];
          closingValues = closingValues.filter((v) => v !== item.value);
        }, 1000);
      }
      if (wasExpanded !== expanded) {
        previouslyExpanded = { ...previouslyExpanded, [item.value]: expanded };
      }
    </script>
  </for>
</div>

<return=api>
