/**
 * View-model hook for the project ticket list (F2).
 *
 * Consumes the {@link TicketGateway} for reads/creates and subscribes to the
 * `Issue*` domain events (via {@link SystemGateway}) to refresh the list on any
 * ticket mutation — including those made by agents through MCP. Filters/search
 * are part of the query and drive a re-fetch when they change.
 *
 * Sprints (ticket #10): the hook also loads the project's sprints so the panel
 * can group tickets by sprint, and exposes `assignSprint` to move a ticket into
 * or out of a sprint. Sprint lifecycle events refresh the sprint list.
 */

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

import {
  isSprintEvent,
  isTicketEvent,
  type GatewayError,
  type Sprint,
  type Ticket,
  type TicketBulkResult,
  type TicketList,
  type TicketPriority,
  type TicketStatus,
} from "@/domain";
import type { CreateTicketInput, TicketListQuery } from "@/ports";
import { useGateways } from "@/app/di";
import {
  hydrateListFilters,
  persistListFilters,
} from "./ticketFilterPersistence";

export interface TicketsViewModel {
  list: TicketList | null;
  /** The project's sprints, ordered by `order` (ticket #10). */
  sprints: Sprint[];
  error: string | null;
  busy: boolean;
  query: TicketListQuery;
  setQuery: (next: TicketListQuery) => void;
  /**
   * Toggles a status in the multi-select filter (ticket #12): adds it when
   * absent, removes it when present. An empty set ⇒ no status constraint (all).
   * The list re-fetches automatically as the query changes.
   */
  toggleStatus: (status: TicketStatus) => void;
  /** Toggles a priority in the multi-select filter (ticket #12). */
  togglePriority: (priority: TicketPriority) => void;
  /** Clears both facet filters (status + priority), leaving other criteria. */
  clearFacets: () => void;
  refresh: () => Promise<void>;
  create: (input: CreateTicketInput) => Promise<Ticket | null>;
  bulkUpdateStatus: (
    refs: string[],
    status: TicketStatus,
  ) => Promise<TicketBulkResult | null>;
  bulkUpdatePriority: (
    refs: string[],
    priority: TicketPriority,
  ) => Promise<TicketBulkResult | null>;
  bulkDelete: (refs: string[]) => Promise<TicketBulkResult | null>;
  /**
   * Assigns several tickets to a sprint by reusing the single-ticket sprint
   * contract. No backend bulk command exists for this path by design (#112).
   */
  bulkAssignSprint: (
    refs: string[],
    sprintId: string | null,
  ) => Promise<TicketBulkResult | null>;
  /**
   * Assigns a ticket to a sprint (or clears it with `sprintId === null`). Reads
   * the ticket's current version first so the summary-based list needs no
   * version column. Refreshes on the resulting `issueSprintChanged` event.
   */
  assignSprint: (ref: string, sprintId: string | null) => Promise<boolean>;
  /** Creates a sprint (#11). Returns the created sprint, or `null` on error. */
  createSprint: (name: string) => Promise<Sprint | null>;
  /** Renames a sprint using its current version (#11). */
  renameSprint: (sprintId: string, name: string) => Promise<boolean>;
  /**
   * Moves a sprint one slot up/down in execution order via `reorderSprints`
   * (#11). A no-op at the ends. Accessible (button-driven), non-DnD.
   */
  moveSprint: (sprintId: string, direction: "up" | "down") => Promise<boolean>;
  /**
   * Deletes a sprint (#11). The backend unassigns its tickets (does NOT delete
   * them). Refreshes both the sprint list and the tickets.
   */
  deleteSprint: (sprintId: string) => Promise<boolean>;
}

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

function describeBulkFailures(result: TicketBulkResult): string | null {
  const failed = result.items.filter((item) => !item.ok);
  if (failed.length === 0) return null;
  return `${failed.length} ticket(s) non modifié(s): ${failed
    .map((item) => item.ref)
    .join(", ")}`;
}

