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,
  MockProfileGateway,
  MockSystemGateway,
  MockTicketGateway,
} from "@/adapters/mock";
import type { Agent, DomainEvent } 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>,
  );
}

async function seedAssistantProfile(profile: MockProfileGateway) {
  return profile.saveProfile({
    id: "qa-assistant",
    name: "QA Assistant",
    command: "codex",
    args: [],
    contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
    detect: null,
    cwdTemplate: "{projectRoot}",
  });
}

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 statuses (OR within facet) and searches text (#12)", 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, { statuses: ["open"] });
    expect(open.items.map((i) => i.ref)).toEqual(["#1"]);

    // OR within the status facet: both values pass.
    const either = await ticket.list(PROJECT_ID, {
      statuses: ["open", "closed"],
    });
    expect(either.items.map((i) => i.ref).sort()).toEqual(["#1", "#2"]);

    // An empty set ⇒ no status constraint (all pass).
    const all = await ticket.list(PROJECT_ID, { statuses: [] });
    expect(all.items).toHaveLength(2);

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

  it("filters by creator and exposes createdBy on summaries (#109)", async () => {
    await ticket.create(PROJECT_ID, { title: "User ticket" });
    const agentTicket = await ticket.create(PROJECT_ID, { title: "Agent ticket" });
    ticket._seedTicket(PROJECT_ID, {
      ...agentTicket,
      createdBy: { kind: "agent", agentId: "agent-1" },
    });

    const byUser = await ticket.list(PROJECT_ID, { createdBy: { kind: "user" } });
    expect(byUser.items.map((i) => i.title)).toEqual(["User ticket"]);
    expect(byUser.items[0]?.createdBy).toEqual({ kind: "user" });

    const byAgent = await ticket.list(PROJECT_ID, {
      createdBy: { kind: "agent", agentId: "agent-1" },
    });
    expect(byAgent.items.map((i) => i.title)).toEqual(["Agent ticket"]);
    expect(byAgent.items[0]?.createdBy).toEqual({
      kind: "agent",
      agentId: "agent-1",
    });
  });

  it("combines status and priority facets with AND (#12)", async () => {
    await ticket.create(PROJECT_ID, {
      title: "a",
      status: "open",
      priority: "high",
    });
    await ticket.create(PROJECT_ID, {
      title: "b",
      status: "open",
      priority: "low",
    });
    await ticket.create(PROJECT_ID, {
      title: "c",
      status: "closed",
      priority: "high",
    });

    // statuses:[open] AND priorities:[high] ⇒ only #1.
    const both = await ticket.list(PROJECT_ID, {
      statuses: ["open"],
      priorities: ["high"],
    });
    expect(both.items.map((i) => i.ref)).toEqual(["#1"]);

    // OR within status (open|closed) AND priorities:[high] ⇒ #1 and #3.
    const mix = await ticket.list(PROJECT_ID, {
      statuses: ["open", "closed"],
      priorities: ["high"],
    });
    expect(mix.items.map((i) => i.ref).sort()).toEqual(["#1", "#3"]);
  });

  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("adds ticket attachments and marks them summarized (#108)", async () => {
    const t = await ticket.create(PROJECT_ID, { title: "With attachment" });

    const attached = await ticket.addAttachment(
      PROJECT_ID,
      t.ref,
      "/tmp/spec.pdf",
      t.version,
      "application/pdf",
    );
    expect(attached.version).toBe(2);
    expect(attached.attachments).toMatchObject([
      {
        filename: "spec.pdf",
        mime: "application/pdf",
        summarizedInCarnet: false,
      },
    ]);

    const content = await ticket.readAttachment(
      PROJECT_ID,
      t.ref,
      attached.attachments[0].id,
    );
    expect(content.attachment.filename).toBe("spec.pdf");
    expect(content.contentBase64).toBeTruthy();

    const summarized = await ticket.markAttachmentSummarized(
      PROJECT_ID,
      t.ref,
      attached.attachments[0].id,
      attached.version,
    );
    expect(summarized.version).toBe(3);
    expect(summarized.attachments[0]).toMatchObject({
      summarizedInCarnet: true,
      summarizedBy: { kind: "user" },
    });
  });

  it("deletes a ticket, removing it and emitting issueDeleted with freedSprint (#6)", async () => {
    ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "S" });
    const t = await ticket.create(PROJECT_ID, { title: "Doomed" });
    await ticket.setTicketSprint(PROJECT_ID, t.ref, "s1", t.version);
    const events: DomainEvent[] = [];
    await system.onDomainEvent((e) => events.push(e));

    await ticket.delete(PROJECT_ID, t.ref);

    // Removed from the store (NotFound afterwards)…
    await expect(ticket.read(PROJECT_ID, t.ref)).rejects.toMatchObject({
      code: "NOT_FOUND",
    });
    // …and the sprint it was released from is reported on the event.
    expect(events).toContainEqual({
      type: "issueDeleted",
      projectId: PROJECT_ID,
      issueRef: t.ref,
      freedSprint: "s1",
    });
  });

  it("rejects deleting an unknown ticket with NotFound (#6)", async () => {
    await expect(ticket.delete(PROJECT_ID, "#404")).rejects.toMatchObject({
      code: "NOT_FOUND",
    });
  });

  it("bulk updates status and reports partial failures in request order", async () => {
    const a = await ticket.create(PROJECT_ID, { title: "A" });
    const b = await ticket.create(PROJECT_ID, { title: "B" });

    const out = await ticket.bulkUpdateStatus(
      PROJECT_ID,
      [a.ref, "#404", b.ref],
      "QA",
    );

    expect(out.items.map((item) => [item.ref, item.ok])).toEqual([
      [a.ref, true],
      ["#404", false],
      [b.ref, true],
    ]);
    expect((await ticket.read(PROJECT_ID, a.ref)).status).toBe("QA");
    expect((await ticket.read(PROJECT_ID, b.ref)).status).toBe("QA");
  });

  it("bulk deletes existing tickets and keeps missing refs as item failures", async () => {
    const a = await ticket.create(PROJECT_ID, { title: "A" });
    const b = await ticket.create(PROJECT_ID, { title: "B" });

    const out = await ticket.bulkDelete(PROJECT_ID, [a.ref, "#404", b.ref]);

    expect(out.items.map((item) => [item.ref, item.ok])).toEqual([
      [a.ref, true],
      ["#404", false],
      [b.ref, true],
    ]);
    await expect(ticket.read(PROJECT_ID, a.ref)).rejects.toMatchObject({
      code: "NOT_FOUND",
    });
    await expect(ticket.read(PROJECT_ID, b.ref)).rejects.toMatchObject({
      code: "NOT_FOUND",
    });
  });

  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("creates a ticket and assigns the sprint chosen via the picker (#38)", async () => {
    ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Alpha" });
    const setSpy = vi.spyOn(ticket, "setTicketSprint");

    renderView(ticket, system, agent);
    fireEvent.click(await screen.findByRole("button", { name: "New" }));
    fireEvent.change(screen.getByLabelText("new ticket title"), {
      target: { value: "With sprint" },
    });

    // Field defaults to "Sans sprint"; open the picker and pick Alpha.
    const sprintField = screen.getByLabelText("choose sprint for new ticket");
    expect(sprintField.textContent).toContain("Sans sprint");
    fireEvent.click(sprintField);
    fireEvent.click(await screen.findByLabelText("select sprint Alpha"));
    expect(
      screen.getByLabelText("choose sprint for new ticket").textContent,
    ).toContain("Alpha");

    fireEvent.click(screen.getByRole("button", { name: "Create" }));

    // create → setTicketSprint(projectId, ref, "s1", version).
    await waitFor(() => expect(setSpy).toHaveBeenCalledTimes(1));
    const call = setSpy.mock.calls[0];
    expect(call[0]).toBe(PROJECT_ID);
    expect(call[2]).toBe("s1");
    await waitFor(async () => {
      const fresh = await ticket.read(PROJECT_ID, call[1]);
      expect(fresh.sprintId).toBe("s1");
    });
  });

  it("creates a ticket without a sprint — setTicketSprint is not called (#38)", async () => {
    const setSpy = vi.spyOn(ticket, "setTicketSprint");

    renderView(ticket, system, agent);
    fireEvent.click(await screen.findByRole("button", { name: "New" }));
    fireEvent.change(screen.getByLabelText("new ticket title"), {
      target: { value: "No sprint" },
    });
    fireEvent.click(screen.getByRole("button", { name: "Create" }));

    await waitFor(async () => {
      const list = await ticket.list(PROJECT_ID);
      expect(list.items.map((i) => i.title)).toContain("No sprint");
    });
    expect(setSpy).not.toHaveBeenCalled();
  });

  it("keeps the created ticket and surfaces the error when sprint assignment fails (#38)", async () => {
    ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Alpha" });
    vi.spyOn(ticket, "setTicketSprint").mockRejectedValueOnce({
      code: "INTERNAL",
      message: "assign boom",
    });

    renderView(ticket, system, agent);
    fireEvent.click(await screen.findByRole("button", { name: "New" }));
    fireEvent.change(screen.getByLabelText("new ticket title"), {
      target: { value: "Kept anyway" },
    });
    fireEvent.click(screen.getByLabelText("choose sprint for new ticket"));
    fireEvent.click(await screen.findByLabelText("select sprint Alpha"));
    fireEvent.click(screen.getByRole("button", { name: "Create" }));

    // The ticket was created despite the failed assignment (no data lost)…
    await waitFor(async () => {
      const list = await ticket.list(PROJECT_ID);
      expect(list.items.map((i) => i.title)).toContain("Kept anyway");
    });
    // …and the assignment failure is surfaced.
    expect(await screen.findByText(/assign boom/)).toBeTruthy();
  });

  it("multi-selects status/priority facets (OR intra, AND inter) and clears (#12)", async () => {
    const listSpy = vi.spyOn(ticket, "list");
    await ticket.create(PROJECT_ID, {
      title: "Alpha",
      status: "open",
      priority: "high",
    });
    await ticket.create(PROJECT_ID, {
      title: "Bravo",
      status: "closed",
      priority: "low",
    });
    await ticket.create(PROJECT_ID, {
      title: "Charlie",
      status: "QA",
      priority: "high",
    });

    renderView(ticket, system, agent);

    // All three visible initially (no facet ⇒ all pass).
    await screen.findByText("Alpha");
    expect(screen.getByText("Bravo")).toBeTruthy();
    expect(screen.getByText("Charlie")).toBeTruthy();

    // Tick two statuses → OR within the facet ⇒ open|closed ⇒ Alpha + Bravo,
    // Charlie (QA) drops out.
    fireEvent.click(screen.getByLabelText("filter status Open"));
    fireEvent.click(screen.getByLabelText("filter status Closed"));
    await waitFor(() => expect(screen.queryByText("Charlie")).toBeNull());
    expect(screen.getByText("Alpha")).toBeTruthy();
    expect(screen.getByText("Bravo")).toBeTruthy();

    // Add a priority → AND across facets ⇒ (open|closed) AND high ⇒ only Alpha.
    fireEvent.click(screen.getByLabelText("filter priority High"));
    await waitFor(() => expect(screen.queryByText("Bravo")).toBeNull());
    expect(screen.getByText("Alpha")).toBeTruthy();

    // The gateway received the expected multi-select query.
    await waitFor(() =>
      expect(listSpy.mock.calls.at(-1)?.[1]).toMatchObject({
        statuses: ["open", "closed"],
        priorities: ["high"],
      }),
    );

    // « Effacer » empties both facets ⇒ all three back.
    fireEvent.click(screen.getByLabelText("clear filters"));
    await waitFor(() => expect(screen.getByText("Charlie")).toBeTruthy());
    expect(screen.getByText("Bravo")).toBeTruthy();
    expect(screen.getByText("Alpha")).toBeTruthy();
  });

  it("sorts via the « Trier par… » control: relays sort + toggles direction (#21)", async () => {
    const listSpy = vi.spyOn(ticket, "list");
    await ticket.create(PROJECT_ID, { title: "Alpha" });
    await ticket.create(PROJECT_ID, { title: "Bravo" });

    renderView(ticket, system, agent);
    await screen.findByText("Alpha");

    // No sort chosen ⇒ the gateway sees no `sort` (historical order preserved).
    expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined();

    // Choose a field ⇒ ascending by default, relayed in the query.
    fireEvent.click(screen.getByLabelText("sort tickets by"));
    fireEvent.click(await screen.findByRole("option", { name: "Titre" }));
    await waitFor(() =>
      expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
        field: "title",
        direction: "asc",
      }),
    );

    // The direction toggle flips asc → desc.
    fireEvent.click(
      screen.getByLabelText("sort direction ascending"),
    );
    await waitFor(() =>
      expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
        field: "title",
        direction: "desc",
      }),
    );

    // Back to « Par défaut » ⇒ `sort` dropped from the query again.
    fireEvent.click(screen.getByLabelText("sort tickets by"));
    fireEvent.click(await screen.findByRole("option", { name: "Par défaut" }));
    await waitFor(() =>
      expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined(),
    );
  });

  it("shows ticket creators and filters the list by creator (#109)", async () => {
    const creator = await seedAgent(agent, "CreatorAgent");
    await ticket.create(PROJECT_ID, { title: "Created by user" });
    const agentTicket = await ticket.create(PROJECT_ID, {
      title: "Created by agent",
    });
    ticket._seedTicket(PROJECT_ID, {
      ...agentTicket,
      createdBy: { kind: "agent", agentId: creator.id },
    });
    const listSpy = vi.spyOn(ticket, "list");

    renderView(ticket, system, agent);

    await screen.findByText("Created by user");
    expect(screen.getByText("Créé par : Utilisateur")).toBeTruthy();
    expect(screen.getByText("Créé par : Agent : CreatorAgent")).toBeTruthy();

    fireEvent.click(screen.getByLabelText("filter by creator"));
    fireEvent.click(await screen.findByRole("option", { name: "CreatorAgent" }));

    await waitFor(() =>
      expect(listSpy.mock.calls.at(-1)?.[1]?.createdBy).toEqual({
        kind: "agent",
        agentId: creator.id,
      }),
    );
    await waitFor(() => expect(screen.queryByText("Created by user")).toBeNull());
    expect(screen.getByText("Created by agent")).toBeTruthy();

    fireEvent.click(screen.getByText("Created by agent"));
    const dialog = await screen.findByRole("dialog", {
      name: `ticket ${agentTicket.ref}`,
    });
    expect(
      within(dialog).getByText("Créé par : Agent : CreatorAgent"),
    ).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");

    fireEvent.click(within(dialog).getByLabelText("ticket status"));
    fireEvent.click(await screen.findByRole("option", { name: "In progress" }));

    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("shows, adds, and marks ticket attachments from the detail (#108)", async () => {
    const t = await ticket.create(PROJECT_ID, { title: "Attachable" });
    renderView(ticket, system, agent);

    fireEvent.click(await screen.findByText("Attachable"));
    const dialog = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });

    expect(within(dialog).getByText("Pièces jointes")).toBeTruthy();
    expect(within(dialog).getByText("Aucune pièce jointe.")).toBeTruthy();

    fireEvent.click(within(dialog).getByText("Joindre"));

    expect(await within(dialog).findByText("mock-attachment.txt")).toBeTruthy();
    expect(within(dialog).getByText(/text\/plain/)).toBeTruthy();
    expect(within(dialog).getByText("Résumé : non")).toBeTruthy();

    const attached = await ticket.read(PROJECT_ID, t.ref);
    expect(attached.attachments).toHaveLength(1);

    fireEvent.click(
      within(dialog).getByLabelText("marquer résumé mock-attachment.txt"),
    );

    expect(await within(dialog).findByText("Résumé : oui")).toBeTruthy();
    await waitFor(async () => {
      const fresh = await ticket.read(PROJECT_ID, t.ref);
      expect(fresh.attachments[0].summarizedInCarnet).toBe(true);
    });
  });

  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.click(within(dialog).getByLabelText("assign agent"));
    fireEvent.click(await screen.findByRole("option", { name: "Backend" }));
    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("preserves an in-progress description edit across a priority change (#9)", async () => {
    const t = await ticket.create(PROJECT_ID, { title: "Persisted" });
    renderView(ticket, system, agent);

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

    // Start editing the description WITHOUT saving.
    const desc = within(dialog).getByLabelText("Description") as HTMLTextAreaElement;
    fireEvent.change(desc, { target: { value: "draft in progress" } });
    // Also edit the title to cover both draft fields.
    const titleInput = within(dialog).getByLabelText("Title") as HTMLInputElement;
    fireEvent.change(titleInput, { target: { value: "Persisted (wip)" } });

    // Change the priority — an immediate-apply mutation that re-fetches the ticket
    // and bumps its version. The unsaved draft must survive.
    fireEvent.click(within(dialog).getByLabelText("ticket priority"));
    fireEvent.click(await screen.findByRole("option", { name: "High" }));

    // The backend applied the priority bump…
    await waitFor(async () => {
      const fresh = await ticket.read(PROJECT_ID, t.ref);
      expect(fresh.priority).toBe("high");
      expect(fresh.version).toBe(2);
    });

    // …and the in-progress edits are still there (not reverted).
    expect(
      (within(dialog).getByLabelText("Description") as HTMLTextAreaElement).value,
    ).toBe("draft in progress");
    expect(
      (within(dialog).getByLabelText("Title") as HTMLInputElement).value,
    ).toBe("Persisted (wip)");
    // The description was never persisted.
    const persisted = await ticket.read(PROJECT_ID, t.ref);
    expect(persisted.description).toBe("");
  });

  it("prompts before closing with unsaved changes, and closes directly when clean (#9)", async () => {
    const t = await ticket.create(PROJECT_ID, { title: "CloseGuard" });
    const onClose = vi.fn();
    const gateways = { ticket, system, agent } as unknown as Gateways;
    render(
      <DIProvider gateways={gateways}>
        <TicketDetail
          projectId={PROJECT_ID}
          ticketRef={t.ref}
          onClose={onClose}
          onOpenRef={() => {}}
        />
      </DIProvider>,
    );

    const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });

    // Clean state → Close fires onClose directly, no confirmation dialog.
    fireEvent.click(within(detail).getByText("Close"));
    expect(onClose).toHaveBeenCalledTimes(1);
    expect(screen.queryByRole("dialog", { name: "unsaved changes" })).toBeNull();

    // Now make the draft dirty and try to close again.
    fireEvent.change(within(detail).getByLabelText("Description"), {
      target: { value: "unsaved body" },
    });
    fireEvent.click(within(detail).getByText("Close"));

    // The confirmation appears and onClose was NOT called again.
    const confirm = await screen.findByRole("dialog", { name: "unsaved changes" });
    expect(onClose).toHaveBeenCalledTimes(1);

    // "Annuler" dismisses the popup and keeps the editor open.
    fireEvent.click(within(confirm).getByLabelText("cancel close"));
    await waitFor(() =>
      expect(screen.queryByRole("dialog", { name: "unsaved changes" })).toBeNull(),
    );
    expect(onClose).toHaveBeenCalledTimes(1);

    // Re-open the confirmation and choose "Fermer sans sauvegarder".
    fireEvent.click(within(detail).getByText("Close"));
    const confirm2 = await screen.findByRole("dialog", { name: "unsaved changes" });
    fireEvent.click(within(confirm2).getByLabelText("close without saving"));
    expect(onClose).toHaveBeenCalledTimes(2);
    // The unsaved description was discarded (never persisted).
    const fresh = await ticket.read(PROJECT_ID, t.ref);
    expect(fresh.description).toBe("");
  });

  it("saves unsaved changes then closes when choosing « Sauvegarder » (#9)", async () => {
    const t = await ticket.create(PROJECT_ID, { title: "SaveOnClose" });
    const onClose = vi.fn();
    const gateways = { ticket, system, agent } as unknown as Gateways;
    render(
      <DIProvider gateways={gateways}>
        <TicketDetail
          projectId={PROJECT_ID}
          ticketRef={t.ref}
          onClose={onClose}
          onOpenRef={() => {}}
        />
      </DIProvider>,
    );

    const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
    fireEvent.change(within(detail).getByLabelText("Description"), {
      target: { value: "final body" },
    });
    fireEvent.click(within(detail).getByText("Close"));

    const confirm = await screen.findByRole("dialog", { name: "unsaved changes" });
    fireEvent.click(within(confirm).getByLabelText("save and close"));

    // The change is persisted and the panel closes.
    await waitFor(async () => {
      const fresh = await ticket.read(PROJECT_ID, t.ref);
      expect(fresh.description).toBe("final body");
    });
    await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
  });

  it("groups tickets by sprint with a « Sans sprint » bucket (#10)", async () => {
    // Two ordered sprints + one ticket in each + one loose ticket.
    ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Sprint One" });
    ticket._seedSprint(PROJECT_ID, { id: "s2", order: 2, name: "Sprint Two" });
    const t1 = await ticket.create(PROJECT_ID, { title: "In one" });
    const t2 = await ticket.create(PROJECT_ID, { title: "In two" });
    await ticket.create(PROJECT_ID, { title: "Loose" });
    await ticket.setTicketSprint(PROJECT_ID, t1.ref, "s1", t1.version);
    await ticket.setTicketSprint(PROJECT_ID, t2.ref, "s2", t2.version);

    renderView(ticket, system, agent);

    // Sections appear in sprint `order`, then the "Sans sprint" bucket last.
    await screen.findByRole("region", { name: "sprint section Sprint One" });
    const sections = screen
      .getAllByRole("region")
      .map((r) => r.getAttribute("aria-label"))
      .filter((l) => l?.startsWith("sprint section"));
    expect(sections).toEqual([
      "sprint section Sprint One",
      "sprint section Sprint Two",
      "sprint section Sans sprint",
    ]);

    // The loose ticket lives in the bucket, the others under their sprint.
    const bucket = screen.getByRole("region", { name: "sprint section Sans sprint" });
    expect(within(bucket).getByText("Loose")).toBeTruthy();
    const one = screen.getByRole("region", { name: "sprint section Sprint One" });
    expect(within(one).getByText("In one")).toBeTruthy();
  });

  it("assigns a ticket to a sprint via the row selector (#10)", async () => {
    ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Sprint One" });
    const t = await ticket.create(PROJECT_ID, { title: "Movable" });

    renderView(ticket, system, agent);

    // It starts in the "Sans sprint" bucket.
    const bucket = await screen.findByRole("region", {
      name: "sprint section Sans sprint",
    });
    expect(within(bucket).getByText("Movable")).toBeTruthy();

    // Pick the sprint in the row selector → assign it.
    fireEvent.click(screen.getByLabelText(`sprint for ${t.ref}`));
    fireEvent.click(await screen.findByRole("option", { name: "Sprint One" }));

    // The gateway recorded the membership…
    await waitFor(async () => {
      const fresh = await ticket.read(PROJECT_ID, t.ref);
      expect(fresh.sprintId).toBe("s1");
    });
    // …and the UI regrouped it under Sprint One, emptying the bucket.
    await waitFor(() => {
      const one = screen.getByRole("region", {
        name: "sprint section Sprint One",
      });
      expect(within(one).getByText("Movable")).toBeTruthy();
    });
    expect(
      screen.queryByRole("region", { name: "sprint section Sans sprint" }),
    ).toBeNull();
  });

  it("deletes a ticket from the detail: confirmation ⇒ row gone + detail closed (#6)", async () => {
    const t = await ticket.create(PROJECT_ID, { title: "Deletable" });
    renderView(ticket, system, agent);

    fireEvent.click(await screen.findByText("Deletable"));
    const detail = await screen.findByRole("dialog", {
      name: `ticket ${t.ref}`,
    });

    // Open the confirmation, then confirm the deletion.
    fireEvent.click(within(detail).getByText("Supprimer"));
    const confirm = await screen.findByRole("dialog", {
      name: "confirmer la suppression",
    });
    fireEvent.click(within(confirm).getByLabelText("confirm delete"));

    // The gateway removed the ticket…
    await waitFor(() =>
      expect(ticket.read(PROJECT_ID, t.ref)).rejects.toMatchObject({
        code: "NOT_FOUND",
      }),
    );
    // …the detail closed (via the `issueDeleted` event → `deleted` flag)…
    await waitFor(() =>
      expect(
        screen.queryByRole("dialog", { name: `ticket ${t.ref}` }),
      ).toBeNull(),
    );
    // …and the row disappeared from the list (event-driven refresh).
    await waitFor(() => expect(screen.queryByText("Deletable")).toBeNull());
    expect(await screen.findByText("No tickets.")).toBeTruthy();
  });

  it("selects several rows and changes their status from the bulk action bar", async () => {
    const a = await ticket.create(PROJECT_ID, { title: "Bulk A" });
    const b = await ticket.create(PROJECT_ID, { title: "Bulk B" });
    await ticket.create(PROJECT_ID, { title: "Unselected" });
    const bulkSpy = vi.spyOn(ticket, "bulkUpdateStatus");

    renderView(ticket, system, agent);

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

    expect(screen.getByRole("toolbar", { name: "bulk ticket actions" })).toBeTruthy();
    fireEvent.click(screen.getByLabelText("Changer le statut"));
    fireEvent.click(await screen.findByRole("option", { name: "QA" }));

    await waitFor(() =>
      expect(bulkSpy).toHaveBeenCalledWith(PROJECT_ID, [a.ref, b.ref], "QA"),
    );
    await waitFor(async () => {
      expect((await ticket.read(PROJECT_ID, a.ref)).status).toBe("QA");
      expect((await ticket.read(PROJECT_ID, b.ref)).status).toBe("QA");
    });
    expect(screen.queryByRole("toolbar", { name: "bulk ticket actions" })).toBeNull();
  });

  it("selects several rows and assigns their sprint from the bulk action bar (#112)", async () => {
    ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
    const a = await ticket.create(PROJECT_ID, { title: "Sprint Bulk A" });
    const b = await ticket.create(PROJECT_ID, { title: "Sprint Bulk B" });
    await ticket.create(PROJECT_ID, { title: "Unselected sprint item" });
    const setSpy = vi.spyOn(ticket, "setTicketSprint");

    renderView(ticket, system, agent);

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

    const toolbar = screen.getByRole("toolbar", { name: "bulk ticket actions" });
    fireEvent.click(within(toolbar).getByRole("button", { name: "Sprint" }));
    fireEvent.click(await screen.findByLabelText("select sprint Target"));

    await waitFor(() => {
      expect(setSpy).toHaveBeenCalledWith(PROJECT_ID, a.ref, "s1", a.version);
      expect(setSpy).toHaveBeenCalledWith(PROJECT_ID, b.ref, "s1", b.version);
    });
    await waitFor(async () => {
      expect((await ticket.read(PROJECT_ID, a.ref)).sprintId).toBe("s1");
      expect((await ticket.read(PROJECT_ID, b.ref)).sprintId).toBe("s1");
    });
    expect(screen.getByRole("status").textContent).toMatch(
      /Sprint mis à jour pour 2 tickets/i,
    );
    expect(screen.queryByRole("toolbar", { name: "bulk ticket actions" })).toBeNull();
  });

  it("keeps failed refs selected after a partial bulk sprint assignment (#112)", async () => {
    ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
    const a = await ticket.create(PROJECT_ID, { title: "Sprint Partial A" });
    const b = await ticket.create(PROJECT_ID, { title: "Sprint Partial B" });
    const originalSetTicketSprint = ticket.setTicketSprint.bind(ticket);
    vi.spyOn(ticket, "setTicketSprint").mockImplementation(
      async (projectId, ref, sprintId, expectedVersion) => {
        if (ref === b.ref) {
          throw { code: "CONFLICT", message: "version conflict" };
        }
        return originalSetTicketSprint(projectId, ref, sprintId, expectedVersion);
      },
    );

    renderView(ticket, system, agent);

    fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
    fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
    const toolbar = screen.getByRole("toolbar", { name: "bulk ticket actions" });
    fireEvent.click(within(toolbar).getByRole("button", { name: "Sprint" }));
    fireEvent.click(await screen.findByLabelText("select sprint Target"));

    await waitFor(async () => {
      expect((await ticket.read(PROJECT_ID, a.ref)).sprintId).toBe("s1");
      expect((await ticket.read(PROJECT_ID, b.ref)).sprintId).toBeNull();
    });
    expect((await screen.findByRole("alert")).textContent).toContain(
      `1 ticket(s) non modifié(s): ${b.ref}`,
    );
    expect(screen.getByRole("status").textContent).toMatch(/1\/2 tickets/i);
    expect(
      (screen.getByLabelText(`select ticket row ${b.ref}`) as HTMLInputElement)
        .checked,
    ).toBe(true);
    expect(
      (screen.getByLabelText(`select ticket row ${a.ref}`) as HTMLInputElement)
        .checked,
    ).toBe(false);
  });

  it("selects several rows and deletes them after bulk confirmation", async () => {
    const a = await ticket.create(PROJECT_ID, { title: "Delete A" });
    const b = await ticket.create(PROJECT_ID, { title: "Delete B" });
    await ticket.create(PROJECT_ID, { title: "Keep me" });
    const bulkSpy = vi.spyOn(ticket, "bulkDelete");

    renderView(ticket, system, agent);

    fireEvent.click(await screen.findByLabelText(`select ticket row ${a.ref}`));
    fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`));
    fireEvent.click(screen.getByRole("button", { name: "Supprimer" }));

    const confirm = await screen.findByRole("dialog", {
      name: "confirmer la suppression groupée",
    });
    fireEvent.click(within(confirm).getByLabelText("confirm bulk delete"));

    await waitFor(() =>
      expect(bulkSpy).toHaveBeenCalledWith(PROJECT_ID, [a.ref, b.ref]),
    );
    await waitFor(() => expect(screen.queryByText("Delete A")).toBeNull());
    expect(screen.queryByText("Delete B")).toBeNull();
    expect(screen.getByText("Keep me")).toBeTruthy();
  });

  it("cancelling the delete confirmation keeps the ticket (#6)", async () => {
    const t = await ticket.create(PROJECT_ID, { title: "Keeper" });
    const delSpy = vi.spyOn(ticket, "delete");
    renderView(ticket, system, agent);

    fireEvent.click(await screen.findByText("Keeper"));
    const detail = await screen.findByRole("dialog", {
      name: `ticket ${t.ref}`,
    });

    fireEvent.click(within(detail).getByText("Supprimer"));
    const confirm = await screen.findByRole("dialog", {
      name: "confirmer la suppression",
    });
    fireEvent.click(within(confirm).getByLabelText("cancel delete"));

    // The confirmation is dismissed, delete was never called, ticket stays.
    await waitFor(() =>
      expect(
        screen.queryByRole("dialog", { name: "confirmer la suppression" }),
      ).toBeNull(),
    );
    expect(delSpy).not.toHaveBeenCalled();
    expect(
      screen.getByRole("dialog", { name: `ticket ${t.ref}` }),
    ).toBeTruthy();
    const fresh = await ticket.read(PROJECT_ID, t.ref);
    expect(fresh.title).toBe("Keeper");
  });

  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`,
      ),
    );
  });

  it("opens, streams, and closes the ticket AI assistant chat (#8)", async () => {
    const t = await ticket.create(PROJECT_ID, { title: "Assistant target" });
    const profile = new MockProfileGateway();
    await seedAssistantProfile(profile);
    const gateways = { ticket, system, agent, profile } as unknown as Gateways;

    render(
      <DIProvider gateways={gateways}>
        <TicketDetail
          projectId={PROJECT_ID}
          ticketRef={t.ref}
          onClose={() => {}}
          onOpenRef={() => {}}
        />
      </DIProvider>,
    );

    const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });

    // The assistant is opened from the prominent bottom-right floating button;
    // it is not inline anymore (#17).
    expect(within(detail).queryByLabelText("profil de l'assistant")).toBeNull();
    fireEvent.click(within(detail).getByLabelText("ouvrir l'assistant IA"));

    // It opens in its own nested window.
    const assistant = await screen.findByRole("dialog", {
      name: `Assistant IA — ${t.ref}`,
    });

    const profileSelect = await within(assistant).findByLabelText(
      "profil de l'assistant",
    );
    expect(within(assistant).getByText("Ouvrir la conversation")).toBeTruthy();

    fireEvent.click(profileSelect);
    fireEvent.click(await screen.findByRole("option", { name: "QA Assistant" }));
    fireEvent.click(within(assistant).getByText("Ouvrir la conversation"));

    expect(
      await within(assistant).findByLabelText("message à l'assistant"),
    ).toBeTruthy();

    fireEvent.change(within(assistant).getByLabelText("message à l'assistant"), {
      target: { value: "Crée un plan de correction" },
    });
    fireEvent.click(within(assistant).getByText("Envoyer"));

    expect(
      await within(assistant).findByText("Crée un plan de correction"),
    ).toBeTruthy();
    expect(
      await within(assistant).findByText(
        "Assistant: reçu « Crée un plan de correction ».",
      ),
    ).toBeTruthy();

    fireEvent.click(within(assistant).getByText("Fermer la conversation"));

    await waitFor(() =>
      expect(
        within(assistant).queryByLabelText("message à l'assistant"),
      ).toBeNull(),
    );
    expect(within(assistant).getByText("Ouvrir la conversation")).toBeTruthy();
    expect(
      within(assistant).queryByText("Crée un plan de correction"),
    ).toBeNull();
    expect(
      within(assistant).queryByText(
        "Assistant: reçu « Crée un plan de correction ».",
      ),
    ).toBeNull();
  });

  it("adds a link via the TicketPicker popup instead of a manual #id (#17)", async () => {
    const a = await ticket.create(PROJECT_ID, { title: "Source ticket" });
    const b = await ticket.create(PROJECT_ID, { title: "Target ticket" });
    const gateways = { ticket, system, agent } as unknown as Gateways;

    render(
      <DIProvider gateways={gateways}>
        <TicketDetail
          projectId={PROJECT_ID}
          ticketRef={a.ref}
          onClose={() => {}}
          onOpenRef={() => {}}
        />
      </DIProvider>,
    );

    const detail = await screen.findByRole("dialog", { name: `ticket ${a.ref}` });
    // The old manual "#id" input is gone.
    expect(within(detail).queryByLabelText("link target ref")).toBeNull();

    // Choose the link kind, then open the picker and select the target ticket.
    fireEvent.click(within(detail).getByLabelText("link kind"));
    fireEvent.click(await screen.findByRole("option", { name: "blocks" }));
    fireEvent.click(within(detail).getByLabelText("add link"));

    // The picker excludes the ticket itself; the target is offered.
    expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull();
    fireEvent.click(await screen.findByLabelText(`select ticket ${b.ref}`));

    await waitFor(async () => {
      const fresh = await ticket.read(PROJECT_ID, a.ref);
      expect(fresh.links).toEqual([{ targetRef: b.ref, kind: "blocks" }]);
    });
  });
});

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

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

  /** Renders the view and opens the sprint-management overlay. */
  async function openManager(): Promise<HTMLElement> {
    renderView(ticket, system, agent);
    await waitFor(() =>
      expect(
        screen.getByRole("button", { name: "manage sprints" }),
      ).toBeTruthy(),
    );
    fireEvent.click(screen.getByRole("button", { name: "manage sprints" }));
    return screen.findByRole("dialog", { name: "manage sprints" });
  }

  it("creates a sprint", async () => {
    const dialog = await openManager();

    fireEvent.change(within(dialog).getByLabelText("new sprint name"), {
      target: { value: "Alpha" },
    });
    fireEvent.click(within(dialog).getByText("Create sprint"));

    await waitFor(async () => {
      const sprints = await ticket.listSprints(PROJECT_ID);
      expect(sprints.map((s) => s.name)).toEqual(["Alpha"]);
    });
    expect(
      await within(dialog).findByLabelText("sprint row Alpha"),
    ).toBeTruthy();
  });

  it("creates a sprint with a default positional name when the name is empty", async () => {
    const dialog = await openManager();

    // Leave the name field empty and create → default "Sprint N" (here N=1).
    expect(
      (within(dialog).getByLabelText("new sprint name") as HTMLInputElement)
        .value,
    ).toBe("");
    fireEvent.click(within(dialog).getByText("Create sprint"));

    await waitFor(async () => {
      const sprints = await ticket.listSprints(PROJECT_ID);
      expect(sprints.map((s) => s.name)).toEqual(["Sprint 1"]);
    });
    expect(
      await within(dialog).findByLabelText("sprint row Sprint 1"),
    ).toBeTruthy();
  });

  it("renames a sprint", async () => {
    ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Old" });
    const dialog = await openManager();

    fireEvent.change(
      await within(dialog).findByLabelText("rename sprint Old"),
      { target: { value: "New" } },
    );
    fireEvent.click(within(dialog).getByLabelText("save sprint Old"));

    await waitFor(async () => {
      const [s] = await ticket.listSprints(PROJECT_ID);
      expect(s.name).toBe("New");
    });
  });

  it("reorders sprints via the accessible down button", async () => {
    ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "One" });
    ticket._seedSprint(PROJECT_ID, { id: "s2", order: 2, name: "Two" });
    const dialog = await openManager();

    fireEvent.click(
      await within(dialog).findByLabelText("move sprint One down"),
    );

    await waitFor(async () => {
      const sprints = await ticket.listSprints(PROJECT_ID);
      // "Two" is now first (order 1), "One" second.
      expect(sprints.map((s) => s.name)).toEqual(["Two", "One"]);
    });
  });

  it("deletes a sprint and unassigns (does not delete) its tickets", async () => {
    ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Doomed" });
    const t = await ticket.create(PROJECT_ID, { title: "Kept" });
    await ticket.setTicketSprint(PROJECT_ID, t.ref, "s1", t.version);

    const dialog = await openManager();

    fireEvent.click(
      await within(dialog).findByLabelText("delete sprint Doomed"),
    );
    // The confirmation explains tickets are unassigned, not deleted.
    const confirm = await screen.findByRole("dialog", {
      name: "delete sprint confirmation",
    });
    expect(confirm.textContent).toMatch(/ne seront pas supprimés/i);
    fireEvent.click(within(confirm).getByLabelText("confirm delete sprint"));

    await waitFor(async () => {
      expect(await ticket.listSprints(PROJECT_ID)).toHaveLength(0);
    });
    // The ticket still exists, just unassigned.
    const fresh = await ticket.read(PROJECT_ID, t.ref);
    expect(fresh.sprintId).toBeNull();
    expect(fresh.title).toBe("Kept");
  });

  it("adds several backlog tickets to a sprint in one pass via the multi picker (#19/#41)", async () => {
    ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
    const t1 = await ticket.create(PROJECT_ID, { title: "Backlog item" });
    const t2 = await ticket.create(PROJECT_ID, { title: "Backlog item 2" });

    const dialog = await openManager();

    // Open the add-ticket picker for the Target sprint (multi-select).
    fireEvent.click(
      await within(dialog).findByLabelText("add ticket to sprint Target"),
    );

    // The picker toggles rows without assigning; confirm applies the batch.
    fireEvent.click(await screen.findByLabelText(`select ticket ${t1.ref}`));
    fireEvent.click(await screen.findByLabelText(`select ticket ${t2.ref}`));
    fireEvent.click(screen.getByLabelText("confirm ticket selection"));

    await waitFor(async () => {
      expect((await ticket.read(PROJECT_ID, t1.ref)).sprintId).toBe("s1");
      expect((await ticket.read(PROJECT_ID, t2.ref)).sprintId).toBe("s1");
    });
  });

  it("excludes tickets already in the sprint from the add picker (#19)", async () => {
    ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
    const member = await ticket.create(PROJECT_ID, { title: "Already in" });
    await ticket.setTicketSprint(PROJECT_ID, member.ref, "s1", member.version);
    const backlog = await ticket.create(PROJECT_ID, { title: "Still backlog" });

    const dialog = await openManager();
    fireEvent.click(
      await within(dialog).findByLabelText("add ticket to sprint Target"),
    );

    // The backlog ticket is offered; the one already in the sprint is excluded.
    expect(
      await screen.findByLabelText(`select ticket ${backlog.ref}`),
    ).toBeTruthy();
    expect(
      screen.queryByLabelText(`select ticket ${member.ref}`),
    ).toBeNull();
  });

  it("removes a ticket from a sprint", async () => {
    ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Holder" });
    const t = await ticket.create(PROJECT_ID, { title: "Member" });
    await ticket.setTicketSprint(PROJECT_ID, t.ref, "s1", t.version);

    const dialog = await openManager();

    fireEvent.click(
      await within(dialog).findByLabelText(`remove ${t.ref} from sprint`),
    );

    await waitFor(async () => {
      const fresh = await ticket.read(PROJECT_ID, t.ref);
      expect(fresh.sprintId).toBeNull();
    });
  });

  /** Seeds sprint "Holder" with one open and one closed ticket assigned. */
  async function seedSprintWithOpenAndClosed(): Promise<void> {
    ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Holder" });
    const openT = await ticket.create(PROJECT_ID, {
      title: "Open member",
      priority: "high",
    });
    await ticket.setTicketSprint(PROJECT_ID, openT.ref, "s1", openT.version);
    const closedT = await ticket.create(PROJECT_ID, {
      title: "Closed member",
      priority: "low",
    });
    const upd = await ticket.update(PROJECT_ID, closedT.ref, {
      status: "closed",
      expectedVersion: closedT.version,
    });
    await ticket.setTicketSprint(PROJECT_ID, closedT.ref, "s1", upd.version);
  }

  it("keeps closed tickets visible in the sprint with status/priority badges (#37)", async () => {
    await seedSprintWithOpenAndClosed();
    const dialog = await openManager();

    const row = await within(dialog).findByLabelText("sprint row Holder");
    // Both the open and the closed ticket are listed (closed is NOT hidden).
    expect(within(row).getByText("Open member")).toBeTruthy();
    expect(within(row).getByText("Closed member")).toBeTruthy();
    // Each row carries its status + priority badge (scoped to the sprint row so
    // the facet-bar labels don't collide).
    expect(within(row).getByText("Closed")).toBeTruthy();
    expect(within(row).getByText("Open")).toBeTruthy();
    expect(within(row).getByText("High")).toBeTruthy();
    expect(within(row).getByText("Low")).toBeTruthy();
  });

  it("filters the sprint tickets by status via its own facets bar (#37)", async () => {
    await seedSprintWithOpenAndClosed();
    const dialog = await openManager();

    await within(dialog).findByText("Open member");
    expect(within(dialog).getByText("Closed member")).toBeTruthy();

    // Constrain the sprint view to Closed only → the open ticket drops out.
    fireEvent.click(within(dialog).getByLabelText("filter status Closed"));
    await waitFor(() =>
      expect(within(dialog).queryByText("Open member")).toBeNull(),
    );
    expect(within(dialog).getByText("Closed member")).toBeTruthy();

    // Clearing the facet brings the open ticket back.
    fireEvent.click(within(dialog).getByLabelText("filter status Closed"));
    await waitFor(() =>
      expect(within(dialog).getByText("Open member")).toBeTruthy(),
    );
  });

  it("sprint ticket view is independent of the main panel filter (#37)", async () => {
    await seedSprintWithOpenAndClosed();

    renderView(ticket, system, agent);
    await waitFor(() =>
      expect(
        screen.getByRole("button", { name: "manage sprints" }),
      ).toBeTruthy(),
    );

    // Constrain the MAIN Tickets panel to Open only → the closed ticket leaves
    // the main list (there is a single main facets bar on screen at this point).
    fireEvent.click(screen.getByLabelText("filter status Open"));
    await waitFor(() => expect(screen.queryByText("Closed member")).toBeNull());

    // Open the sprint manager: its own query ignores the main filter, so the
    // closed ticket is still shown inside its sprint.
    fireEvent.click(screen.getByRole("button", { name: "manage sprints" }));
    const dialog = await screen.findByRole("dialog", { name: "manage sprints" });
    const row = await within(dialog).findByLabelText("sprint row Holder");
    expect(await within(row).findByText("Closed member")).toBeTruthy();
    expect(within(row).getByText("Open member")).toBeTruthy();
  });
});
