/**
 * Loop runtime shared by loop-grid and loop-carousel (T118) and the Taxonomy filter (T119).
 *
 * The server prints the first page and `data-zx-loop='{"id","page","max","pagination","rest","post","qv","filters"}'`
 * on the widget root. The runtime loads further pages from `GET zadex/v1/loop/{doc}/{id}` (doc = closest
 * `[data-zx-doc]`, i.e. the page or the loop-item / theme template holding the loop):
 *
 *  - numbers / prev-next: link clicks load the page with AJAX (URL `?zx-page-<id>=N` kept in sync);
 *  - load more: appends the next page; infinite: same, when the sentinel scrolls into view;
 *  - filters: `applyLoopFilter(target, filterId, taxonomy, terms)` (the Taxonomy filter widget) re-renders page 1.
 *
 * New items are initialised with `window.zadexFrontend.init()` (widget runtimes inside loop items). The CSS of the
 * documents in the items comes as the response's `css` field and is added to the head when missing (rest-styles.ts).
 * Events on the root: `zadex:loop:loaded` ({ page, mode, response }).
 */
import { insertRestStyles, type RestStyles } from './rest-styles';

export interface LoopConfig {
  id: string;
  page: number;
  max: number;
  pagination: string;
  rest: string;
  post?: number;
  qv?: Record<string, unknown>;
  filters?: Record<string, { taxonomy: string; terms: string[] }>;
}

export interface LoopResponse {
  html: string;
  /** handle => CSS of the documents rendered in the items (loop-item templates, components ...). */
  css?: RestStyles;
  pagination: string;
  page: number;
  maxPages: number;
  found: number;
  hasMore: boolean;
  empty: boolean;
}

export interface LoopController {
  readonly root: HTMLElement;
  readonly config: LoopConfig;
  load(page: number, mode: 'append' | 'replace'): Promise<void>;
  setFilter(filterId: string, taxonomy: string, terms: string[]): Promise<void>;
  url(page: number): string;
  destroy(): void;
}

export interface LoopOptions {
  fetch?: typeof fetch;
  /** Items container selector (default `.zx-loop__items`). */
  items?: string;
  /** Attribute holding the JSON config (the carousel uses `data-zx-loop-config`: `data-zx-loop` is the slider's loop flag). */
  attr?: string;
  /** Called after items were appended / replaced (carousel: slider.update()). */
  onItems?: (mode: 'append' | 'replace') => void;
}

const registry = new Map<string, LoopController>();
const pending = new Map<string, Map<string, { taxonomy: string; terms: string[] }>>();

export function readLoopConfig(root: HTMLElement, attr = 'data-zx-loop'): LoopConfig | null {
  try {
    const raw = root.getAttribute(attr);
    if (!raw) return null;
    const c = JSON.parse(raw) as Partial<LoopConfig>;
    return {
      id: String(c.id ?? root.dataset.zxId ?? ''),
      page: Math.max(1, Number(c.page) || 1),
      max: Math.max(0, Number(c.max) || 0),
      pagination: String(c.pagination ?? 'none'),
      rest: String(c.rest ?? ''),
      post: Number(c.post) || 0,
      qv: c.qv && typeof c.qv === 'object' ? c.qv : undefined,
      filters: c.filters && typeof c.filters === 'object' ? c.filters : {},
    };
  } catch {
    return null;
  }
}

/** REST URL of a loop page (works with pretty permalinks and `?rest_route=`). */
export function loopUrl(config: LoopConfig, doc: string, page: number, filters: Record<string, { taxonomy: string; terms: string[] }> = {}): string {
  const base = config.rest || '/wp-json/zadex/v1/';
  const url = new URL(`${base}loop/${encodeURIComponent(doc)}/${encodeURIComponent(config.id)}`, typeof location !== 'undefined' ? location.href : 'http://localhost/');
  url.searchParams.set('page', String(page));
  if (config.post) url.searchParams.set('ctx', String(config.post));
  const byTax = new Map<string, Set<string>>();
  for (const f of Object.values(filters)) {
    if (!f || !f.taxonomy || !f.terms?.length) continue;
    const set = byTax.get(f.taxonomy) ?? new Set<string>();
    f.terms.forEach((t) => set.add(t));
    byTax.set(f.taxonomy, set);
  }
  for (const [tax, terms] of [...byTax.entries()].sort(([a], [b]) => a.localeCompare(b))) url.searchParams.set(`tax[${tax}]`, [...terms].join(','));
  for (const [k, v] of Object.entries(config.qv ?? {})) if (typeof v === 'string' || typeof v === 'number') url.searchParams.set(`qv[${k}]`, String(v));
  return url.toString();
}

