import { describe, it, expect, beforeEach, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";

import { DIProvider } from "@/app/di";
import { MockSystemGateway, MockTicketGateway } from "@/adapters/mock";
import type { Ticket } from "@/domain";
import type { Gateways } from "@/ports";
import { TicketPicker, type TicketPickerResult } from "./TicketPicker";

const PROJECT_ID = "project-picker-test";

function renderPicker(
  ticket: MockTicketGateway,
  props: Partial<React.ComponentProps<typeof TicketPicker>> = {},
) {
  const onSelect = vi.fn();
  const onClose = vi.fn();
  const gateways = { ticket } as unknown as Gateways;
  const utils = render(
    <DIProvider gateways={gateways}>
      <TicketPicker
        open
        projectId={PROJECT_ID}
        onSelect={onSelect}
        onClose={onClose}
        {...props}
      />
    </DIProvider>,
  );
  return { ...utils, onSelect, onClose };
}

/** Seeds three tickets with distinct status/priority for filter assertions. */
async function seedTrio(ticket: MockTicketGateway): Promise<Ticket[]> {
  const a = await ticket.create(PROJECT_ID, {
    title: "Alpha bug",
    status: "open",
    priority: "high",
  });
  const b = await ticket.create(PROJECT_ID, {
    title: "Bravo task",
    priority: "low",
  });
  await ticket.update(PROJECT_ID, b.ref, {
    status: "closed",
    expectedVersion: b.version,
  });
  const c = await ticket.create(PROJECT_ID, {
    title: "Charlie chore",
    status: "QA",
    priority: "medium",
  });
  return [a, b, c];
}

describe("TicketPicker (#18)", () => {
  let system: MockSystemGateway;
  let ticket: MockTicketGateway;

  beforeEach(() => {
    system = new MockSystemGateway();
    ticket = new MockTicketGateway(system);
  });

  it("renders nothing when closed", () => {
    const gateways = { ticket } as unknown as Gateways;
    const { container } = render(
      <DIProvider gateways={gateways}>
        <TicketPicker
          open={false}
          projectId={PROJECT_ID}
          onSelect={vi.fn()}
          onClose={vi.fn()}
        />
      </DIProvider>,
    );
    expect(container.querySelector('[role="dialog"]')).toBeNull();
  });

  it("lists tickets and single-select hands back the full result + closes", async () => {
    const [a] = await seedTrio(ticket);
    const { onSelect, onClose } = renderPicker(ticket);

    // Rows appear.
    await screen.findByText("Alpha bug");
    expect(screen.getByText("Bravo task")).toBeTruthy();
    expect(screen.getByText("Charlie chore")).toBeTruthy();

    fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));

    await waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
    const result = onSelect.mock.calls[0][0] as TicketPickerResult;
    expect(result).toMatchObject({
      ref: a.ref,
      id: a.id,
      number: a.number,
      title: "Alpha bug",
      status: "open",
      priority: "high",
    });
    expect(onClose).toHaveBeenCalledTimes(1);
  });

  it("hides excludeRefs client-side", async () => {
    const [a, b] = await seedTrio(ticket);
    renderPicker(ticket, { excludeRefs: [a.ref] });

    await screen.findByText("Bravo task");
    expect(screen.queryByText("Alpha bug")).toBeNull();
    // Excluded ref cannot be selected either.
    expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull();
    expect(screen.getByLabelText(`select ticket ${b.ref}`)).toBeTruthy();
  });

  it("filters by status facet (OR within, AND across)", async () => {
    await seedTrio(ticket);
    renderPicker(ticket);

    await screen.findByText("Alpha bug");

    // Tick "open" ⇒ only Alpha (open) remains; Bravo (closed) & Charlie (QA) go.
    fireEvent.click(screen.getByLabelText("filter status Open"));
    await waitFor(() => expect(screen.queryByText("Bravo task")).toBeNull());
    expect(screen.getByText("Alpha bug")).toBeTruthy();
    expect(screen.queryByText("Charlie chore")).toBeNull();

    // Add priority "low" ⇒ (open) AND (low) ⇒ nothing (Alpha is high).
    fireEvent.click(screen.getByLabelText("filter priority Low"));
    await waitFor(() =>
      expect(screen.getByText("No matching tickets.")).toBeTruthy(),
    );

    // Clear facets ⇒ all three back.
    fireEvent.click(screen.getByLabelText("clear filters"));
    await waitFor(() => expect(screen.getByText("Charlie chore")).toBeTruthy());
    expect(screen.getByText("Alpha bug")).toBeTruthy();
    expect(screen.getByText("Bravo task")).toBeTruthy();
  });

  it("sorts the results via the « Trier par… » control (#21)", async () => {
    const listSpy = vi.spyOn(ticket, "list");
    await seedTrio(ticket);
    renderPicker(ticket);
    await screen.findByText("Alpha bug");

    // The sort control is present in the picker too (shared TicketFacetsBar).
    const sortBy = screen.getByLabelText("sort tickets by");
    fireEvent.change(sortBy, { target: { value: "title" } });
    await waitFor(() =>
      expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
        field: "title",
        direction: "asc",
      }),
    );
    // Ascending by title ⇒ Alpha before Charlie in the DOM.
    await waitFor(() => {
      const rendered = screen
        .getAllByText(/Alpha bug|Bravo task|Charlie chore/)
        .map((n) => n.textContent);
      expect(rendered.indexOf("Alpha bug")).toBeLessThan(
        rendered.indexOf("Charlie chore"),
      );
    });

    // Flip to descending ⇒ Charlie now precedes Alpha.
    fireEvent.click(screen.getByLabelText("sort direction ascending"));
    await waitFor(() =>
      expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
        field: "title",
        direction: "desc",
      }),
    );
    await waitFor(() => {
      const rendered = screen
        .getAllByText(/Alpha bug|Bravo task|Charlie chore/)
        .map((n) => n.textContent);
      expect(rendered.indexOf("Charlie chore")).toBeLessThan(
        rendered.indexOf("Alpha bug"),
      );
    });
  });

  it("debounced text search narrows the list", async () => {
    await seedTrio(ticket);
    renderPicker(ticket);

    await screen.findByText("Alpha bug");

    fireEvent.change(screen.getByLabelText("search tickets"), {
      target: { value: "charlie" },
    });

    // Wait on the removal (all three are visible until the debounced refetch).
    await waitFor(() => expect(screen.queryByText("Alpha bug")).toBeNull());
    expect(screen.getByText("Charlie chore")).toBeTruthy();
    expect(screen.queryByText("Bravo task")).toBeNull();
  });

  it("honors initialQuery as a seed filter", async () => {
    await seedTrio(ticket);
    renderPicker(ticket, { initialQuery: { statuses: ["QA"] } });

    await screen.findByText("Charlie chore");
    expect(screen.queryByText("Alpha bug")).toBeNull();
    expect(screen.queryByText("Bravo task")).toBeNull();
  });

  it("closes on Escape and on backdrop click", async () => {
    await seedTrio(ticket);
    const { onClose } = renderPicker(ticket);
    await screen.findByText("Alpha bug");

    fireEvent.keyDown(document, { key: "Escape" });
    expect(onClose).toHaveBeenCalledTimes(1);

    // Backdrop (the outermost fixed overlay) closes on mousedown.
    const dialog = screen.getByRole("dialog");
    const backdrop = dialog.parentElement as HTMLElement;
    fireEvent.mouseDown(backdrop);
    expect(onClose).toHaveBeenCalledTimes(2);
  });

  it("uses a custom title and surfaces a resolve failure without closing", async () => {
    const [a] = await seedTrio(ticket);
    const readSpy = vi
      .spyOn(ticket, "read")
      .mockRejectedValueOnce({ code: "NOT_FOUND", message: "gone" });
    const { onSelect, onClose } = renderPicker(ticket, {
      title: "Choisir un ticket à lier",
    });

    await screen.findByText("Alpha bug");
    expect(
      screen.getByRole("dialog", { name: "Choisir un ticket à lier" }),
    ).toBeTruthy();

    fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));

    await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("gone"));
    expect(onSelect).not.toHaveBeenCalled();
    expect(onClose).not.toHaveBeenCalled();
    readSpy.mockRestore();
  });

  describe("multi-select (#41)", () => {
    it("toggles rows without closing and confirms the resolved array", async () => {
      const [a, , c] = await seedTrio(ticket);
      const { onSelect, onClose } = renderPicker(ticket, {
        selectionMode: "multi",
        confirmLabel: "Ajouter au sprint",
      });

      await screen.findByText("Alpha bug");

      const confirm = screen.getByLabelText(
        "confirm ticket selection",
      ) as HTMLButtonElement;
      // Empty selection ⇒ confirm disabled, count reads 0.
      expect(confirm.disabled).toBe(true);
      expect(screen.getByText("0 sélectionné")).toBeTruthy();

      // Select two rows; the picker stays open and does not fire onSelect yet.
      fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
      fireEvent.click(screen.getByLabelText(`select ticket ${c.ref}`));
      expect(onSelect).not.toHaveBeenCalled();
      expect(onClose).not.toHaveBeenCalled();

      // Visual state: the toggled rows expose aria-checked.
      expect(
        screen.getByLabelText(`select ticket ${a.ref}`).getAttribute("aria-checked"),
      ).toBe("true");
      expect(screen.getByText("2 sélectionnés")).toBeTruthy();
      expect(confirm.textContent).toContain("2");
      expect(confirm.disabled).toBe(false);

      // Toggle one back off ⇒ count drops, still open.
      fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
      expect(screen.getByText("1 sélectionné")).toBeTruthy();
      expect(
        screen.getByLabelText(`select ticket ${a.ref}`).getAttribute("aria-checked"),
      ).toBe("false");

      // Re-select and confirm ⇒ onSelect receives the resolved array, then close.
      fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
      fireEvent.click(confirm);

      await waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
      const results = onSelect.mock.calls[0][0] as TicketPickerResult[];
      expect(Array.isArray(results)).toBe(true);
      expect(results.map((r) => r.ref).sort()).toEqual([a.ref, c.ref].sort());
      expect(results).toContainEqual(
        expect.objectContaining({ ref: a.ref, id: a.id, number: a.number }),
      );
      expect(onClose).toHaveBeenCalledTimes(1);
    });

    it("confirm stays disabled when selection is empty", async () => {
      await seedTrio(ticket);
      const { onSelect } = renderPicker(ticket, { selectionMode: "multi" });
      await screen.findByText("Alpha bug");

      const confirm = screen.getByLabelText(
        "confirm ticket selection",
      ) as HTMLButtonElement;
      expect(confirm.disabled).toBe(true);
      // Clicking a disabled button is a no-op.
      fireEvent.click(confirm);
      expect(onSelect).not.toHaveBeenCalled();
    });

    it("respects excludeRefs and facet filters in multi mode", async () => {
      const [a, b] = await seedTrio(ticket);
      renderPicker(ticket, {
        selectionMode: "multi",
        excludeRefs: [a.ref],
      });

      // Excluded ref is not selectable.
      await screen.findByText("Bravo task");
      expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull();
      expect(screen.getByLabelText(`select ticket ${b.ref}`)).toBeTruthy();

      // Facets still narrow the list (Open ⇒ only Alpha, which is excluded ⇒ none).
      fireEvent.click(screen.getByLabelText("filter status Open"));
      await waitFor(() =>
        expect(screen.getByText("No matching tickets.")).toBeTruthy(),
      );
    });

    it("closes on Escape and backdrop without confirming", async () => {
      const [a] = await seedTrio(ticket);
      const { onSelect, onClose } = renderPicker(ticket, {
        selectionMode: "multi",
      });
      await screen.findByText("Alpha bug");
      fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));

      fireEvent.keyDown(document, { key: "Escape" });
      expect(onClose).toHaveBeenCalledTimes(1);
      expect(onSelect).not.toHaveBeenCalled();

      const backdrop = screen.getByRole("dialog").parentElement as HTMLElement;
      fireEvent.mouseDown(backdrop);
      expect(onClose).toHaveBeenCalledTimes(2);
      expect(onSelect).not.toHaveBeenCalled();
    });
  });
});
