/**
 * SearchSelect — custom element wrapping the search-select widget.
 *
 * A search box paired with a dropdown of options. Multi-select renders chosen
 * items as removable pills (inline with the search box), each backed by a
 * hidden <input>. Single-select renders no pill: the committed label lives
 * inside the search box (which doubles as a combobox — focus clears it to
 * search, picking an option fills it), with a lone hidden <input> carrying the
 * value. Both keep hidden inputs so Django validation works.
 *
 * Filter mode (filter-mode="true", rendered by FilterSelect): value rows carry
 * +/− buttons that add include (✓) / exclude (✗) pills, plus pinned modifier
 * pseudo-options ((Any)/(None)) that are mutually exclusive with value pills.
 * Filter widgets have no hidden inputs; readSearchSelect serialises their state
 * into data-included / data-excluded / data-modifier for the filter bar.
 *
 * Dynamically-added rows and pills are cloned from hidden <template> elements
 * the server renders with the same Python components (Pill / SearchSelect /
 * FilterSelect). The JS only fills in the label slot ([data-search-select-label]),
 * value, and data-* attributes — so all markup and Tailwind class strings live
 * in one place (the Python components), never duplicated here.
 */

// The contract for the "search-select:change" CustomEvent this widget emits.
// Consumers (e.g. add_purchase.ts) import these types — never redefine them.
export interface SearchSelectOption {
  value: string;
  label: string;
  data: Record<string, string>;
}

export interface SearchSelectChangeDetail {
  name: string;
  values: string[];
  last: SearchSelectOption | null;
}

// The widget stashes per-instance state directly on its DOM elements.
interface SearchSelectContainer extends HTMLElement {
  _searchSelectLabel?: string;
  _searchSelectDirty?: boolean;
}

interface OptionRow extends HTMLElement {
  _searchSelectOption?: SearchSelectOption;
}

interface FilterPillEntry {
  id: string;
  label: string;
}

const DEBOUNCE_MS = 100;

// Must match Python common/components/filters.py:_PRESENCE_MODIFIERS.
// These modifiers are mutually exclusive with value pills — selecting
// one clears all value pills.  Non-presence modifiers (INCLUDES_ALL,
// INCLUDES_ONLY) coexist with value pills.
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];