function setPageParam(id: string, page: number): void {
  if (typeof history === 'undefined' || typeof location === 'undefined') return;
  try {
    const url = new URL(location.href);
    if (page > 1) url.searchParams.set(`zx-page-${id}`, String(page));
    else url.searchParams.delete(`zx-page-${id}`);
    history.replaceState(history.state, '', url.toString());
  } catch {
    /* ignore */
  }
}

export function createLoop(root: HTMLElement, options: LoopOptions = {}): LoopController | null {
  const config = readLoopConfig(root, options.attr);
  if (!config || !config.id) return null;
  const doFetch = options.fetch ?? (typeof fetch !== 'undefined' ? fetch.bind(globalThis) : undefined);
  const itemsSel = options.items ?? '.zx-loop__items';
  const doc = (root.parentElement?.closest('[data-zx-doc]') as HTMLElement | null)?.dataset.zxDoc ?? '';
  const filters: Record<string, { taxonomy: string; terms: string[] }> = { ...(config.filters ?? {}) };
  const win = root.ownerDocument.defaultView ?? (typeof window !== 'undefined' ? window : undefined);
  let page = config.page;
  let max = config.max;
  let busy = false;
  let seq = 0;
  let observer: IntersectionObserver | null = null;
  const cleanups: Array<() => void> = [];

  const itemsEl = (): HTMLElement | null => root.querySelector<HTMLElement>(itemsSel);

  const bindPagination = (): void => {
    observer?.disconnect();
    observer = null;
    const sentinel = root.querySelector<HTMLElement>('[data-zx-loop-sentinel]');
    if (sentinel && config.pagination === 'infinite' && typeof IntersectionObserver !== 'undefined') {
      observer = new IntersectionObserver((entries) => {
        if (entries.some((e) => e.isIntersecting) && !busy && page < max) void controller.load(page + 1, 'append');
      }, { rootMargin: '200px 0px' });
      observer.observe(sentinel);
    }
  };

  const onClick = (e: MouseEvent): void => {
    const target = e.target as HTMLElement | null;
    if (!target || typeof target.closest !== 'function') return;
    const pag = target.closest('[data-zx-pagination]');
    if (!pag || !root.contains(pag) || pag.closest('[data-zx-loop], [data-zx-loop-config]') !== root) return;
    if (target.closest('[data-zx-loop-more]')) {
      e.preventDefault();
      if (!busy && page < max) void controller.load(page + 1, 'append');
      return;
    }
    const link = target.closest<HTMLElement>('a[data-zx-page]');
    if (link) {
      e.preventDefault();
      const n = Number(link.dataset.zxPage) || 1;
      void controller.load(n, 'replace').then(() => {
        setPageParam(config.id, n);
        if (typeof root.scrollIntoView === 'function') root.scrollIntoView({ block: 'start', behavior: 'smooth' });
      });
    }
  };
  root.addEventListener('click', onClick);
  cleanups.push(() => root.removeEventListener('click', onClick));

  const controller: LoopController = {
    root,
    config,
    url: (n: number) => loopUrl(config, doc, n, filters),
    async load(n, mode) {
      if (!doFetch || !doc) return;
      const my = ++seq;
      busy = true;
      root.classList.add('is-loading');
      root.setAttribute('aria-busy', 'true');
      const more = root.querySelector<HTMLButtonElement>('[data-zx-loop-more]');
      if (more) more.disabled = true;
      try {
        const res = await doFetch(controller.url(n), { headers: { Accept: 'application/json' }, credentials: 'same-origin' });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = (await res.json()) as LoopResponse;
        if (my !== seq) return; // a newer request replaced this one (fast filter clicks)
        insertRestStyles(data.css, root.ownerDocument);
        const items = itemsEl();
        const runtime = (win as (Window & { zadexFrontend?: { init(root?: ParentNode): Promise<void>; dispose?(el: HTMLElement): void } }) | undefined)?.zadexFrontend;
        if (items) {
          // Replaced items: dispose their widget runtimes first (off-canvas portals, sliders …).
          if (mode === 'replace') items.querySelectorAll<HTMLElement>('[data-zx-type]').forEach((el) => runtime?.dispose?.(el));
          if (mode === 'replace') items.innerHTML = data.html;
          else items.insertAdjacentHTML('beforeend', data.html);
        }
        page = data.page;
        max = data.maxPages;
        const empty = root.querySelector<HTMLElement>('.zx-loop__empty');
        if (empty) empty.hidden = !(data.empty && (mode === 'replace' || !items?.children.length));
        const oldPag = root.querySelector<HTMLElement>('[data-zx-pagination]');
        if (oldPag) {
          if (data.pagination) oldPag.outerHTML = data.pagination;
          else oldPag.remove();
        } else if (data.pagination) {
          root.insertAdjacentHTML('beforeend', data.pagination);
        }
        bindPagination();
        options.onItems?.(mode);
        if (items) void runtime?.init(items);
        root.dispatchEvent(new CustomEvent('zadex:loop:loaded', { bubbles: true, detail: { page, mode, response: data } }));
      } catch (err) {
        root.dispatchEvent(new CustomEvent('zadex:loop:error', { bubbles: true, detail: { error: err } }));
      } finally {
        if (my === seq) {
          busy = false;
          root.classList.remove('is-loading');
          root.removeAttribute('aria-busy');
          const btn = root.querySelector<HTMLButtonElement>('[data-zx-loop-more]');
          if (btn) btn.disabled = false;
        }
      }
    },
    async setFilter(filterId, taxonomy, terms) {
      if (terms.length) filters[filterId] = { taxonomy, terms: [...terms] };
      else delete filters[filterId];
      setPageParam(config.id, 1);
      await controller.load(1, 'replace');
    },
    destroy() {
      observer?.disconnect();
      cleanups.forEach((c) => c());
      if (registry.get(config.id) === controller) registry.delete(config.id);
    },
  };

  bindPagination();
  registry.set(config.id, controller);
  // A filter that ran before this loop's runtime loaded (URL state restored on load).
  const waiting = pending.get(config.id);
  if (waiting) {
    pending.delete(config.id);
    const key = (f?: { terms: string[] }): string => (f?.terms ?? []).join(',');
    let changed = false;
    for (const [fid, f] of waiting) {
      if (key(filters[fid]) !== key(f)) changed = true;
      if (f.terms.length) filters[fid] = f;
      else delete filters[fid];
    }
    if (changed) void controller.load(1, 'replace'); // the server already rendered an identical state
  }
  return controller;
}

