import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
import { ClaudeCliClient } from "@/agent/claude-cli-client.js";
import { mockLogger } from "../_helpers/mock-logger.js";

describe("ClaudeCliClient.extractAssistantText", () => {
  test("returns the final result event text", () => {
    const lines = [
      JSON.stringify({ type: "system", subtype: "init", model: "claude-opus-4-8" }),
      JSON.stringify({
        type: "assistant",
        message: { content: [{ type: "text", text: "working on it" }] },
      }),
      JSON.stringify({
        type: "result",
        subtype: "success",
        result: "PR_TITLE_START\nFix the bug\nPR_TITLE_END",
      }),
    ];
    expect(ClaudeCliClient.extractAssistantText(lines)).toBe(
      "PR_TITLE_START\nFix the bug\nPR_TITLE_END",
    );
  });

  test("falls back to last assistant message when no result event", () => {
    const lines = [
      JSON.stringify({
        type: "assistant",
        message: { content: [{ type: "text", text: "first" }] },
      }),
      JSON.stringify({
        type: "assistant",
        message: { content: [{ type: "text", text: "final answer" }] },
      }),
    ];
    expect(ClaudeCliClient.extractAssistantText(lines)).toBe("final answer");
  });

  test("falls back to concatenated text_delta from stream_event", () => {
    const lines = [
      JSON.stringify({
        type: "stream_event",
        event: { type: "content_block_delta", delta: { type: "text_delta", text: "hel" } },
      }),
      JSON.stringify({
        type: "stream_event",
        event: { type: "content_block_delta", delta: { type: "text_delta", text: "lo" } },
      }),
    ];
    expect(ClaudeCliClient.extractAssistantText(lines)).toBe("hello");
  });

  test("ignores unparseable lines", () => {
    const lines = [
      "not json",
      JSON.stringify({ type: "result", subtype: "success", result: "ok" }),
    ];
    expect(ClaudeCliClient.extractAssistantText(lines)).toBe("ok");
  });
});

function makeStream(lines: string[]): ReadableStream<Uint8Array> {
  const encoder = new TextEncoder();
  return new ReadableStream({
    start(controller) {
      for (const line of lines) controller.enqueue(encoder.encode(line + "\n"));
      controller.close();
    },
  });
}

function makeFakeProc(opts: {
  exitCode?: number;
  stdoutLines?: string[];
  stderrLines?: string[];
  diedImmediately?: boolean;
}) {
  const exitCode = opts.exitCode ?? 0;
  return {
    pid: 4242,
    // Non-null exitCode at construction simulates a process that died before we await it.
    exitCode: (opts.diedImmediately ? exitCode : null) as number | null,
    exited: Promise.resolve(exitCode),
    stdout: makeStream(opts.stdoutLines ?? []),
    stderr: makeStream(opts.stderrLines ?? []),
    kill: mock(() => {}),
  };
}

