/**
 * Ticket #60 — the ticket AI assistant must never hang silently.
 *
 * The backend now emits an explicit terminal `{ kind: "error", message }` chunk
 * when a turn produces no usable answer. This hook must consume it: end the
 * pending turn with a readable message and drop `busy`. It must also keep the
 * input disabled until the REAL terminal (`final` or `error`) arrives — not the
 * moment `agent_send` returns (the stream is still flowing then).
 */
import { describe, it, expect } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";

import { DIProvider } from "@/app/di";
import type { Gateways } from "@/ports";
import type { ReplyChunk, TicketChat } from "@/domain";
import { useTicketAssistant } from "./useTicketAssistant";

const P = "proj";
const REF = "#1";

/**
 * A controllable ticket gateway: `sendTicketChat` captures the chunk handler and
 * stays pending until the test resolves it, so the terminal chunk timing is
 * driven explicitly (no reliance on the transport promise settling).
 */
class FakeTicketGateway {
  captured: ((chunk: ReplyChunk) => void) | null = null;
  sendResolve: (() => void) | null = null;
  sendCalls = 0;

  async openTicketChat(): Promise<TicketChat> {
    return { sessionId: "sess-1", requester: "a1", issueRef: REF } as TicketChat;
  }
  async closeTicketChat(): Promise<void> {}
  sendTicketChat(
    _sessionId: string,
    _message: string,
    onChunk: (chunk: ReplyChunk) => void,
  ): Promise<void> {
    this.sendCalls += 1;
    this.captured = onChunk;
    return new Promise<void>((resolve) => {
      this.sendResolve = resolve;
    });
  }
}

function wrapperFor(ticket: FakeTicketGateway) {
  const gateways = { ticket } as unknown as Gateways;
  return function Wrapper({ children }: { children: ReactNode }) {
    return <DIProvider gateways={gateways}>{children}</DIProvider>;
  };
}

/** Opens the session and fires a send that leaves the transport pending. */
async function openAndSend(gw: FakeTicketGateway) {
  const hook = renderHook(() => useTicketAssistant(P, REF), {
    wrapper: wrapperFor(gw),
  });
  await act(async () => {
    await hook.result.current.open("prof-1");
  });
  expect(hook.result.current.sessionId).toBe("sess-1");
  act(() => {
    void hook.result.current.send("édite le ticket");
  });
  await waitFor(() => expect(gw.captured).toBeTruthy());
  return hook;
}

describe("useTicketAssistant — ticket #60 (no-reply terminal)", () => {
  it("an error chunk ends the pending turn with a visible message and clears busy", async () => {
    const gw = new FakeTicketGateway();
    const { result } = await openAndSend(gw);

    // Mid-turn: busy true, an empty pending assistant bubble is streaming.
    expect(result.current.busy).toBe(true);
    expect(result.current.turns.at(-1)).toMatchObject({
      role: "assistant",
      pending: true,
    });

    // Backend terminal fallback: no usable answer.
    act(() => {
      gw.captured!({
        kind: "error",
        message: "Le modèle n'a renvoyé aucune réponse.",
      });
    });

    await waitFor(() => expect(result.current.busy).toBe(false));
    const last = result.current.turns.at(-1)!;
    expect(last.role).toBe("assistant");
    expect(last.pending).toBeFalsy();
    expect(last.text).toContain("Le modèle n'a renvoyé aucune réponse.");
  });

  it("a non-empty final renders the answer and clears busy (nominal, no regression)", async () => {
    const gw = new FakeTicketGateway();
    const { result } = await openAndSend(gw);

    act(() => {
      gw.captured!({ kind: "textDelta", text: "Titre " });
    });
    act(() => {
      gw.captured!({ kind: "final", content: "Titre mis à jour." });
    });

    await waitFor(() => expect(result.current.busy).toBe(false));
    const last = result.current.turns.at(-1)!;
    expect(last.role).toBe("assistant");
    expect(last.text).toBe("Titre mis à jour.");
    expect(last.pending).toBeFalsy();
  });

  it("busy stays true after agent_send resolves, until a terminal chunk arrives", async () => {
    const gw = new FakeTicketGateway();
    const { result } = await openAndSend(gw);
    expect(result.current.busy).toBe(true);

    // `agent_send` returns (stream started) but NO terminal chunk yet: busy must
    // NOT drop — the input stays disabled while the turn is still in flight.
    await act(async () => {
      gw.sendResolve!();
    });
    expect(result.current.busy).toBe(true);
    expect(result.current.turns.at(-1)).toMatchObject({ pending: true });

    // Only the real terminal (here `final`) re-enables the input.
    act(() => {
      gw.captured!({ kind: "final", content: "fini" });
    });
    await waitFor(() => expect(result.current.busy).toBe(false));
    expect(result.current.turns.at(-1)).toMatchObject({
      text: "fini",
      pending: false,
    });
  });
});
