/**
 * Lightweight ticket search view-model for the {@link TicketPicker} popup (#18).
 *
 * A deliberately slim alternative to {@link useTickets}: it wraps only
 * `TicketGateway.list` with the status/priority facets and a debounced free-text
 * search, plus client-side `excludeRefs` filtering and opaque-cursor pagination.
 * It does NOT group by sprint, subscribe to domain events, or expose
 * create/assign — the picker only ever reads and selects.
 *
 * ── Contract guards ──
 * - G3-bis: `cursor` is an **opaque token**. We never build or increment it; we
 *   only relay the `nextCursor` the previous page returned. A backend migration
 *   to opaque cursors is therefore zero-change here.
 * - `excludeRefs` is filtered **client-side** after each page arrives (the
 *   backend has no "exclude" criterion).
 * - `TicketSummary` from `ticket_list` carries no `id`/`number`, so {@link
 *   TicketSearchViewModel.resolve} reads the ticket on selection to build the
 *   full {@link TicketPickerResult}.
 */

import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import {
  parseTicketFilters,
  sanitizePickerFilters,
} from "./ticketFilterPersistence";

import type {
  GatewayError,
  TicketPriority,
  TicketRef,
  TicketStatus,
  TicketSummary,
} from "@/domain";
import { isSprintEvent, isTicketEvent } from "@/domain";
import type { TicketListQuery, TicketListSort } from "@/ports";
import { useGateways } from "@/app/di";

/**
 * The selection payload the picker hands back (frozen contract, #18). `number`
 * is the numeric form of `ref` and `id` is the stable ticket id — both resolved
 * via a single `ticket_read` on selection since `ticket_list` omits them.
 */
export interface TicketPickerResult {
  ref: TicketRef;
  id: string;
  number: number;
  title: string;
  status: TicketStatus;
  priority: TicketPriority;
}

export interface UseTicketSearchOptions {
  /**
   * Seed query. `statuses`/`priorities`/`assignedAgentId`/`text`/`limit` are
   * honored; `cursor` is ignored (pagination always starts at the first page).
   */
  initialQuery?: TicketListQuery;
  /** Refs to hide from the results, filtered client-side after each page. */
  excludeRefs?: TicketRef[];
  /** Debounce applied to the free-text search, in ms (default 200). */
  debounceMs?: number;
  /**
   * When true, subscribe to `Issue*`/`sprint*` domain events and re-fetch the
   * first page on any ticket/sprint mutation. Off by default because the
   * {@link TicketPicker} popup is short-lived and only reads; the sprint view
   * (#37) enables it so its independent query stays in sync with add/remove.
   */
  refreshOnEvents?: boolean;
  /**
   * Storage key under which this surface persists its filters (ticket #29). When
   * set, the facets/text/sort are hydrated from it at mount and re-persisted on
   * change — but **`initialQuery` wins per field**: any criterion the caller
   * imposes (a business filter such as a sprint context) overrides the persisted
   * preference for that field. `assignedAgentId`, `limit` and the opaque `cursor`
   * are never persisted here (picker surface). When omitted, no persistence
   * happens (default) so unrelated `useTicketSearch` call-sites are unaffected.
   */
  persistenceKey?: string;
}

export interface TicketSearchViewModel {
  /** Result rows for the current query, accumulated across loaded pages, with
   *  `excludeRefs` removed. */
  rows: TicketSummary[];
  busy: boolean;
  error: string | null;
  /** Raw (un-debounced) search text — bind straight to the input. */
  text: string;
  setText: (text: string) => void;
  statuses: TicketStatus[];
  priorities: TicketPriority[];
  toggleStatus: (status: TicketStatus) => void;
  togglePriority: (priority: TicketPriority) => void;
  clearFacets: () => void;
  /** Current sort (#21); `undefined` ⇒ the backend's historical order. */
  sort: TicketListSort | undefined;
  /** Sets the sort (or clears it); resets pagination to the first page. */
  setSort: (sort: TicketListSort | undefined) => void;
  /** True when the backend reported a further page (opaque cursor present). */
  hasMore: boolean;
  /** Loads and appends the next page (no-op when exhausted or busy). */
  loadMore: () => void;
  /** Forces a first-page re-fetch (e.g. after an external mutation). */
  refresh: () => void;
  /** Resolves a row's `#ref` to the full picker result (reads id + number). */
  resolve: (ref: TicketRef) => Promise<TicketPickerResult>;
}

