/**
 * #77 lot F2 — the shared devices surface, driven through the mock gateway
 * (B1/B2/B3 are not landed; the mock models the frozen DTO contract).
 *
 * The load-bearing assertions:
 *  - the copied code carries **no separator**, whatever the display shows;
 *  - no IP / User-Agent ever reaches the DOM;
 *  - revoking the current device ends the session instead of refreshing a list
 *    we are no longer allowed to read.
 */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";

import type { Gateways } from "@/ports";
import type { PairedDevice } from "@/domain";
import { DIProvider } from "@/app/di";
import { createMockGateways, MockDeviceGateway } from "@/adapters/mock";
import { DevicesScreen } from "./DevicesScreen";

/**
 * Epoch-ms fixtures — the encoding the backend actually sends (`*AtMs: number`).
 * These were ISO strings, which let the suite pass while the real screen
 * rendered "—" on every row.
 */
const DEVICES: PairedDevice[] = [
  {
    deviceId: "d1",
    name: "iPhone",
    pairedAtMs: new Date(2026, 6, 12, 10, 0).getTime(),
    lastSeenAtMs: Date.now(),
    isCurrentDevice: true,
  },
  {
    deviceId: "d2",
    name: "Chrome sur Windows",
    pairedAtMs: new Date(2026, 5, 1, 10, 0).getTime(),
    lastSeenAtMs: new Date(2026, 6, 12, 14, 32).getTime(),
    isCurrentDevice: false,
  },
];

function setup(devices: PairedDevice[] = DEVICES) {
  const gateways: Gateways = createMockGateways();
  const device = gateways.device as MockDeviceGateway;
  device._setDevices(devices);
  const onSessionEnded = vi.fn();
  render(
    <DIProvider gateways={gateways}>
      <DevicesScreen onSessionEnded={onSessionEnded} />
    </DIProvider>,
  );
  return { device, onSessionEnded };
}

const writeText = vi.fn(async (_text: string) => {});

beforeEach(() => {
  writeText.mockClear();
  Object.defineProperty(window.navigator, "clipboard", {
    value: { writeText },
    configurable: true,
  });
});

const rowFor = async (name: string) =>
  (await screen.findByText(name)).closest("li") as HTMLElement;

describe("DevicesScreen list", () => {
  it("lists devices with their name, activity and pairing date", async () => {
    setup();

    const row = await rowFor("Chrome sur Windows");
    expect(within(row).getByText("Appairé le 1 juin")).toBeTruthy();
    expect(screen.getAllByTestId("device-row")).toHaveLength(2);
  });

  it("badges the current device", async () => {
    setup();

    const current = await rowFor("iPhone");
    expect(within(current).getByText("Cet appareil")).toBeTruthy();
    const other = await rowFor("Chrome sur Windows");
    expect(within(other).queryByText("Cet appareil")).toBeNull();
  });

  it("never renders an IP or a User-Agent", async () => {
    setup();
    await screen.findByTestId("device-list");

    const text = screen.getByTestId("devices-screen").textContent ?? "";
    expect(text).not.toMatch(/Mozilla|AppleWebKit|\d+\.\d+\.\d+\.\d+/);
  });

  it("says so plainly when nothing is paired", async () => {
    setup([]);
    expect(await screen.findByText("Aucun appareil appairé.")).toBeTruthy();
  });
});

describe("DevicesScreen pairing code", () => {
  it("copies the canonical code, with no separator in the clipboard value", async () => {
    setup();

    fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
    const panel = await screen.findByTestId("pairing-code-panel");
    fireEvent.click(within(panel).getByRole("button", { name: "Copier" }));

    await waitFor(() => expect(writeText).toHaveBeenCalled());
    const copied = writeText.mock.calls[0][0];
    // The whole point of the arbitration: a dash here would be retyped by the
    // user and refused by the server (#75).
    expect(copied).toMatch(/^[0-9A-F]{8}$/);
    expect(copied).not.toContain("-");
    expect(copied).not.toContain(" ");
  });

  it("groups the code visually while keeping the value intact for readers", async () => {
    setup();

    fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
    const value = await screen.findByTestId("pairing-code-value");

    // Two blocks of 4 on screen…
    const blocks = value.querySelectorAll("span");
    expect(blocks).toHaveLength(2);
    expect(blocks[0].textContent).toHaveLength(4);
    expect(blocks[1].textContent).toHaveLength(4);
    // …but a screen reader hears the code it must type, unseparated.
    expect(value.getAttribute("aria-label")).toMatch(/^[0-9A-F]{8}$/);
  });

  it("shows the instruction and a countdown", async () => {
    setup();

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

    expect(await screen.findByText("Saisissez ce code sur le nouvel appareil.")).toBeTruthy();
    expect((await screen.findByTestId("pairing-code-countdown")).textContent).toBe(
      "Expire dans 10 min",
    );
  });

  it("does not declare a freshly generated code expired", async () => {
    // The visible symptom of the ISO/epoch mismatch (#77): `new Date(<epoch-ms>)`
    // was Invalid Date ⇒ the countdown read null ⇒ every brand-new code showed
    // "Ce code a expiré." the instant it appeared. Guard the whole failure, not
    // just the formatter.
    setup();

    fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
    await screen.findByTestId("pairing-code-panel");

    expect(screen.queryByText("Ce code a expiré.")).toBeNull();
    expect(screen.queryByRole("button", { name: "Générer un nouveau code" })).toBeNull();
    expect(screen.getByTestId("pairing-code-countdown").textContent).toMatch(
      /^Expire dans \d+ (min|s)$/,
    );
  });

  it("replaces the code rather than stacking panels when regenerating", async () => {
    const { device } = setup();

    fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
    await screen.findByTestId("pairing-code-panel");
    fireEvent.click(screen.getByRole("button", { name: "Appairer" }));

    await waitFor(() => expect(device._issuedCodes).toHaveLength(2));
    expect(screen.getAllByTestId("pairing-code-panel")).toHaveLength(1);
    // Generating invalidates the previous code server-side; the UI shows only
    // the live one so nobody reads a dead code aloud.
    const shown = (await screen.findByTestId("pairing-code-value")).getAttribute("aria-label");
    expect(shown).toBe(device._issuedCodes[1]);
  });

  it("announces expiry without alarmism and offers a new code", async () => {
    vi.useFakeTimers({ shouldAdvanceTime: true });
    try {
      setup();
      fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
      await screen.findByTestId("pairing-code-panel");

      await vi.advanceTimersByTimeAsync(601_000);

      expect(await screen.findByText("Ce code a expiré.")).toBeTruthy();
      expect(screen.getByRole("button", { name: "Générer un nouveau code" })).toBeTruthy();
    } finally {
      vi.useRealTimers();
    }
  });
});

