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

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

// Optional grouping — mirrors command.marko's group shape. When `groups` is
// given it drives rendering instead of the flat `items`/`option` lists; the
// generated `collection` is still built from the flattened item set (zag has
// no native group concept — see select.props.mjs — so grouping is display
// only, matching the shadcn bases-radix `SelectGroup`/`SelectLabel` slots).
export interface SelectGroup {
  label: string;
  items: SelectItem[];
}

// `items`/`value` are narrowed vs. the raw machine Props: `items` drives the
// generated `collection`, and `value` is typed as the plain string[] the
// caller works with (the machine's own `value` type matches already).
//
// `option` is the attr-tag compound API (flat: no per-item render-prop needed
// since select items are plain strings today). When present it is normalized
// into the same SelectItem[] shape `items` already uses, and that normalized
// array feeds buildCollection/serviceProps and both <for> loops unchanged —
// `items=` keeps working as-is for the data-driven case.
export type Input = Omit<MachineInput<"div", select.Props<SelectItem>>, "collection"> & {
  items?: SelectItem[];
  groups?: SelectGroup[];
  option?: Marko.AttrTag<SelectItem>;
  valueChange?: (value: string[]) => void;
  placeholder?: string;
  /** Field label rendered above the trigger (NOT the shadcn listbox group
   * label — see `select-label` note on the group label below). */
  label?: string;
  size?: "default" | "sm";
};

<const/{ placeholder, label, size, class: className }=input/>
<const/optionTags=[...(input.option ?? [])]/>
<const/groups=input.groups/>
<const/items=(optionTags.length > 0 ? optionTags : groups ? groups.flatMap((g) => g.items) : (input.items ?? []))/>

<const/buildCollection=(list: SelectItem[]) =>
  select.collection({
    items: list,
    itemToValue: (item: SelectItem) => item.value,
    itemToString: (item: SelectItem) => item.label,
    isItemDisabled: (item: SelectItem) => !!item.disabled,
  })
/>

// `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=() => select from=input props=(picked) => ({ ...picked, collection: buildCollection(items) })/>

<const/scrollViewport=(e: Event, delta: number) => {
  const target = e.currentTarget as HTMLElement;
  const viewport = target.closest("[data-slot='select-content']")?.querySelector<HTMLElement>("[data-slot='select-viewport']");
  viewport?.scrollBy({ top: delta, behavior: "smooth" });
}>

<const/nativeAttrs=() =>
  stripOwnProps(
    select.splitProps(input)[1],
    "class",
    "valueChange",
    "items",
    "groups",
    "option",
    "placeholder",
    "label",
    "size",
  )
>