export function useTickets(projectId: string): TicketsViewModel {
  const { ticket, system, uiPreferences } = useGateways();
  const [list, setList] = useState<TicketList | null>(null);
  const [sprints, setSprints] = useState<Sprint[]>([]);
  const [error, setError] = useState<string | null>(null);
  const [busy, setBusy] = useState(false);
  // Hydrate the filters persisted for this project (ticket #29) on first render
  // so reopening the window restores them without a flash of defaults. The
  // assignee, if any, is validated for shape here and reconciled against the
  // live agent roster by the panel (an obsolete assignee is then cleared).
  const [query, setQuery] = useState<TicketListQuery>(() =>
    hydrateListFilters(uiPreferences, projectId),
  );

  // Re-hydrate when the hosting project changes (the mounted hook is normally
  // pinned to one project, but stay correct if `projectId` is swapped in place).
  const hydratedProject = useRef(projectId);
  useEffect(() => {
    if (hydratedProject.current === projectId) return;
    hydratedProject.current = projectId;
    setQuery(hydrateListFilters(uiPreferences, projectId));
  }, [projectId, uiPreferences]);

  // Persist the filters (minus the opaque cursor) whenever they change, scoped
  // to this project's list key.
  useEffect(() => {
    persistListFilters(uiPreferences, projectId, query);
  }, [uiPreferences, projectId, query]);

  // Stable dependency key so `refresh` only changes when a filter actually does.
  const queryKey = useMemo(() => JSON.stringify(query), [query]);

  const refresh = useCallback(async () => {
    setBusy(true);
    setError(null);
    try {
      setList(await ticket.list(projectId, query));
    } catch (e) {
      setError(describe(e));
    } finally {
      setBusy(false);
    }
    // `query` is captured via `queryKey`; listing it directly would rebuild the
    // callback on every render (a new object identity each time).
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [projectId, ticket, queryKey]);

  // Toggle a value in one facet set, dropping the key entirely when it empties
  // so the query stays clean ("no constraint"). Setting a fresh object identity
  // re-fetches via the `queryKey` effect (ticket #12).
  const toggleStatus = useCallback((status: TicketStatus) => {
    setQuery((prev) => {
      const current = prev.statuses ?? [];
      const next = current.includes(status)
        ? current.filter((s) => s !== status)
        : [...current, status];
      const { statuses: _drop, ...rest } = prev;
      return next.length > 0 ? { ...rest, statuses: next } : rest;
    });
  }, []);

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

  const clearFacets = useCallback(() => {
    setQuery(({ statuses: _s, priorities: _p, ...rest }) => rest);
  }, []);

  const refreshSprints = useCallback(async () => {
    try {
      setSprints(await ticket.listSprints(projectId));
    } catch (e) {
      setError(describe(e));
    }
  }, [projectId, ticket]);

  const create = useCallback(
    async (input: CreateTicketInput): Promise<Ticket | null> => {
      setError(null);
      try {
        const created = await ticket.create(projectId, input);
        await refresh();
        return created;
      } catch (e) {
        setError(describe(e));
        return null;
      }
    },
    [projectId, ticket, refresh],
  );

  const bulkUpdateStatus = useCallback(
    async (
      refs: string[],
      status: TicketStatus,
    ): Promise<TicketBulkResult | null> => {
      if (refs.length === 0) return { items: [] };
      setBusy(true);
      setError(null);
      try {
        const result = await ticket.bulkUpdateStatus(projectId, refs, status);
        const failure = describeBulkFailures(result);
        if (failure) setError(failure);
        await refresh();
        return result;
      } catch (e) {
        setError(describe(e));
        return null;
      } finally {
        setBusy(false);
      }
    },
    [projectId, ticket, refresh],
  );

  const bulkUpdatePriority = useCallback(
    async (
      refs: string[],
      priority: TicketPriority,
    ): Promise<TicketBulkResult | null> => {
      if (refs.length === 0) return { items: [] };
      setBusy(true);
      setError(null);
      try {
        const result = await ticket.bulkUpdatePriority(projectId, refs, priority);
        const failure = describeBulkFailures(result);
        if (failure) setError(failure);
        await refresh();
        return result;
      } catch (e) {
        setError(describe(e));
        return null;
      } finally {
        setBusy(false);
      }
    },
    [projectId, ticket, refresh],
  );

  const bulkDelete = useCallback(
    async (refs: string[]): Promise<TicketBulkResult | null> => {
      if (refs.length === 0) return { items: [] };
      setBusy(true);
      setError(null);
      try {
        const result = await ticket.bulkDelete(projectId, refs);
        const failure = describeBulkFailures(result);
        if (failure) setError(failure);
        await Promise.all([refresh(), refreshSprints()]);
        return result;
      } catch (e) {
        setError(describe(e));
        return null;
      } finally {
        setBusy(false);
      }
    },
    [projectId, ticket, refresh, refreshSprints],
  );

  const bulkAssignSprint = useCallback(
    async (
      refs: string[],
      sprintId: string | null,
    ): Promise<TicketBulkResult | null> => {
      if (refs.length === 0) return { items: [] };
      setBusy(true);
      setError(null);
      try {
        const items: TicketBulkResult["items"] = [];
        for (const ref of refs) {
          try {
            const current = await ticket.read(projectId, ref);
            const updated = await ticket.setTicketSprint(
              projectId,
              ref,
              sprintId,
              current.version,
            );
            items.push({ ref, ok: true, ticket: updated });
          } catch (e) {
            items.push({
              ref,
              ok: false,
              error:
                e && typeof e === "object" && "message" in e
                  ? (e as GatewayError)
                  : { code: "UNKNOWN", message: String(e) },
            });
          }
        }
        const result = { items };
        const failure = describeBulkFailures(result);
        await Promise.all([refresh(), refreshSprints()]);
        if (failure) setError(failure);
        return result;
      } catch (e) {
        setError(describe(e));
        return null;
      } finally {
        setBusy(false);
      }
    },
    [projectId, ticket, refresh, refreshSprints],
  );

  const assignSprint = useCallback(
    async (ref: string, sprintId: string | null): Promise<boolean> => {
      setError(null);
      try {
        const current = await ticket.read(projectId, ref);
        await ticket.setTicketSprint(projectId, ref, sprintId, current.version);
        // The `issueSprintChanged` event refreshes the list; refresh the sprint
        // counts too (and update immediately in case events are not wired).
        await Promise.all([refresh(), refreshSprints()]);
        return true;
      } catch (e) {
        setError(describe(e));
        return false;
      }
    },
    [projectId, ticket, refresh, refreshSprints],
  );

  const createSprint = useCallback(
    async (name: string): Promise<Sprint | null> => {
      setError(null);
      try {
        const created = await ticket.createSprint(projectId, name);
        await refreshSprints();
        return created;
      } catch (e) {
        setError(describe(e));
        return null;
      }
    },
    [projectId, ticket, refreshSprints],
  );

  const renameSprint = useCallback(
    async (sprintId: string, name: string): Promise<boolean> => {
      setError(null);
      try {
        const current = sprints.find((s) => s.id === sprintId);
        if (!current) return false;
        await ticket.renameSprint(projectId, sprintId, name, current.version);
        await refreshSprints();
        return true;
      } catch (e) {
        setError(describe(e));
        return false;
      }
    },
    [projectId, ticket, sprints, refreshSprints],
  );

  const moveSprint = useCallback(
    async (sprintId: string, direction: "up" | "down"): Promise<boolean> => {
      setError(null);
      const ordered = [...sprints].sort((a, b) => a.order - b.order);
      const index = ordered.findIndex((s) => s.id === sprintId);
      const swapWith = direction === "up" ? index - 1 : index + 1;
      if (index === -1 || swapWith < 0 || swapWith >= ordered.length) {
        return false;
      }
      const next = [...ordered];
      [next[index], next[swapWith]] = [next[swapWith], next[index]];
      try {
        await ticket.reorderSprints(
          projectId,
          next.map((s) => s.id),
        );
        await refreshSprints();
        return true;
      } catch (e) {
        setError(describe(e));
        return false;
      }
    },
    [projectId, ticket, sprints, refreshSprints],
  );

  const deleteSprint = useCallback(
    async (sprintId: string): Promise<boolean> => {
      setError(null);
      try {
        await ticket.deleteSprint(projectId, sprintId);
        // Delete unassigns tickets → refresh both projections.
        await Promise.all([refresh(), refreshSprints()]);
        return true;
      } catch (e) {
        setError(describe(e));
        return false;
      }
    },
    [projectId, ticket, refresh, refreshSprints],
  );

  useEffect(() => {
    void refresh();
  }, [refresh]);

  useEffect(() => {
    void refreshSprints();
  }, [refreshSprints]);

  useEffect(() => {
    if (!system) return;
    let unsubscribe: (() => void) | undefined;
    let cancelled = false;
    void system
      .onDomainEvent((event) => {
        if (isTicketEvent(event)) void refresh();
        // Sprint membership (`issueSprintChanged`) and sprint lifecycle events
        // both change the grouping → refresh the sprint list.
        if (isSprintEvent(event) || event.type === "issueSprintChanged") {
          void refreshSprints();
        }
      })
      .then((u) => {
        if (cancelled) u();
        else unsubscribe = u;
      });
    return () => {
      cancelled = true;
      unsubscribe?.();
    };
  }, [refresh, refreshSprints, system]);

  return {
    list,
    sprints,
    error,
    busy,
    query,
    setQuery,
    toggleStatus,
    togglePriority,
    clearFacets,
    refresh,
    create,
    bulkUpdateStatus,
    bulkUpdatePriority,
    bulkDelete,
    bulkAssignSprint,
    assignSprint,
    createSprint,
    renameSprint,
    moveSprint,
    deleteSprint,
  };
}
