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

import { DIProvider } from "@/app/di";
import {
  MockAgentGateway,
  MockSystemGateway,
  MockTicketGateway,
} from "@/adapters/mock";
import type { Agent } from "@/domain";
import type { Gateways } from "@/ports";
import { TicketsView } from "./TicketsView";
import { TicketDetail } from "./TicketDetail";

const PROJECT_ID = "project-tickets-test";

function stubClipboard() {
  const writeText = vi.fn().mockResolvedValue(undefined);
  Object.assign(navigator, { clipboard: { writeText } });
  return writeText;
}

async function seedAgent(agent: MockAgentGateway, name: string): Promise<Agent> {
  return agent.createAgent(PROJECT_ID, { name, profileId: "p-1" });
}

function renderView(
  ticket: MockTicketGateway,
  system: MockSystemGateway,
  agent: MockAgentGateway,
) {
  const gateways = { ticket, system, agent } as unknown as Gateways;
  return render(
    <DIProvider gateways={gateways}>
      <TicketsView projectId={PROJECT_ID} />
    </DIProvider>,
  );
}

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

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

  it("assigns sequential #refs and emits issueCreated", async () => {
    const events: string[] = [];
    await system.onDomainEvent((e) => events.push(e.type));

    const a = await ticket.create(PROJECT_ID, { title: "First" });
    const b = await ticket.create(PROJECT_ID, { title: "Second" });

    expect(a.ref).toBe("#1");
    expect(b.ref).toBe("#2");
    expect(b.version).toBe(1);
    expect(events).toContain("issueCreated");
  });

  it("filters by status and searches text", async () => {
    await ticket.create(PROJECT_ID, { title: "alpha bug", status: "open" });
    const t2 = await ticket.create(PROJECT_ID, { title: "beta task" });
    await ticket.update(PROJECT_ID, t2.ref, {
      status: "closed",
      expectedVersion: t2.version,
    });

    const open = await ticket.list(PROJECT_ID, { status: "open" });
    expect(open.items.map((i) => i.ref)).toEqual(["#1"]);

    const search = await ticket.list(PROJECT_ID, { text: "beta" });
    expect(search.items.map((i) => i.ref)).toEqual(["#2"]);
  });

  it("rejects a stale write with a version conflict", async () => {
    const t = await ticket.create(PROJECT_ID, { title: "x" });
    await ticket.update(PROJECT_ID, t.ref, {
      title: "y",
      expectedVersion: t.version,
    });

    await expect(
      ticket.update(PROJECT_ID, t.ref, {
        title: "z",
        expectedVersion: t.version, // stale (1, now 2)
      }),
    ).rejects.toMatchObject({ message: expect.stringContaining("version conflict") });
  });

  it("links and unlinks tickets, bumping the version", async () => {
    const a = await ticket.create(PROJECT_ID, { title: "a" });
    await ticket.create(PROJECT_ID, { title: "b" });
    const linked = await ticket.link(PROJECT_ID, a.ref, "#2", "blocks", a.version);
    expect(linked.links).toEqual([{ targetRef: "#2", kind: "blocks" }]);
    const unlinked = await ticket.unlink(
      PROJECT_ID,
      a.ref,
      "#2",
      linked.version,
      "blocks",
    );
    expect(unlinked.links).toEqual([]);
  });
});

