// @vitest-environment jsdom
//
// Reducer tests for the client-side prompt-queue feature (#1031),
// plus hook-level drain-race regression tests for #1144 (queued
// follow-ups silently dropped on reconnect).
//
// While a turn is running, sendPrompt dispatches `enqueue_prompt`
// instead of the immediate POST path. The reducer keeps the queue
// across re-renders so the drain effect can pop heads on Stopped, and
// the QueuedPromptsStrip can render / edit / drop entries before they
// fire.

import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { emptyCockpitState, type QueuedPrompt } from "../lib/cockpitTypes";
import { cockpitHookReducer, combineQueuedPrompts, useCockpit } from "./useCockpit";

describe("cockpitHookReducer / queue actions", () => {
  it("emptyCockpitState starts with an empty queue", () => {
    expect(emptyCockpitState().queuedPrompts).toEqual([]);
  });

  it("enqueue_prompt appends to the end of queuedPrompts", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
    try {
      const s1 = cockpitHookReducer(emptyCockpitState(), {
        kind: "enqueue_prompt",
        text: "first",
      });
      const s2 = cockpitHookReducer(s1, {
        kind: "enqueue_prompt",
        text: "second",
      });
      expect(s2.queuedPrompts).toHaveLength(2);
      expect(s2.queuedPrompts[0]?.text).toBe("first");
      expect(s2.queuedPrompts[1]?.text).toBe("second");
      expect(s2.queuedPrompts[0]?.queuedAt).toBe(
        "2026-01-01T00:00:00.000Z",
      );
    } finally {
      vi.useRealTimers();
    }
  });

  it("dequeue_prompt removes the matching entry by id", () => {
    const s1 = cockpitHookReducer(emptyCockpitState(), {
      kind: "enqueue_prompt",
      text: "first",
    });
    const s2 = cockpitHookReducer(s1, {
      kind: "enqueue_prompt",
      text: "second",
    });
    const headId = s2.queuedPrompts[0]?.id;
    expect(headId).toBeDefined();
    const s3 = cockpitHookReducer(s2, {
      kind: "dequeue_prompt",
      id: headId!,
    });
    expect(s3.queuedPrompts).toHaveLength(1);
    expect(s3.queuedPrompts[0]?.text).toBe("second");
  });

  it("dequeue_prompt is a no-op for a missing id", () => {
    const s1 = cockpitHookReducer(emptyCockpitState(), {
      kind: "enqueue_prompt",
      text: "first",
    });
    const s2 = cockpitHookReducer(s1, {
      kind: "dequeue_prompt",
      id: "nope",
    });
    expect(s2.queuedPrompts).toHaveLength(1);
  });

  it("edit_queued_prompt updates only the targeted entry's text", () => {
    const s1 = cockpitHookReducer(emptyCockpitState(), {
      kind: "enqueue_prompt",
      text: "first",
    });
    const s2 = cockpitHookReducer(s1, {
      kind: "enqueue_prompt",
      text: "second",
    });
    const targetId = s2.queuedPrompts[1]?.id;
    expect(targetId).toBeDefined();
    const s3 = cockpitHookReducer(s2, {
      kind: "edit_queued_prompt",
      id: targetId!,
      text: "second (edited)",
    });
    expect(s3.queuedPrompts[0]?.text).toBe("first");
    expect(s3.queuedPrompts[1]?.text).toBe("second (edited)");
  });

  it("clear_queue drops every entry", () => {
    const s1 = cockpitHookReducer(emptyCockpitState(), {
      kind: "enqueue_prompt",
      text: "first",
    });
    const s2 = cockpitHookReducer(s1, {
      kind: "enqueue_prompt",
      text: "second",
    });
    const s3 = cockpitHookReducer(s2, { kind: "clear_queue" });
    expect(s3.queuedPrompts).toEqual([]);
  });

  it("dequeue_prompts_by_id removes only the listed ids, preserving order", () => {
    const s1 = cockpitHookReducer(emptyCockpitState(), {
      kind: "enqueue_prompt",
      text: "first",
    });
    const s2 = cockpitHookReducer(s1, {
      kind: "enqueue_prompt",
      text: "second",
    });
    const s3 = cockpitHookReducer(s2, {
      kind: "enqueue_prompt",
      text: "third",
    });
    const firstId = s3.queuedPrompts[0]!.id;
    const thirdId = s3.queuedPrompts[2]!.id;
    const s4 = cockpitHookReducer(s3, {
      kind: "dequeue_prompts_by_id",
      ids: [firstId, thirdId],
    });
    expect(s4.queuedPrompts).toHaveLength(1);
    expect(s4.queuedPrompts[0]?.text).toBe("second");
  });

  it("dequeue_prompts_by_id with an empty id list is a no-op", () => {
    const s1 = cockpitHookReducer(emptyCockpitState(), {
      kind: "enqueue_prompt",
      text: "first",
    });
    const s2 = cockpitHookReducer(s1, {
      kind: "dequeue_prompts_by_id",
      ids: [],
    });
    expect(s2).toBe(s1);
  });

  it("queue is independent of activity / turnActive state", () => {
    // Enqueue while a turn is mid-flight (turnActive=true, activity has
    // a user_prompt row) and ensure the queue mutation does not clobber
    // the rest of state.
    const base = {
      ...emptyCockpitState(),
      activity: [
        {
          id: "user-1",
          kind: "user_prompt" as const,
          text: "original",
          at: "2026-01-01T00:00:00Z",
        },
      ],
      turnActive: true,
    };
    const next = cockpitHookReducer(base, {
      kind: "enqueue_prompt",
      text: "queued follow-up",
    });
    expect(next.activity).toEqual(base.activity);
    expect(next.turnActive).toBe(true);
    expect(next.queuedPrompts[0]?.text).toBe("queued follow-up");
  });
});