const initWidget = (containerElement: Element) => {
  const container = containerElement as SearchSelectContainer;
  const search = container.querySelector<HTMLInputElement>("[data-search-select-search]");
  const options = container.querySelector<HTMLElement>("[data-search-select-options]");
  const pills = container.querySelector<HTMLElement>("[data-search-select-pills]");
  if (!search || !options || !pills) return;

  const name = container.getAttribute("name") ?? "";
  const searchUrl = container.getAttribute("search-url");
  const isFilter = container.getAttribute("filter-mode") === "true";
  const freeText = container.getAttribute("free-text") === "true";
  const multi = container.getAttribute("multi") === "true";
  const alwaysVisible = container.getAttribute("always-visible") === "true";
  const prefetch = parseInt(container.getAttribute("prefetch") ?? "", 10) || 0;
  const syncUrl = container.getAttribute("sync-url") === "true";

  const noResults = options.querySelector<HTMLElement>("[data-search-select-no-results]");
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
  let pendingRequest: AbortController | null = null; // in-flight, so newer queries win
  let hasPrefetched = false;

  const hasVisibleContent = () => {
    const optionRows = options.querySelectorAll<HTMLElement>("[data-search-select-option]");
    for (let i = 0; i < optionRows.length; i++) {
      if (optionRows[i].style.display !== "none") return true;
    }
    if (noResults && !noResults.classList.contains("hidden")) return true;
    if (options.querySelector("[data-search-select-modifier-option]")) return true;
    return false;
  };

  const showPanel = () => {
    if (alwaysVisible || hasVisibleContent()) {
      options.classList.remove("hidden");
    }
  };
  const hidePanel = () => {
    if (!alwaysVisible) options.classList.add("hidden");
  };

  const setNoResults = (visible: boolean) => {
    if (!noResults) return;
    noResults.classList.toggle("hidden", !visible);
    if (visible) showPanel();
  };

  // ── Highlight tracking (filter mode) ──
  let highlightedRow: HTMLElement | null = null;

  const highlightOption = (row: HTMLElement | null) => {
    clearHighlight();
    if (!row) return;
    row.setAttribute("data-search-select-highlighted", "");
    highlightedRow = row;
    row.scrollIntoView({ block: "nearest" });
  };

  const clearHighlight = () => {
    if (highlightedRow) {
      highlightedRow.removeAttribute("data-search-select-highlighted");
      highlightedRow = null;
    }
  };

  const getVisibleOptions = (): HTMLElement[] => {
    const all = options.querySelectorAll<HTMLElement>("[data-search-select-option]");
    return Array.from(all).filter(row => row.style.display !== "none");
  };

  const autoHighlight = (query: string) => {
    const visible = getVisibleOptions();
    if (visible.length === 0) {
      clearHighlight();
      return;
    }
    const lower = query.toLowerCase();
    // 1. Starts-with match
    for (let i = 0; i < visible.length; i++) {
      const label = (visible[i].getAttribute("data-label") || "").toLowerCase();
      if (lower && label.startsWith(lower)) {
        highlightOption(visible[i]);
        return;
      }
    }
    // 2. Substring match (fuzzy-lite)
    for (let j = 0; j < visible.length; j++) {
      const subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase();
      if (lower && subLabel.includes(lower)) {
        highlightOption(visible[j]);
        return;
      }
    }
    // 3. Fallback: first visible option
    highlightOption(visible[0]);
  };

  // Get active values in both form and filter modes
  const getSelectedValues = (): Set<string> => {
    const values = new Set<string>();
    pills.querySelectorAll<HTMLInputElement>('input[type="hidden"]').forEach(input => {
      values.add(input.value);
    });
    pills.querySelectorAll<HTMLElement>("[data-pill]").forEach(pill => {
      const value = pill.getAttribute("data-value");
      if (value) values.add(value);
    });
    return values;
  };

  // ── Render server-fetched rows into the panel ──
  const renderRows = (items: SearchSelectOption[]) => {
    const selectedValues = getSelectedValues();
    const preservedOptions: SearchSelectOption[] = [];

    // Extract existing option data for currently selected values before removing
    options.querySelectorAll<HTMLElement>("[data-search-select-option]").forEach(row => {
      const value = row.getAttribute("data-value");
      if (value && selectedValues.has(value)) {
        preservedOptions.push(optionFromRow(row));
      }
      row.remove();
    });

    const renderedValues = new Set<string>();

    // Render preserved options first (to keep them at the top)
    preservedOptions.forEach(option => {
      options.insertBefore(buildRow(option), noResults || null);
      renderedValues.add(String(option.value));
    });

    // Render newly fetched items (excluding already rendered preserved ones)
    // Fix DOM-limit vs fetch mismatch: Do not slice the items, render all returned items.
    items.forEach(item => {
      if (!renderedValues.has(String(item.value))) {
        options.insertBefore(buildRow(item), noResults || null);
        renderedValues.add(String(item.value));
      }
    });

    showPanel();
  };

  // ── Clone a server-rendered <template> prototype by name. The server emits
  //    the mode-appropriate prototypes, so the JS never names a class. ──
  const cloneTemplate = (templateName: string): HTMLElement | null => {
    const template = container.querySelector<HTMLTemplateElement>(
      `template[data-search-select-template="${templateName}"]`
    );
    const clone = template?.content.firstElementChild?.cloneNode(true);
    return (clone as HTMLElement) ?? null;
  };

  const setLabel = (node: Element, label: string) => {
    const slot = node.querySelector("[data-search-select-label]");
    if (slot) slot.textContent = label;
  };

  const applyData = (node: Element, data: Record<string, string> = {}) => {
    Object.keys(data).forEach(key => {
      node.setAttribute(`data-${key}`, data[key]);
    });
  };

  // Build an option row by cloning the "row" template (the same prototype the
  // server renders, so fetched and pre-rendered rows are identical).
  const buildRow = (option: SearchSelectOption): HTMLElement | Comment => {
    const row = cloneTemplate("row") as OptionRow | null;
    if (!row) return document.createComment("ss-row");
    row.setAttribute("data-value", option.value);
    row.setAttribute("data-label", option.label);
    applyData(row, option.data);
    setLabel(row, option.label);
    row._searchSelectOption = option;
    return row;
  };

  // ── Client-side filter of the currently loaded rows. Returns the number of
  //    visible rows so the caller decides whether to show the no-results node. ──
  const filterRows = (query: string): number => {
    const lower = query.toLowerCase();
    let visibleCount = 0;
    options.querySelectorAll<HTMLElement>("[data-search-select-option]").forEach(item => {
      const label = (item.getAttribute("data-label") || "").toLowerCase();
      const match = label.includes(lower);
      item.style.display = match ? "" : "none";
      if (match) visibleCount += 1;
    });
    return visibleCount;
  };

  // ── Fetch matching rows from the server. The previous in-flight request is
  //    aborted so a slower earlier response can never overwrite a newer one. ──
  const fetchFromServer = (query: string) => {
    if (pendingRequest) pendingRequest.abort();
    pendingRequest = new AbortController();
    let url = `${searchUrl}?q=${encodeURIComponent(query)}`;
    if (prefetch && !query) url += `&limit=${prefetch}`;
    fetch(url, { credentials: "same-origin", signal: pendingRequest.signal })
      .then(response => response.json())
      .then((items: SearchSelectOption[]) => {
        pendingRequest = null;
        renderRows(items);
        // Re-apply the live query: the box may hold more text than was sent.
        setNoResults(filterRows(search.value.trim()) === 0);
        autoHighlight(search.value.trim());
      })
      .catch(error => {
        if (error?.name === "AbortError") return; // superseded
        pendingRequest = null;
        setNoResults(true);
      });
  };

  // In free-text mode the typed text is the value itself: there is no
  // backing list, so we rebuild a single ephemeral option row reflecting the
  // current query so the +/− buttons (or Enter) can commit it as a pill.
  const rebuildFreeTextRow = (query: string) => {
    options.querySelectorAll("[data-search-select-option]").forEach(row => row.remove());
    if (!query) {
      setNoResults(false);
      clearHighlight();
      return;
    }
    const row = buildRow({ value: query, label: query, data: {} });
    options.insertBefore(row, noResults || null);
    setNoResults(false);
    highlightOption(row as HTMLElement);
  };

  // Called on every keystroke. With a search_url, filter the loaded window
  // instantly (zero latency) and debounce a server request for the rest;
  // no-results stays hidden until the response decides it, to avoid a flash
  // over an incomplete window. Without a search_url the loaded set is complete,
  // so the client-side filter is authoritative.
  const runSearch = () => {
    const query = search.value.trim();
    if (freeText) {
      rebuildFreeTextRow(query);
      showPanel();
      return;
    }
    if (searchUrl) {
      filterRows(query);
      setNoResults(false);
      if (debounceTimer) clearTimeout(debounceTimer);
      debounceTimer = setTimeout(() => {
        fetchFromServer(query);
      }, DEBOUNCE_MS);
    } else {
      setNoResults(filterRows(query) === 0);
    }
    autoHighlight(query);
    showPanel();
  };

  // ── Single-select combobox: the search box shows the committed label;
  //    focusing clears it to search, blurring restores it (or deselects). ──
  if (!multi) container._searchSelectLabel = search.value;

  search.addEventListener("focus", () => {
    if (!multi) {
      // Hide the committed label so the box becomes a fresh search field.
      search.value = "";
      container._searchSelectDirty = false;
    }
    if (freeText) {
      rebuildFreeTextRow(search.value.trim());
    } else if (searchUrl) {
      if (prefetch && !hasPrefetched) {
        // Seed the window immediately on first open (not debounced).
        hasPrefetched = true;
        fetchFromServer("");
      } else {
        // Show whatever is already loaded; the server decides no-results.
        filterRows(search.value.trim());
        setNoResults(false);
        autoHighlight(search.value.trim());
      }
    } else {
      setNoResults(filterRows(search.value.trim()) === 0);
      autoHighlight(search.value.trim());
    }
    showPanel();
  });

  search.addEventListener("input", () => {
    clearHighlight();
    if (!multi) {
      if (!container._searchSelectDirty) {
        const label = container._searchSelectLabel || "";
        if (search.value.startsWith(label)) {
          search.value = search.value.slice(label.length);
        }
        container._searchSelectDirty = true;
      }
    }
    runSearch();
  });

  if (!multi) {
    search.addEventListener("blur", () => {
      // Defer so an option click (which fires before blur settles) wins.
      setTimeout(() => {
        if (container._searchSelectDirty && search.value.trim() === "") {
          // User intentionally cleared the box → deselect.
          pills.innerHTML = "";
          container._searchSelectLabel = "";
          emitChange(null);
        } else {
          // Focused-and-left, or typed a partial query without picking →
          // restore the committed label (no-op right after a selection).
          search.value = container._searchSelectLabel || "";
        }
      }, 120);
    });
  }

  // ── Keyboard navigation (both form and filter modes) ──
  search.addEventListener("keydown", (event) => {
    const { key } = event;

    if (!multi && key === "Backspace" && !container._searchSelectDirty) {
      event.preventDefault();
      search.value = "";
      search.dispatchEvent(new Event("input", { bubbles: true }));
      return;
    }

    if (!["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(key)) return;
    const visible = getVisibleOptions();
    if (visible.length === 0) {
      if (key === "Escape") hidePanel();
      return;
    }

    if (key === "ArrowDown") {
      event.preventDefault();
      showPanel();
      const downIndex = highlightedRow ? visible.indexOf(highlightedRow) : -1;
      highlightOption(visible[(downIndex + 1) % visible.length]);
    } else if (key === "ArrowUp") {
      event.preventDefault();
      showPanel();
      const upIndex = highlightedRow ? visible.indexOf(highlightedRow) : -1;
      highlightOption(visible[(upIndex - 1 + visible.length) % visible.length]);
    } else if (key === "Enter") {
      if (highlightedRow) {
        event.preventDefault();
        const option = optionFromRow(highlightedRow);
        if (isFilter) {
          addFilterPill(option, "include");
          search.value = "";
        } else {
          selectOption(option);
        }
        clearHighlight();
        hidePanel();
      }
    } else if (key === "Escape") {
      clearHighlight();
      hidePanel();
    }
  });

  // Clicking an option must not blur the input before the click selects.
  options.addEventListener("mousedown", (event) => {
    event.preventDefault();
  });

  // ── Option click → select (form mode) or include/exclude (filter mode) ──
  options.addEventListener("click", (event) => {
    if (isFilter) {
      handleFilterOptionClick(event);
      return;
    }
    const row = (event.target as Element).closest<HTMLElement>("[data-search-select-option]");
    if (!row) return;
    selectOption(optionFromRow(row));
  });

  const handleFilterOptionClick = (event: MouseEvent) => {
    const target = event.target as Element;
    // Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
    const modifierRow = target.closest<HTMLElement>("[data-search-select-modifier-option]");
    if (modifierRow) {
      setModifier(
        modifierRow.getAttribute("data-search-select-modifier-option") ?? "",
        modifierRow.getAttribute("data-label") ?? ""
      );
      return;
    }
    // Include / exclude button on a value row.
    const button = target.closest<HTMLElement>("[data-search-select-action]");
    if (button) {
      const row = button.closest<HTMLElement>("[data-search-select-option]");
      if (!row) return;
      addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action") ?? "include");
      return;
    }
    // Click on the option row itself → include.
    const optionRow = target.closest<HTMLElement>("[data-search-select-option]");
    if (optionRow) {
      addFilterPill(optionFromRow(optionRow), "include");
    }
  };

  // Add (or re-type) an include/exclude pill for a value. Selecting any value
  // clears a presence modifier — NOT_NULL / IS_NULL are mutually exclusive
  // with value pills.  Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY)
  // persist alongside value pills.
  const addFilterPill = (option: SearchSelectOption, kind: string) => {
    const modifierPill = pills.querySelector("[data-search-select-modifier]");
    if (modifierPill) {
      const modifierValue = modifierPill.getAttribute("data-search-select-modifier") ?? "";
      if (PRESENCE_MODIFIERS.includes(modifierValue)) {
        clearModifier();
      }
    }
    const existing = pills.querySelector(
      `[data-pill][data-value="${cssEscape(option.value)}"]`
    );
    if (existing) existing.remove();
    pills.appendChild(buildFilterValuePill(option, kind));
    search.value = "";
    emitChange(null);
  };

  const buildFilterValuePill = (option: SearchSelectOption, kind: string): HTMLElement => {
    const pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude")!;
    pill.setAttribute("data-value", option.value);
    pill.setAttribute("data-label", option.label);
    applyData(pill, option.data);
    setLabel(pill, option.label);
    return pill;
  };

  // Set the modifier pill.  Presence modifiers (NOT_NULL / IS_NULL) clear all
  // value pills — they are mutually exclusive.  Non-presence modifiers
  // (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills.
  const setModifier = (modifierValue: string, label: string) => {
    // Remove any existing modifier pill to avoid duplicates.
    clearModifierPill();
    if (PRESENCE_MODIFIERS.includes(modifierValue)) {
      pills.innerHTML = "";
    }
    const pill = cloneTemplate("pill-modifier")!;
    pill.setAttribute("data-search-select-modifier", modifierValue);
    setLabel(pill, label);
    pills.insertBefore(pill, pills.firstChild);
    container.setAttribute("data-modifier", modifierValue);
    hidePanel();
    emitChange(null);
  };

  // Remove the modifier pill and its container attribute.  Safe to call when
  // there is no modifier pill (no-op).  Does not touch value pills.
  const clearModifierPill = () => {
    const modifierPill = pills.querySelector("[data-search-select-modifier]");
    if (modifierPill) modifierPill.remove();
    container.removeAttribute("data-modifier");
  };

  const clearModifier = () => {
    clearModifierPill();
  };

  const optionFromRow = (row: HTMLElement): SearchSelectOption => {
    const optionRow = row as OptionRow;
    if (optionRow._searchSelectOption) return optionRow._searchSelectOption;
    const data: Record<string, string> = {};
    Object.keys(row.dataset).forEach(key => {
      if (key !== "value" && key !== "label" && key !== "ssOption") {
        data[key] = row.dataset[key] ?? "";
      }
    });
    return {
      value: row.getAttribute("data-value") ?? "",
      label: row.getAttribute("data-label") ?? "",
      data,
    };
  };

  const selectOption = (option: SearchSelectOption) => {
    if (multi) {
      if (!pills.querySelector(`input[value="${cssEscape(option.value)}"]`)) {
        addPill(option);
      }
      search.value = "";
    } else {
      // Single-select: no pill — show the label in the search box and keep a
      // lone hidden input under [data-search-select-pills] for submission.
      pills.innerHTML = "";
      pills.appendChild(buildHidden(option.value));
      search.value = option.label;
      container._searchSelectLabel = option.label;
      container._searchSelectDirty = false;
      hidePanel();
    }
    emitChange(option);
  };

  const addPill = (option: SearchSelectOption) => {
    const pill = buildPill(option);
    if (pill) pills.appendChild(pill);
    pills.appendChild(buildHidden(option.value));
  };

  const buildPill = (option: SearchSelectOption): HTMLElement | null => {
    const pill = cloneTemplate("pill");
    if (!pill) return null;
    pill.setAttribute("data-value", option.value);
    applyData(pill, option.data);
    setLabel(pill, option.label);
    return pill;
  };

  const buildHidden = (value: string): HTMLInputElement => {
    const input = document.createElement("input");
    input.type = "hidden";
    input.name = name;
    input.value = value;
    return input;
  };

  // ── Pill × → remove ──
  pills.addEventListener("click", (event) => {
    const removeButton = (event.target as Element).closest("[data-pill-remove]");
    if (!removeButton) return;
    const pill = removeButton.closest("[data-pill]");
    if (!pill) return;
    if (isFilter) {
      // Filter pills have no hidden input.
      if (pill.hasAttribute("data-search-select-modifier")) {
        clearModifierPill();
      } else {
        pill.remove();
      }
      emitChange(null);
      return;
    }
    const value = pill.getAttribute("data-value");
    pill.remove();
    const hidden = pills.querySelector(`input[value="${cssEscape(value)}"]`);
    if (hidden) hidden.remove();
    emitChange(null);
  });

  const currentValues = (): string[] => {
    return Array.from(
      pills.querySelectorAll<HTMLInputElement>('input[type="hidden"]')
    ).map(input => input.value);
  };

  const emitChange = (last: SearchSelectOption | null) => {
    const values = currentValues();
    if (syncUrl) syncToUrl(values);
    container.dispatchEvent(
      new CustomEvent<SearchSelectChangeDetail>("search-select:change", {
        bubbles: true,
        detail: { name, values, last },
      })
    );
  };

  const syncToUrl = (values: string[]) => {
    const params = new URLSearchParams(window.location.search);
    params.delete(name);
    values.forEach(value => {
      params.append(name, value);
    });
    const queryString = params.toString();
    history.replaceState(null, "", queryString ? `?${queryString}` : window.location.pathname);
  };

  // On init, restore from URL params if the server supplied no selected pills.
  if (syncUrl && !pills.querySelector("[data-pill]")) {
    const initial = new URLSearchParams(window.location.search).getAll(name);
    initial.forEach(value => {
      addPill({ value, label: value, data: {} });
    });
  }

  // ── Close panel on outside click ──
  const onDocumentClick = (event: MouseEvent) => {
    if (!container.contains(event.target as Node)) hidePanel();
  };
  document.addEventListener("click", onDocumentClick);
  return onDocumentClick;
};

/** Minimal escape for use inside an attribute-value selector. */
const cssEscape = (value: string | null): string => String(value).replace(/["\\]/g, "\\$&");

// Serialise each widget's current state onto data-* attributes for the caller.
// Form widgets expose data-values (the submitted hidden-input values); filter
// widgets expose data-included / data-excluded / data-modifier for the filter
// bar to read.
export function readSearchSelect(form: HTMLElement): void {
  form.querySelectorAll<HTMLElement>("search-select").forEach(container => {
    const pills = container.querySelector<HTMLElement>("[data-search-select-pills]");
    if (container.getAttribute("filter-mode") === "true") {
      const included: FilterPillEntry[] = [];
      const excluded: FilterPillEntry[] = [];
      let modifier = "";
      if (pills) {
        pills.querySelectorAll<HTMLElement>("[data-pill]").forEach(pill => {
          const pillModifier = pill.getAttribute("data-search-select-modifier");
          if (pillModifier) {
            modifier = pillModifier;  // last modifier pill wins
            return;                    // skip value extraction for this pill
          }
          const value = pill.getAttribute("data-value") ?? "";
          const label = pill.getAttribute("data-label") || "";
          if (pill.getAttribute("data-search-select-type") === "exclude") {
            excluded.push({ id: value, label });
          } else {
            included.push({ id: value, label });
          }
        });
      }
      container.setAttribute("data-included", JSON.stringify(included));
      container.setAttribute("data-excluded", JSON.stringify(excluded));
      if (modifier) container.setAttribute("data-modifier", modifier);
      else container.removeAttribute("data-modifier");
      return;
    }
    const values = pills
      ? Array.from(pills.querySelectorAll<HTMLInputElement>('input[type="hidden"]')).map(input => input.value)
      : [];
    container.setAttribute("data-values", JSON.stringify(values));
  });
}

// Keep as window global for filter_bar.ts until it is converted to a custom element.
window.readSearchSelect = readSearchSelect;

class SearchSelectElement extends HTMLElement {
  private onDocumentClick: ((event: MouseEvent) => void) | null = null;

  connectedCallback(): void {
    this.onDocumentClick = initWidget(this) as ((event: MouseEvent) => void) | null;
  }

  disconnectedCallback(): void {
    if (this.onDocumentClick) {
      document.removeEventListener("click", this.onDocumentClick);
      this.onDocumentClick = null;
    }
  }
}

customElements.define("search-select", SearchSelectElement);