describe("TicketsView", () => {
  let system: MockSystemGateway;
  let ticket: MockTicketGateway;
  let agent: MockAgentGateway;

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

  it("shows the empty state then a created ticket", async () => {
    renderView(ticket, system, agent);
    expect(await screen.findByText("No tickets.")).toBeTruthy();

    await ticket.create(PROJECT_ID, { title: "Live created" });
    // The panel refreshes on the issueCreated event.
    expect(await screen.findByText("Live created")).toBeTruthy();
  });

  it("opens the detail and edits status with a version bump", async () => {
    const t = await ticket.create(PROJECT_ID, { title: "Editable" });
    renderView(ticket, system, agent);

    fireEvent.click(await screen.findByText("Editable"));
    const dialog = await screen.findByRole("dialog");

    const statusSelect = within(dialog).getByLabelText("ticket status");
    fireEvent.change(statusSelect, { target: { value: "inProgress" } });

    await waitFor(async () => {
      const fresh = await ticket.read(PROJECT_ID, t.ref);
      expect(fresh.status).toBe("inProgress");
      expect(fresh.version).toBe(2);
    });
  });

  it("saves the ticket-scoped carnet (replaceable)", async () => {
    const t = await ticket.create(PROJECT_ID, { title: "WithCarnet" });
    renderView(ticket, system, agent);

    fireEvent.click(await screen.findByText("WithCarnet"));
    const dialog = await screen.findByRole("dialog");

    fireEvent.change(within(dialog).getByLabelText("ticket carnet"), {
      target: { value: "## notes\nscoped body" },
    });
    fireEvent.click(within(dialog).getByText("Save carnet"));

    await waitFor(async () => {
      const carnet = await ticket.readCarnet(PROJECT_ID, t.ref);
      expect(carnet.carnet).toBe("## notes\nscoped body");
    });
  });

  it("assigns only known project agents", async () => {
    const known = await seedAgent(agent, "Backend");
    const t = await ticket.create(PROJECT_ID, { title: "Assignable" });
    renderView(ticket, system, agent);

    fireEvent.click(await screen.findByText("Assignable"));
    const dialog = await screen.findByRole("dialog");

    fireEvent.change(within(dialog).getByLabelText("assign agent"), {
      target: { value: known.id },
    });
    fireEvent.click(within(dialog).getByText("Assign"));

    await waitFor(async () => {
      const fresh = await ticket.read(PROJECT_ID, t.ref);
      expect(fresh.assignedAgentIds).toEqual([known.id]);
    });
    expect(within(dialog).getAllByText("Backend").length).toBeGreaterThan(0);
  });

  it("surfaces a version-conflict banner and reloads (F3)", async () => {
    // No system gateway ⇒ the detail does not auto-refresh on events, so we can
    // deterministically make its held version go stale under an external write.
    const t = await ticket.create(PROJECT_ID, { title: "Racy" });
    const gateways = { ticket, agent } as unknown as Gateways;
    render(
      <DIProvider gateways={gateways}>
        <TicketDetail
          projectId={PROJECT_ID}
          ticketRef={t.ref}
          onClose={() => {}}
          onOpenRef={() => {}}
        />
      </DIProvider>,
    );

    const dialog = await screen.findByRole("dialog");
    // The detail now holds version 1. Bump the store to version 2 behind its back.
    await ticket.update(PROJECT_ID, t.ref, {
      description: "external",
      expectedVersion: t.version,
    });

    fireEvent.change(within(dialog).getByLabelText("Title"), {
      target: { value: "Racy edited" },
    });
    fireEvent.click(within(dialog).getByText("Save changes"));

    expect(
      await within(dialog).findByText(/modified elsewhere and reloaded/i),
    ).toBeTruthy();
    // After the reload the detail field reflects the reloaded (title unchanged)
    // ticket rather than the abandoned local edit.
    await waitFor(() =>
      expect(
        (within(dialog).getByLabelText("Title") as HTMLInputElement)
          .value,
      ).toBe("Racy"),
    );
  });

  it("copies a delegation context prompt (F7)", async () => {
    const writeText = stubClipboard();
    const t = await ticket.create(PROJECT_ID, { title: "Delegate me" });
    renderView(ticket, system, agent);

    fireEvent.click(await screen.findByText("Delegate me"));
    const dialog = await screen.findByRole("dialog");
    fireEvent.click(within(dialog).getByText("Delegation context"));

    await waitFor(() =>
      expect(writeText).toHaveBeenCalledWith(
        `Travaille sur le ticket ${t.ref} : Delegate me`,
      ),
    );
  });
});