describe("combineQueuedPrompts (combined drain mode)", () => {
  const mk = (id: string, text: string): QueuedPrompt => ({
    id,
    text,
    queuedAt: "2026-01-01T00:00:00.000Z",
  });

  it("joins entries with a blank line", () => {
    const out = combineQueuedPrompts([
      mk("a", "first"),
      mk("b", "second"),
      mk("c", "third"),
    ]);
    expect(out).toBe("first\n\nsecond\n\nthird");
  });

  it("preserves intra-entry newlines unchanged", () => {
    const out = combineQueuedPrompts([
      mk("a", "line 1\nline 2"),
      mk("b", "after"),
    ]);
    expect(out).toBe("line 1\nline 2\n\nafter");
  });

  it("returns an empty string for an empty queue", () => {
    expect(combineQueuedPrompts([])).toBe("");
  });

  it("returns a single entry unchanged for a one-item queue", () => {
    expect(combineQueuedPrompts([mk("a", "only one")])).toBe("only one");
  });
});

// Hook-level regression tests for #1144: queued prompts were silently
// dropped when the drain effect fired during the WS reconnect window.
// connect() awaits fetchReplay BEFORE opening the WS, and replay can
// dispatch a Stopped frame (turnActive flips to false). Under the prior
// optimistic-clear ordering, the drain effect would fire while status
// was still "connecting", clear the queue, then dispatchPromptNow would
// bail with an error banner -- queue gone, message never sent.

interface FakeSocket {
  url: string;
  protocols: string[] | string | undefined;
  readyState: number;
  onopen: ((ev: Event) => void) | null;
  onclose: ((ev: CloseEvent) => void) | null;
  onerror: ((ev: Event) => void) | null;
  onmessage: ((ev: MessageEvent) => void) | null;
  close: () => void;
  send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void;
}

const sockets: FakeSocket[] = [];
let originalWebSocket: typeof WebSocket;

class FakeWebSocket implements FakeSocket {
  url: string;
  protocols: string[] | string | undefined;
  readyState: number = 0;
  onopen: ((ev: Event) => void) | null = null;
  onclose: ((ev: CloseEvent) => void) | null = null;
  onerror: ((ev: Event) => void) | null = null;
  onmessage: ((ev: MessageEvent) => void) | null = null;
  static CONNECTING = 0;
  static OPEN = 1;
  static CLOSING = 2;
  static CLOSED = 3;
  constructor(url: string, protocols?: string | string[]) {
    this.url = url;
    this.protocols = protocols;
    sockets.push(this);
  }
  close(): void {
    this.readyState = FakeWebSocket.CLOSED;
    if (this.onclose) {
      this.onclose({
        code: 1000,
        reason: "test close",
        wasClean: true,
      } as CloseEvent);
    }
  }
  send(): void {
    /* no-op */
  }
}