<div ...nativeAttrs() ...api().getRootProps() data-slot="select" class=cn(className)>
  // RULE 9 TRAP: upstream `select-label` is the LISTBOX GROUP LABEL rendered
  // inside the content (see `<div data-slot="select-label">` further below,
  // under each group). This element is OUR field label above the trigger —
  // same concept shadcn's `Field`/`Label` primitives cover elsewhere, not
  // the thing upstream's select-label hook targets. It intentionally
  // carries no anchor class (an unstyled `select-field-label` slot) so it
  // never collides with the group-label anchor reserved below.
  <if=label>
    <label ...api().getLabelProps() data-slot="select-field-label" class=styles.fieldLabel>${label}</label>
  </if>
  <else>
    <label ...api().getLabelProps() data-slot="select-field-label" class=styles.fieldLabelSrOnly>${placeholder || "Select an option"}</label>
  </else>
  <div ...api().getControlProps() data-slot="select-control">
    <button type="button"
      ...api().getTriggerProps()
      data-slot="select-trigger"
      data-size=(size ?? "default")
      class=styles.trigger
    >
      <span data-slot="select-value">${api().valueAsString || placeholder}</span>
      <Icon ...api().getIndicatorProps() name="ChevronDown" class=styles.triggerIcon/>
    </button>
  </div>
  <select ...api().getHiddenSelectProps() data-slot="select-hidden-select">
    <for|entry| of=items>
      <option value=entry.value disabled=entry.disabled>${entry.label}</option>
    </for>
  </select>
  <zag-portal>
    <if=api().open>
      <div ...api().getPositionerProps() data-slot="select-positioner" style=positionerStyle>
        <div
          ...api().getContentProps()
          data-slot="select-content"
          class=styles.content
        >
          <button type="button" aria-hidden="true" tabindex="-1" data-slot="select-scroll-up-button" class=styles.scrollUpButton on-click(e: Event) { scrollViewport(e, -40); }>
            <Icon name="ChevronUp" class=styles.scrollButtonIcon/>
          </button>
          <div data-slot="select-viewport" class=styles.viewport>
            <if=groups>
              <for|group, groupIndex| of=groups>
                <div data-slot="select-group" class=styles.group>
                  <div ...api().getItemGroupLabelProps({ htmlFor: `group-${groupIndex}` }) data-slot="select-label" class=styles.groupLabel>${group.label}</div>
                  <for|entry| of=group.items>
                    <div
                      ...api().getItemProps({ item: entry })
                      data-slot="select-item"
                      class=styles.item
                    >
                      <span ...api().getItemIndicatorProps({ item: entry }) data-slot="select-item-indicator" class=styles.itemIndicator>
                        <Icon name="Check" class=styles.itemIndicatorIcon/>
                      </span>
                      <span ...api().getItemTextProps({ item: entry }) data-slot="select-item-text">${entry.label}</span>
                    </div>
                  </for>
                </div>
                <if=(groupIndex < groups.length - 1)>
                  <div data-slot="select-separator" class=styles.separator></div>
                </if>
              </for>
            </if>
            <else>
              <for|entry| of=items>
                <div
                  ...api().getItemProps({ item: entry })
                  data-slot="select-item"
                  class=styles.item
                >
                  <span ...api().getItemIndicatorProps({ item: entry }) data-slot="select-item-indicator" class=styles.itemIndicator>
                    <Icon name="Check" class=styles.itemIndicatorIcon/>
                  </span>
                  <span ...api().getItemTextProps({ item: entry }) data-slot="select-item-text">${entry.label}</span>
                </div>
              </for>
            </else>
          </div>
          <button type="button" aria-hidden="true" tabindex="-1" data-slot="select-scroll-down-button" class=styles.scrollDownButton on-click(e: Event) { scrollViewport(e, 40); }>
            <Icon name="ChevronDown" class=styles.scrollButtonIcon/>
          </button>
        </div>
      </div>
    </if>
  </zag-portal>
</div>

<script>
  // Restore keyboard control when the listbox is opened BY MOUSE.
  //
  // Zag's `setInitialFocus` is an ACTION that schedules its DOM read with a
  // single `raf()`. Every framework adapter Zag ships renders synchronously on
  // a state write, so the content element exists by that frame. Marko batches:
  // the machine's `notify()` bumps its rev on a microtask and the render lands
  // a frame later, so on the mouse path Zag's raf fires while the content is
  // still absent (verified live: Zag's raf at +350ms with content=null, content
  // mounts at +399ms). `element?.focus()` then no-ops, focus stays on the
  // trigger, and arrow keys do nothing. The keyboard path happens to win the
  // race because its keydown route reaches the machine a tick later.
  //
  // Reading `api().open` makes this block re-run on every open/close. The
  // double rAF matches the deferral marko-ui already applies to machine
  // effects, which is the point at which the portalled content is committed.
  const isOpen = api().open;
  if (isOpen) {
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        // `id` is typed AttrString (may be undefined). Guard rather than
        // pass it through: getElementById(undefined) stringifies to
        // "undefined" and silently matches nothing.
        const contentId = api().getContentProps().id;
        const content = contentId ? document.getElementById(contentId) : null;
        // Only intervene if Zag's own attempt lost the race; never steal focus
        // from an element inside the content the user has already reached.
        if (!content || content.contains(document.activeElement)) return;
        // Mirrors @zag-js/dom-query getInitialFocus(): an explicit autofocus
        // target wins, otherwise the content itself (it carries tabindex=0 and
        // select items are intentionally not tabbable).
        const target = content.querySelector<HTMLElement>("[data-autofocus],[autofocus]") ?? content;
        target.focus({ preventScroll: true });
      });
    });
  }
</script>

<return=api>