function describe(e: unknown): string {
  if (e && typeof e === "object" && "message" in e) {
    return String((e as GatewayError).message);
  }
  return String(e);
}

/** Facet subset of the query we own locally (text is handled via debounce). */
interface Facets {
  statuses?: TicketStatus[];
  priorities?: TicketPriority[];
  assignedAgentId?: string;
}

export function useTicketSearch(
  projectId: string,
  options: UseTicketSearchOptions = {},
): TicketSearchViewModel {
  const {
    initialQuery,
    excludeRefs,
    debounceMs = 200,
    refreshOnEvents,
    persistenceKey,
  } = options;
  const { ticket, system, uiPreferences } = useGateways();

  // Seed the initial facets/text/sort once (ticket #29): the persisted
  // preference is the base, but `initialQuery` overrides it per field so a
  // caller-imposed business filter always wins over a stored one.
  const seedRef = useRef<{
    statuses?: TicketStatus[];
    priorities?: TicketPriority[];
    assignedAgentId?: string;
    text?: string;
    sort?: TicketListSort;
  } | null>(null);
  if (seedRef.current === null) {
    const persisted =
      persistenceKey && uiPreferences
        ? parseTicketFilters(uiPreferences.read(persistenceKey))
        : {};
    seedRef.current = {
      statuses: initialQuery?.statuses ?? persisted.statuses,
      priorities: initialQuery?.priorities ?? persisted.priorities,
      assignedAgentId: initialQuery?.assignedAgentId ?? persisted.assignedAgentId,
      text: initialQuery?.text ?? persisted.text,
      sort: initialQuery?.sort ?? persisted.sort,
    };
  }
  const seed = seedRef.current;

  const [facets, setFacets] = useState<Facets>({
    statuses: seed.statuses,
    priorities: seed.priorities,
    assignedAgentId: seed.assignedAgentId,
  });
  const [text, setText] = useState(seed.text ?? "");
  const [debouncedText, setDebouncedText] = useState(text);
  const [sort, setSort] = useState<TicketListSort | undefined>(seed.sort);

  const [items, setItems] = useState<TicketSummary[]>([]);
  const [cursor, setCursor] = useState<string | undefined>(undefined);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  // Bumped to force a first-page re-fetch without changing the query (used by
  // `refresh` and the domain-event subscription).
  const [reloadNonce, setReloadNonce] = useState(0);
  const refresh = useCallback(() => setReloadNonce((n) => n + 1), []);

  const limit = initialQuery?.limit;
  // Stable set so identity only changes when the caller passes new refs.
  const excludeKey = (excludeRefs ?? []).join(",");
  const excluded = useMemo(
    () => new Set(excludeRefs ?? []),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [excludeKey],
  );

  // Debounce the free-text search so typing doesn't refetch on every keystroke.
  useEffect(() => {
    const id = setTimeout(() => setDebouncedText(text), debounceMs);
    return () => clearTimeout(id);
  }, [text, debounceMs]);

  // Persist the picker's filters (ticket #29): text/statuses/priorities/sort
  // only — never `assignedAgentId`, `limit` or the opaque `cursor`. Keyed by the
  // caller-provided `persistenceKey`; a no-op when persistence is disabled.
  useEffect(() => {
    if (!persistenceKey || !uiPreferences) return;
    const trimmed = debouncedText.trim();
    const sanitized = sanitizePickerFilters({
      ...(facets.statuses && facets.statuses.length > 0
        ? { statuses: facets.statuses }
        : {}),
      ...(facets.priorities && facets.priorities.length > 0
        ? { priorities: facets.priorities }
        : {}),
      ...(trimmed ? { text: trimmed } : {}),
      ...(sort ? { sort } : {}),
    });
    if (sanitized) uiPreferences.write(persistenceKey, sanitized);
    else uiPreferences.remove(persistenceKey);
  }, [persistenceKey, uiPreferences, facets, debouncedText, sort]);

  // A key that changes whenever the effective query (minus cursor) changes, so
  // the first-page effect refetches exactly when a criterion moves.
  const queryKey = useMemo(
    () => JSON.stringify({ facets, text: debouncedText.trim(), sort, limit }),
    [facets, debouncedText, sort, limit],
  );

  // Guards against races: only the latest first-page/loadMore fetch may commit.
  const fetchToken = useRef(0);

  const buildQuery = useCallback(
    (nextCursor?: string): TicketListQuery => {
      const trimmed = debouncedText.trim();
      return {
        ...(facets.statuses && facets.statuses.length > 0
          ? { statuses: facets.statuses }
          : {}),
        ...(facets.priorities && facets.priorities.length > 0
          ? { priorities: facets.priorities }
          : {}),
        ...(facets.assignedAgentId
          ? { assignedAgentId: facets.assignedAgentId }
          : {}),
        ...(trimmed ? { text: trimmed } : {}),
        ...(sort ? { sort } : {}),
        ...(limit ? { limit } : {}),
        // G3-bis: opaque token, relayed verbatim from the previous page only.
        ...(nextCursor ? { cursor: nextCursor } : {}),
      };
    },
    [facets, debouncedText, sort, limit],
  );

  // First page: (re)fetch whenever the query key changes; replaces the list.
  useEffect(() => {
    const token = ++fetchToken.current;
    setBusy(true);
    setError(null);
    void ticket
      .list(projectId, buildQuery())
      .then((page) => {
        if (token !== fetchToken.current) return;
        setItems(page.items);
        setCursor(page.nextCursor);
      })
      .catch((e) => {
        if (token !== fetchToken.current) return;
        setError(describe(e));
        setItems([]);
        setCursor(undefined);
      })
      .finally(() => {
        if (token === fetchToken.current) setBusy(false);
      });
    // `buildQuery`/`projectId`/`ticket` are captured via `queryKey`; listing
    // them would refetch on every render (fresh object identities).
    // `reloadNonce` is an explicit refetch trigger (refresh / domain events).
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [queryKey, projectId, ticket, reloadNonce]);

  // Optional live refresh: on any ticket/sprint mutation, re-fetch the first
  // page so an externally-driven change (e.g. sprint add/remove) reflects here.
  useEffect(() => {
    if (!refreshOnEvents || !system) return;
    let unsubscribe: (() => void) | undefined;
    let cancelled = false;
    void system
      .onDomainEvent((event) => {
        if (isTicketEvent(event) || isSprintEvent(event)) {
          setReloadNonce((n) => n + 1);
        }
      })
      .then((u) => {
        if (cancelled) u();
        else unsubscribe = u;
      });
    return () => {
      cancelled = true;
      unsubscribe?.();
    };
  }, [refreshOnEvents, system]);

  const loadMore = useCallback(() => {
    if (busy || !cursor) return;
    const token = ++fetchToken.current;
    setBusy(true);
    setError(null);
    void ticket
      .list(projectId, buildQuery(cursor))
      .then((page) => {
        if (token !== fetchToken.current) return;
        setItems((prev) => [...prev, ...page.items]);
        setCursor(page.nextCursor);
      })
      .catch((e) => {
        if (token !== fetchToken.current) return;
        setError(describe(e));
      })
      .finally(() => {
        if (token === fetchToken.current) setBusy(false);
      });
  }, [busy, cursor, ticket, projectId, buildQuery]);

  const toggleStatus = useCallback((status: TicketStatus) => {
    setFacets((prev) => {
      const current = prev.statuses ?? [];
      const next = current.includes(status)
        ? current.filter((s) => s !== status)
        : [...current, status];
      return { ...prev, statuses: next.length > 0 ? next : undefined };
    });
  }, []);

  const togglePriority = useCallback((priority: TicketPriority) => {
    setFacets((prev) => {
      const current = prev.priorities ?? [];
      const next = current.includes(priority)
        ? current.filter((p) => p !== priority)
        : [...current, priority];
      return { ...prev, priorities: next.length > 0 ? next : undefined };
    });
  }, []);

  const clearFacets = useCallback(() => {
    setFacets((prev) => ({
      ...prev,
      statuses: undefined,
      priorities: undefined,
    }));
  }, []);

  const resolve = useCallback(
    async (ref: TicketRef): Promise<TicketPickerResult> => {
      const t = await ticket.read(projectId, ref);
      return {
        ref: t.ref,
        id: t.id,
        number: t.number,
        title: t.title,
        status: t.status,
        priority: t.priority,
      };
    },
    [ticket, projectId],
  );

  const rows = useMemo(
    () => (excluded.size === 0 ? items : items.filter((t) => !excluded.has(t.ref))),
    [items, excluded],
  );

  return {
    rows,
    busy,
    error,
    text,
    setText,
    statuses: facets.statuses ?? [],
    priorities: facets.priorities ?? [],
    toggleStatus,
    togglePriority,
    clearFacets,
    sort,
    setSort,
    hasMore: cursor !== undefined,
    loadMore,
    refresh,
    resolve,
  };
}