describe("ClaudeCliClient.runAgent", () => {
  let spawnSpy: ReturnType<typeof spyOn>;
  let fileSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fileSpy = spyOn(Bun, "file").mockReturnValue({
      exists: () => Promise.resolve(false),
    } as unknown as ReturnType<typeof Bun.file>);
  });

  afterEach(() => {
    spawnSpy?.mockRestore();
    fileSpy?.mockRestore();
  });

  test("invokes claude with headless stream-json flags and ANTHROPIC env", async () => {
    const proc = makeFakeProc({
      exitCode: 0,
      stdoutLines: [JSON.stringify({ type: "result", subtype: "success", result: "done" })],
    });
    spawnSpy = spyOn(Bun, "spawn").mockReturnValue(proc as unknown as ReturnType<typeof Bun.spawn>);

    const client = new ClaudeCliClient("sk-test", "claude-opus-4-8", undefined, mockLogger());
    const res = await client.runAgent("do the thing", "/tmp/workdir", 60_000);

    expect(res.success).toBe(true);
    expect(res.output).toBe("done");

    const [cmdArgs, spawnOpts] = spawnSpy.mock.calls[0] as [
      string[],
      { env: Record<string, string> },
    ];
    expect(cmdArgs).toContain("claude");
    expect(cmdArgs).toContain("-p");
    expect(cmdArgs).toContain("do the thing");
    expect(cmdArgs).toContain("--output-format");
    expect(cmdArgs).toContain("stream-json");
    expect(cmdArgs).toContain("--verbose");
    expect(cmdArgs).toContain("--dangerously-skip-permissions");
    expect(cmdArgs).toEqual(expect.arrayContaining(["--model", "claude-opus-4-8"]));
    expect(cmdArgs).not.toContain("--api-key");
    expect(spawnOpts.env.ANTHROPIC_API_KEY).toBe("sk-test");
    expect(spawnOpts.env.IS_SANDBOX).toBe("1");
    expect(spawnOpts.env.DISABLE_AUTOUPDATER).toBe("1");
    expect(spawnOpts.env.ANTHROPIC_BASE_URL).toBeUndefined();
  });

  test("returns failure when the process dies immediately on spawn", async () => {
    const proc = makeFakeProc({ exitCode: 1, diedImmediately: true, stderrLines: ["boom"] });
    spawnSpy = spyOn(Bun, "spawn").mockReturnValue(proc as unknown as ReturnType<typeof Bun.spawn>);

    const client = new ClaudeCliClient("sk-test", "claude-opus-4-8", undefined, mockLogger());
    const res = await client.runAgent("do the thing", "/tmp/workdir", 60_000);

    expect(res.success).toBe(false);
    expect(res.exitCode).toBe(1);
    expect(res.output).toBe("");
  });

  test("sets ANTHROPIC_BASE_URL when a gateway base url is provided", async () => {
    const proc = makeFakeProc({
      exitCode: 0,
      stdoutLines: [JSON.stringify({ type: "result", subtype: "success", result: "ok" })],
    });
    spawnSpy = spyOn(Bun, "spawn").mockReturnValue(proc as unknown as ReturnType<typeof Bun.spawn>);

    const client = new ClaudeCliClient(
      "sk-test",
      "claude-opus-4-8",
      "https://glados.example",
      mockLogger(),
    );
    await client.runAgent("x", "/tmp/workdir", 60_000);

    const [, spawnOpts] = spawnSpy.mock.calls[0] as [string[], { env: Record<string, string> }];
    expect(spawnOpts.env.ANTHROPIC_BASE_URL).toBe("https://glados.example");
  });

  test("returns success:false on non-zero exit", async () => {
    const proc = makeFakeProc({ exitCode: 1, stdoutLines: [] });
    spawnSpy = spyOn(Bun, "spawn").mockReturnValue(proc as unknown as ReturnType<typeof Bun.spawn>);

    const client = new ClaudeCliClient("sk-test", "claude-opus-4-8", undefined, mockLogger());
    const res = await client.runAgent("x", "/tmp/workdir", 60_000);
    expect(res.success).toBe(false);
    expect(res.exitCode).toBe(1);
  });

  test("streams assistant text through the logger as structured claude_stream chunks", async () => {
    const proc = makeFakeProc({
      exitCode: 0,
      stdoutLines: [
        JSON.stringify({
          type: "stream_event",
          event: { type: "content_block_delta", delta: { type: "text_delta", text: "hel" } },
        }),
        JSON.stringify({
          type: "stream_event",
          event: { type: "content_block_delta", delta: { type: "text_delta", text: "lo" } },
        }),
        JSON.stringify({ type: "result", subtype: "success", result: "hello" }),
      ],
    });
    spawnSpy = spyOn(Bun, "spawn").mockReturnValue(proc as unknown as ReturnType<typeof Bun.spawn>);

    const logger = mockLogger();
    const client = new ClaudeCliClient("sk-test", "claude-opus-4-8", undefined, logger);
    await client.runAgent("x", "/tmp/workdir", 60_000);

    const infoCalls = (logger.info as ReturnType<typeof mock>).mock.calls;
    const streamCall = infoCalls.find((c) => c[1] === "claude_stream");
    expect(streamCall).toBeDefined();
    expect(streamCall?.[0]).toMatchObject({ phase: "text", chunk: "hello" });

    const resultCall = infoCalls.find(
      (c) => c[1] === "claude_event" && c[0]?.phase === "result_final",
    );
    expect(resultCall).toBeDefined();
  });

  test("truncates long stream text into chunks no larger than the field cap", async () => {
    const long = "a".repeat(20_000);
    const proc = makeFakeProc({
      exitCode: 0,
      stdoutLines: [
        JSON.stringify({
          type: "stream_event",
          event: { type: "content_block_delta", delta: { type: "text_delta", text: long } },
        }),
        JSON.stringify({ type: "result", subtype: "success", result: "ok" }),
      ],
    });
    spawnSpy = spyOn(Bun, "spawn").mockReturnValue(proc as unknown as ReturnType<typeof Bun.spawn>);

    const logger = mockLogger();
    const client = new ClaudeCliClient("sk-test", "claude-opus-4-8", undefined, logger);
    await client.runAgent("x", "/tmp/workdir", 60_000);

    const infoCalls = (logger.info as ReturnType<typeof mock>).mock.calls;
    const chunks = infoCalls
      .filter((c) => c[1] === "claude_stream")
      .map((c) => c[0].chunk as string);
    expect(chunks.length).toBeGreaterThan(1);
    for (const chunk of chunks) expect(chunk.length).toBeLessThanOrEqual(8 * 1024);
    expect(chunks.join("")).toBe(long);
  });
});

// ---------------------------------------------------------------------------
// ClaudeCliClient abort (AbortSignal)
// ---------------------------------------------------------------------------

import { JobAbortedError } from "@/core/errors.js";

describe("ClaudeCliClient abort", () => {
  test("rejects with JobAbortedError when the signal is already aborted", async () => {
    const client = new ClaudeCliClient("key", "model", undefined, mockLogger());
    const ac = new AbortController();
    ac.abort();
    await expect(
      client.runAgent("prompt", "/tmp", 1000, { signal: ac.signal }),
    ).rejects.toBeInstanceOf(JobAbortedError);
  });
});
