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

export interface ListboxItem {
  value: string;
  label: string;
  disabled?: boolean;
}

export interface ListboxOptionAttrs {
  value: string;
  label: string;
  disabled?: boolean;
}

// Module-scope cache for built collections, keyed by the `items` array
// reference. `serviceProps` (below) is a closure re-invoked on every
// reactive recompute; rebuilding the Zag collection fresh each time hands
// <zag> a new collection identity on every read, which thrashes Zag's
// props diffing and made selection updates lag or silently fail to land
// (verified 2026-08-11). A WeakMap keyed by the `items` reference is safe to
// share at module scope: distinct <Listbox> instances always pass distinct
// `items` arrays, so cache entries never collide across instances, and
// entries are garbage-collected once an instance's `items` array is no
// longer referenced. Not stored in a <let>/<const> tag-variable — a Zag
// collection carries functions and isn't SSR-serializable.
static const collectionCache = new WeakMap<ListboxItem[], ReturnType<typeof listboxMachine.collection<ListboxItem>>>();

// `items` drives the generated `collection` (machine-owned, derived, so it
// is omitted from the public Input and rebuilt below); `value` keeps the
// plain string[] shape the machine already uses.
//
// Dual API: the compound attr tag (`<@option>`) is the primary,
// Marko-idiomatic form; `items=` remains as sugar for the data-driven case.
// If both are supplied, attr tags win.
export type Input = Omit<MachineInput<"div", listboxMachine.Props<ListboxItem>>, "collection"> & {
  items?: ListboxItem[];
  option?: Marko.AttrTag<ListboxOptionAttrs>;
  valueChange?: (value: string[]) => void;
  highlightedValueChange?: (highlightedValue: string | null) => void;
  label?: string;
};

<const/{ label, class: className }=input/>

// Normalize whichever source was supplied into ONE array reference. What the
// WeakMap cache below actually needs is stability ACROSS serviceProps()
// re-reads within a computation: both paths get that, because `items` is a
// <const> whose reference only changes when its inputs change. The two paths
// differ across input changes: `items=` callers who pass the same array
// reference keep the cached collection forever, while the attr-tag path
// spreads a FRESH optionTags array whenever `input.option` changes identity
// (any parent re-render), rebuilding the collection once per input change.
// That rebuild is intentional and cheap at attr-tag list sizes — the cache's
// hard guarantee only holds for `items=` callers.
<const/optionTags=[...(input.option ?? [])]/>
<const/items=(optionTags.length > 0 ? optionTags : (input.items ?? []))/>

<const/buildCollection=(list: ListboxItem[]) => {
  const cached = collectionCache.get(list);
  if (cached) return cached;
  const built = listboxMachine.collection({
    items: list,
    itemToValue: (item: ListboxItem) => item.value,
    itemToString: (item: ListboxItem) => item.label,
    isItemDisabled: (item: ListboxItem) => !!item.disabled,
  });
  collectionCache.set(list, built);
  return built;
}/>

// `collection` is derived state (rebuilt whenever `items` changes), not a
// plain input field — it holds methods and cannot cross the tag-input
// boundary. Layer it on in `props=`, which receives the picked props.
<zag/api=() => listboxMachine from=input props=(picked) => ({ ...picked, collection: buildCollection(items) })
  onHighlightChange(details: listboxMachine.HighlightChangeDetails<ListboxItem>) {
    input.onHighlightChange?.(details);
    input.highlightedValueChange?.(details.highlightedValue);
  }
/>

// `collection` is Omit-ted from this component's public Input (it is derived
// from `items`), but `splitProps` is constrained on the full `ListboxProps`,
// which requires it. Hand it the collection this component actually builds:
// `splitProps` only partitions keys by name, and `collection` is machine-owned
// so it is stripped from the leftover either way. `id` gets the same
// treatment: `MachineInput` makes it optional (<zag>'s `from=` generates one)
// while `ListboxProps` requires it, and splitProps never reads its value.
<const/nativeAttrs=() =>
  stripOwnProps(
    listboxMachine.splitProps({ ...input, id: input.id ?? "", collection: buildCollection(items) })[1],
    "class",
    "items",
    "option",
    "valueChange",
    "highlightedValueChange",
    "label",
  )
>

<div ...nativeAttrs() ...api().getRootProps() data-slot="listbox" class=cn(styles.root, className)>
  <if=label>
    <label ...api().getLabelProps() data-slot="listbox-label" class=styles.label>${label}</label>
  </if>
  <div
    ...api().getContentProps()
    data-slot="listbox-content"
    class=styles.content
  >
    <for|entry| of=items>
      <div
        ...api().getItemProps({ item: entry })
        data-slot="listbox-item"
        class=styles.item
      >
        <span data-slot="listbox-item-text">${entry.label}</span>
      </div>
    </for>
  </div>
</div>

<return=api>