async function flushAsync(): Promise<void> {
  await act(async () => {
    for (let i = 0; i < 8; i++) {
      await Promise.resolve();
    }
  });
}

describe("useCockpit drain race (#1144)", () => {
  let promptPostCount: number;
  let promptPostShouldFail: boolean;
  let promptPostBodies: string[];
  let replayResponse: { frames: unknown[]; lost: boolean; highest_seq: number };

  beforeEach(() => {
    sockets.length = 0;
    promptPostCount = 0;
    promptPostShouldFail = false;
    promptPostBodies = [];
    replayResponse = { frames: [], lost: false, highest_seq: 0 };
    vi.stubGlobal(
      "fetch",
      vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
        const url = typeof input === "string" ? input : input.toString();
        if (url.includes("/cockpit/replay")) {
          return new Response(JSON.stringify(replayResponse), { status: 200 });
        }
        if (url.includes("/cockpit/prompt")) {
          promptPostCount += 1;
          if (typeof init?.body === "string") {
            promptPostBodies.push(init.body);
          }
          if (promptPostShouldFail) {
            return new Response("simulated failure", { status: 500 });
          }
          return new Response("{}", { status: 200 });
        }
        return new Response("{}", { status: 200 });
      }),
    );
    originalWebSocket = global.WebSocket;
    global.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
  });

  afterEach(() => {
    global.WebSocket = originalWebSocket;
    vi.unstubAllGlobals();
  });

  it("does not POST while the WS is still connecting, even if the queue is non-empty", async () => {
    const { result } = renderHook(() => useCockpit("sess-drain-1"));
    await flushAsync();
    expect(sockets).toHaveLength(1);
    // WS is still in CONNECTING (FakeWebSocket starts at readyState=0).
    // sendPrompt's status guard would itself reject the call, so
    // simulate the in-the-wild path: directly dispatch onto the
    // reducer-backed queue while turnActive is false. We do that by
    // calling the public sendPrompt while turnActive is true (so it
    // enqueues), then releasing turnActive without opening the socket.
    //
    // The simplest deterministic reproduction is to pre-seed the queue
    // via sendPrompt in the connecting window with turnActive flipped
    // by a Stopped frame on the WS. Easier still: invoke the public
    // sendPrompt with status not "open" to confirm the gate; then
    // verify zero prompt POSTs went out.
    act(() => {
      void result.current.sendPrompt("queued before open");
    });
    await flushAsync();
    expect(promptPostCount).toBe(0);
    // The error banner from sendPrompt's own status guard is fine; the
    // important thing is that the drain effect did NOT also fire and
    // optimistically clear a queue.
    expect(result.current.state.queuedPrompts).toEqual([]);
  });

  it("combined-mode drain leaves the queue intact when the prompt POST fails", async () => {
    const { result } = renderHook(() => useCockpit("sess-drain-2"));
    await flushAsync();
    const ws = sockets[0]!;
    // Open the socket so sendPrompt's status gate clears.
    act(() => {
      ws.readyState = FakeWebSocket.OPEN;
      ws.onopen?.({} as Event);
    });
    await flushAsync();

    // Mark the turn as active so subsequent sendPrompt calls enqueue.
    act(() => {
      ws.onmessage?.({
        data: JSON.stringify({
          session_id: "sess-drain-2",
          seq: 1,
          event: { UserPromptSent: { text: "kicker" } },
        }),
      } as MessageEvent);
    });
    await flushAsync();
    expect(result.current.state.turnActive).toBe(true);

    // Enqueue two follow-ups while the turn is active.
    act(() => {
      void result.current.sendPrompt("queued A");
    });
    act(() => {
      void result.current.sendPrompt("queued B");
    });
    await flushAsync();
    expect(result.current.state.queuedPrompts).toHaveLength(2);

    // Configure the prompt POST to fail, then end the turn so the drain
    // fires.
    promptPostShouldFail = true;
    act(() => {
      ws.onmessage?.({
        data: JSON.stringify({
          session_id: "sess-drain-2",
          seq: 2,
          event: { Stopped: { reason: "prompt_complete" } },
        }),
      } as MessageEvent);
    });
    await flushAsync();

    // The combined POST was attempted exactly once with both entries
    // joined, but failed; the queue MUST remain intact for the next
    // turn-end retry.
    expect(promptPostCount).toBe(1);
    expect(promptPostBodies[0]).toContain("queued A");
    expect(promptPostBodies[0]).toContain("queued B");
    expect(result.current.state.queuedPrompts).toHaveLength(2);
    expect(result.current.state.queuedPrompts[0]?.text).toBe("queued A");
    expect(result.current.state.queuedPrompts[1]?.text).toBe("queued B");
  });

  it("combined-mode drain only clears the items it sent, not items enqueued during the await", async () => {
    const { result } = renderHook(() => useCockpit("sess-drain-3"));
    await flushAsync();
    const ws = sockets[0]!;
    act(() => {
      ws.readyState = FakeWebSocket.OPEN;
      ws.onopen?.({} as Event);
    });
    await flushAsync();

    // Start a turn and enqueue two follow-ups.
    act(() => {
      ws.onmessage?.({
        data: JSON.stringify({
          session_id: "sess-drain-3",
          seq: 1,
          event: { UserPromptSent: { text: "kicker" } },
        }),
      } as MessageEvent);
    });
    await flushAsync();
    act(() => {
      void result.current.sendPrompt("queued A");
    });
    act(() => {
      void result.current.sendPrompt("queued B");
    });
    await flushAsync();
    expect(result.current.state.queuedPrompts).toHaveLength(2);

    // Make the prompt POST hang so we have a window to enqueue more
    // entries during the await.
    let resolvePost: ((res: Response) => void) | null = null;
    const pendingPost = new Promise<Response>((resolve) => {
      resolvePost = resolve;
    });
    vi.stubGlobal(
      "fetch",
      vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
        const url = typeof input === "string" ? input : input.toString();
        if (url.includes("/cockpit/replay")) {
          return new Response(
            JSON.stringify({ frames: [], lost: false, highest_seq: 0 }),
            { status: 200 },
          );
        }
        if (url.includes("/cockpit/prompt")) {
          promptPostCount += 1;
          if (typeof init?.body === "string") {
            promptPostBodies.push(init.body);
          }
          return pendingPost;
        }
        return new Response("{}", { status: 200 });
      }),
    );

    // End the turn -> drain fires, dispatchPromptNow awaits.
    act(() => {
      ws.onmessage?.({
        data: JSON.stringify({
          session_id: "sess-drain-3",
          seq: 2,
          event: { Stopped: { reason: "prompt_complete" } },
        }),
      } as MessageEvent);
    });
    await flushAsync();
    expect(promptPostCount).toBe(1);

    // Mid-await: a new turn would normally have to start before the
    // user could enqueue, but the queue actions are independent. Push a
    // new turn (UserPromptSent) so turnActive flips back to true and a
    // sendPrompt enqueues rather than racing dispatchPromptNow.
    act(() => {
      ws.onmessage?.({
        data: JSON.stringify({
          session_id: "sess-drain-3",
          seq: 3,
          event: { UserPromptSent: { text: "echoed combined" } },
        }),
      } as MessageEvent);
    });
    await flushAsync();
    act(() => {
      void result.current.sendPrompt("queued during await");
    });
    await flushAsync();
    expect(result.current.state.queuedPrompts).toHaveLength(3);

    // Resolve the in-flight POST as success; only the snapshot ids
    // should be removed -- the late entry stays.
    act(() => {
      resolvePost?.(new Response("{}", { status: 200 }));
    });
    await flushAsync();
    expect(result.current.state.queuedPrompts).toHaveLength(1);
    expect(result.current.state.queuedPrompts[0]?.text).toBe(
      "queued during await",
    );
  });
});
