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

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

const PROJECT_ID = "project-sprint-picker";

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

/** Seeds two ordered sprints. */
function seedSprints(ticket: MockTicketGateway) {
  const beta = ticket._seedSprint(PROJECT_ID, {
    id: "s2",
    order: 2,
    name: "Beta",
    ticketCount: 3,
  });
  const alpha = ticket._seedSprint(PROJECT_ID, {
    id: "s1",
    order: 1,
    name: "Alpha",
  });
  return { alpha, beta };
}

describe("SprintPicker (#38)", () => {
  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}>
        <SprintPicker
          open={false}
          projectId={PROJECT_ID}
          onSelect={vi.fn()}
          onClose={vi.fn()}
        />
      </DIProvider>,
    );
    expect(container.querySelector('[role="dialog"]')).toBeNull();
  });

  it("lists the project's sprints (ordered) plus a « Sans sprint » entry", async () => {
    seedSprints(ticket);
    renderPicker(ticket);

    // "Sans sprint" is always present.
    expect(screen.getByLabelText("select no sprint")).toBeTruthy();

    // Both sprints appear once loaded, ordered by `order` (Alpha before Beta).
    await screen.findByLabelText("select sprint Alpha");
    const options = screen
      .getAllByRole("option")
      .map((n) => n.getAttribute("aria-label"));
    expect(options).toEqual([
      "select no sprint",
      "select sprint Alpha",
      "select sprint Beta",
    ]);
  });

  it("selecting a sprint hands back the sprint and closes", async () => {
    const { alpha } = seedSprints(ticket);
    const { onSelect, onClose } = renderPicker(ticket);

    fireEvent.click(await screen.findByLabelText("select sprint Alpha"));

    expect(onSelect).toHaveBeenCalledTimes(1);
    expect(onSelect.mock.calls[0][0]).toMatchObject({
      id: alpha.id,
      name: "Alpha",
    });
    expect(onClose).toHaveBeenCalledTimes(1);
  });

  it("selecting « Sans sprint » hands back null and closes", async () => {
    seedSprints(ticket);
    const { onSelect, onClose } = renderPicker(ticket, {
      selectedSprintId: "s1",
    });

    await screen.findByLabelText("select sprint Alpha");
    fireEvent.click(screen.getByLabelText("select no sprint"));

    expect(onSelect).toHaveBeenCalledTimes(1);
    expect(onSelect.mock.calls[0][0]).toBeNull();
    expect(onClose).toHaveBeenCalledTimes(1);
  });

  it("marks the currently-selected sprint as aria-selected", async () => {
    seedSprints(ticket);
    renderPicker(ticket, { selectedSprintId: "s2" });

    const beta = await screen.findByLabelText("select sprint Beta");
    expect(beta.getAttribute("aria-selected")).toBe("true");
    expect(
      screen.getByLabelText("select sprint Alpha").getAttribute("aria-selected"),
    ).toBe("false");
    // "Sans sprint" is not selected when a sprint id is active.
    expect(
      screen.getByLabelText("select no sprint").getAttribute("aria-selected"),
    ).toBe("false");
  });

  it("shows an empty state when the project has no sprints", async () => {
    renderPicker(ticket);
    expect(await screen.findByText("No sprints yet.")).toBeTruthy();
    // "Sans sprint" is still offered so the user can clear an assignment.
    expect(screen.getByLabelText("select no sprint")).toBeTruthy();
  });

  it("closes on Escape and on backdrop click", async () => {
    seedSprints(ticket);
    const { onClose } = renderPicker(ticket);
    await screen.findByLabelText("select sprint Alpha");

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

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

  it("surfaces a listSprints failure", async () => {
    vi.spyOn(ticket, "listSprints").mockRejectedValueOnce({
      code: "INTERNAL",
      message: "boom",
    });
    renderPicker(ticket);
    const alert = await screen.findByRole("alert");
    expect(alert.textContent).toContain("boom");
  });
});