/** Current filter state a loop was rendered / loaded with (server `filters` + applied ones). */
export function loopFilterState(loopId: string, filterId: string): string[] | null {
  const c = registry.get(loopId);
  const f = c?.config.filters?.[filterId];
  return f ? [...f.terms] : c ? [] : null;
}

/**
 * Taxonomy filter → loop. Re-renders page 1 of the target loop with the terms (empty = all). When the loop's
 * runtime is not ready yet the filter is queued and applied as soon as it initialises.
 */
export function applyLoopFilter(loopId: string, filterId: string, taxonomy: string, terms: string[]): Promise<void> {
  if (typeof document !== 'undefined') {
    document.dispatchEvent(new CustomEvent('zadex:loop:filter', { detail: { target: loopId, filter: filterId, taxonomy, terms } }));
  }
  const c = registry.get(loopId);
  if (c) return c.setFilter(filterId, taxonomy, terms);
  const map = pending.get(loopId) ?? new Map();
  map.set(filterId, { taxonomy, terms: [...terms] });
  pending.set(loopId, map);
  return Promise.resolve();
}

export function getLoop(loopId: string): LoopController | undefined {
  return registry.get(loopId);
}

/** Test helper. */
export function __resetLoops(): void {
  registry.clear();
  pending.clear();
}