describe("DevicesScreen rename", () => {
  it("renames through the ⋯ menu", async () => {
    const { device } = setup();

    const row = await rowFor("Chrome sur Windows");
    fireEvent.click(within(row).getByRole("button", { name: "Actions pour Chrome sur Windows" }));
    fireEvent.click(within(row).getByRole("menuitem", { name: "Renommer" }));

    fireEvent.change(screen.getByDisplayValue("Chrome sur Windows"), {
      target: { value: "PC du bureau" },
    });
    fireEvent.click(screen.getByRole("button", { name: "Renommer" }));

    expect(await screen.findByText("PC du bureau")).toBeTruthy();
    expect((await device.listDevices()).find((d) => d.deviceId === "d2")?.name).toBe(
      "PC du bureau",
    );
  });
});

describe("DevicesScreen revocation", () => {
  async function openRevoke(name: string) {
    const row = await rowFor(name);
    fireEvent.click(within(row).getByRole("button", { name: `Actions pour ${name}` }));
    fireEvent.click(within(row).getByRole("menuitem", { name: "Révoquer" }));
    return screen.findByRole("dialog");
  }

  it("confirms before revoking another device", async () => {
    const { device, onSessionEnded } = setup();

    const dialog = await openRevoke("Chrome sur Windows");
    expect(within(dialog).getByText("Révoquer cet appareil ?")).toBeTruthy();
    expect(
      within(dialog).getByText("Il devra être appairé à nouveau pour accéder à IdeA."),
    ).toBeTruthy();

    fireEvent.click(within(dialog).getByRole("button", { name: "Révoquer" }));

    await waitFor(() => expect(screen.queryByText("Chrome sur Windows")).toBeNull());
    expect(await device.listDevices()).toHaveLength(1);
    // Revoking someone else must not touch our own session.
    expect(onSessionEnded).not.toHaveBeenCalled();
  });

  it("cancelling leaves the device alone", async () => {
    const { device } = setup();

    const dialog = await openRevoke("Chrome sur Windows");
    fireEvent.click(within(dialog).getByRole("button", { name: "Annuler" }));

    expect(screen.getByText("Chrome sur Windows")).toBeTruthy();
    expect(await device.listDevices()).toHaveLength(2);
  });

  it("warns about immediate sign-out when revoking the current device", async () => {
    setup();

    const dialog = await openRevoke("iPhone");
    expect(within(dialog).getByText(/Vous serez déconnecté immédiatement\./)).toBeTruthy();
  });

  it("ends the session when the current device revokes itself (nominal case)", async () => {
    const { onSessionEnded } = setup();

    const dialog = await openRevoke("iPhone");
    fireEvent.click(within(dialog).getByRole("button", { name: "Révoquer" }));

    await waitFor(() => expect(onSessionEnded).toHaveBeenCalled());
  });

  it("revokes everything, current device included, behind a strong confirmation", async () => {
    const { device, onSessionEnded } = setup();
    await screen.findByTestId("device-list");

    fireEvent.click(screen.getByRole("button", { name: "Révoquer tous les appareils" }));
    const dialog = await screen.findByRole("dialog");
    expect(within(dialog).getByText("Révoquer tous les appareils ?")).toBeTruthy();

    fireEvent.click(within(dialog).getByRole("button", { name: "Tout révoquer" }));

    await waitFor(() => expect(onSessionEnded).toHaveBeenCalled());
    expect(await device.listDevices()).toHaveLength(0);
  });

  it("does not end the session when revoke-all excludes the current device", async () => {
    // Desktop: no device is ever `isCurrentDevice`, so wiping the list must not
    // pretend the app just logged itself out.
    const { onSessionEnded } = setup(DEVICES.map((d) => ({ ...d, isCurrentDevice: false })));
    await screen.findByTestId("device-list");

    fireEvent.click(screen.getByRole("button", { name: "Révoquer tous les appareils" }));
    fireEvent.click(
      within(await screen.findByRole("dialog")).getByRole("button", { name: "Tout révoquer" }),
    );

    await waitFor(() => expect(screen.getByText("Aucun appareil appairé.")).toBeTruthy());
    expect(onSessionEnded).not.toHaveBeenCalled();
  });
});
