import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
import { tmpdir } from "node:os";
import { mkdtempSync, rmSync } from "node:fs";
import { CursorCliClient } from "@/agent/cursor-cli-client.js";
import { JobTimeoutError } from "@/core/errors.js";
import { mockLogger } from "../_helpers/mock-logger.js";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

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 makeEmptyStream(): ReadableStream<Uint8Array> {
  return makeStream([]);
}

/** Fake Bun.SpawnOptions.Writable-compatible process shape. */
interface FakeProc {
  pid: number;
  exitCode: number | null;
  exited: Promise<number>;
  stdout: ReadableStream<Uint8Array>;
  stderr: ReadableStream<Uint8Array>;
  kill: ReturnType<typeof mock>;
}

function makeFakeProc(opts: {
  exitCode?: number | null;
  exited?: Promise<number>;
  stdoutLines?: string[];
  stderrLines?: string[];
}): FakeProc {
  const exitCode = opts.exitCode ?? null;
  const exitCodeForExited = typeof opts.exitCode === "number" ? opts.exitCode : 0;
  return {
    pid: 12345,
    exitCode,
    exited: opts.exited ?? Promise.resolve(exitCodeForExited),
    stdout: makeStream(opts.stdoutLines ?? []),
    stderr: makeStream(opts.stderrLines ?? []),
    kill: mock(() => {}),
  };
}

// ---------------------------------------------------------------------------
// Static method: extractAssistantText
// ---------------------------------------------------------------------------

describe("CursorCliClient.extractAssistantText", () => {
  test("empty array → empty string", () => {
    expect(CursorCliClient.extractAssistantText([])).toBe("");
  });

  test("non-JSON lines are skipped", () => {
    expect(CursorCliClient.extractAssistantText(["not json", "also not json"])).toBe("");
  });

  test("unknown event types produce no output", () => {
    const line = JSON.stringify({ type: "tool_call", name: "run_command" });
    expect(CursorCliClient.extractAssistantText([line])).toBe("");
  });

  test("assistant event with message.content array of text blocks → joined text", () => {
    const line = JSON.stringify({
      type: "assistant",
      message: {
        content: [
          { type: "text", text: "Hello" },
          { type: "text", text: "World" },
        ],
      },
    });
    expect(CursorCliClient.extractAssistantText([line])).toBe("Hello\nWorld");
  });

  test("assistant event with message.content array skips non-text blocks", () => {
    const line = JSON.stringify({
      type: "assistant",
      message: {
        content: [
          { type: "tool_use", name: "run_command" },
          { type: "text", text: "Done!" },
        ],
      },
    });
    expect(CursorCliClient.extractAssistantText([line])).toBe("Done!");
  });

  test("assistant event with content string → returns it", () => {
    const line = JSON.stringify({ type: "assistant", content: "Simple answer" });
    expect(CursorCliClient.extractAssistantText([line])).toBe("Simple answer");
  });

  test("assistant event with no content and no message.content → produces empty and falls through", () => {
    const line = JSON.stringify({ type: "assistant" });
    // No text extracted; result is empty
    expect(CursorCliClient.extractAssistantText([line])).toBe("");
  });

  test("agent_turn_end event with result string → used as fallback when no assistant message", () => {
    const line = JSON.stringify({ type: "agent_turn_end", result: "Turn result" });
    expect(CursorCliClient.extractAssistantText([line])).toBe("Turn result");
  });

  test("agent_turn_end result is ignored when there is an assistant message", () => {
    const assistantLine = JSON.stringify({
      type: "assistant",
      message: { content: [{ type: "text", text: "Assistant text" }] },
    });
    const turnEndLine = JSON.stringify({ type: "agent_turn_end", result: "Should be ignored" });
    expect(CursorCliClient.extractAssistantText([assistantLine, turnEndLine])).toBe(
      "Assistant text",
    );
  });

  test("multiple assistant events → returns text from the LAST one", () => {
    const first = JSON.stringify({
      type: "assistant",
      message: { content: [{ type: "text", text: "First response" }] },
    });
    const second = JSON.stringify({
      type: "assistant",
      message: { content: [{ type: "text", text: "Final response" }] },
    });
    expect(CursorCliClient.extractAssistantText([first, second])).toBe("Final response");
  });

  test("mixed events → only assistant text extracted", () => {
    const lines = [
      JSON.stringify({ type: "tool_call", name: "bash", arguments: '{"cmd":"ls"}' }),
      JSON.stringify({ type: "tool_result", output: "file.ts\n" }),
      JSON.stringify({ type: "text_delta", content: "partial thinking..." }),
      JSON.stringify({
        type: "assistant",
        message: { content: [{ type: "text", text: "All done." }] },
      }),
    ];
    expect(CursorCliClient.extractAssistantText(lines)).toBe("All done.");
  });

  test("result is trimmed of surrounding whitespace", () => {
    const line = JSON.stringify({ type: "assistant", content: "  some spaces  " });
    expect(CursorCliClient.extractAssistantText([line])).toBe("some spaces");
  });

  test("agent_turn_end result is trimmed", () => {
    const line = JSON.stringify({ type: "agent_turn_end", result: "  trimmed  " });
    expect(CursorCliClient.extractAssistantText([line])).toBe("trimmed");
  });

  test("agent_turn_end with non-string result is ignored", () => {
    const line = JSON.stringify({ type: "agent_turn_end", result: 42 });
    expect(CursorCliClient.extractAssistantText([line])).toBe("");
  });

  test("agent_turn_end with empty string result is ignored (empty)", () => {
    const line = JSON.stringify({ type: "agent_turn_end", result: "" });
    expect(CursorCliClient.extractAssistantText([line])).toBe("");
  });
});

// ---------------------------------------------------------------------------
// runAgent — integration tests with Bun.spawn + Bun.file mocked
// ---------------------------------------------------------------------------

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

  beforeEach(() => {
    // Default: cursor-agent binary does NOT exist → falls back to "cursor"
    fileSpy = spyOn(Bun, "file").mockReturnValue({
      exists: async () => false,
    } as ReturnType<typeof Bun.file>);
  });

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

  test("happy path — exit 0, parses assistant text", async () => {
    const assistantLine = JSON.stringify({
      type: "assistant",
      message: { content: [{ type: "text", text: "Fix applied successfully." }] },
    });

    const proc = makeFakeProc({
      stdoutLines: [assistantLine],
      exitCode: null,
      exited: Promise.resolve(0),
    });

    spawnSpy = spyOn(Bun, "spawn").mockReturnValue(proc as unknown as ReturnType<typeof Bun.spawn>);

    const client = new CursorCliClient("api-key", "claude-3-7-sonnet", mockLogger());
    const result = await client.runAgent("Fix the bug", "/tmp/workdir", 30_000);

    expect(result.success).toBe(true);
    expect(result.exitCode).toBe(0);
    expect(result.output).toBe("Fix applied successfully.");
    expect(spawnSpy).toHaveBeenCalledTimes(1);
  });

  test("non-zero exit code → success: false", async () => {
    const proc = makeFakeProc({
      stdoutLines: [],
      exitCode: null,
      exited: Promise.resolve(1),
    });

    spawnSpy = spyOn(Bun, "spawn").mockReturnValue(proc as unknown as ReturnType<typeof Bun.spawn>);

    const client = new CursorCliClient("api-key", "claude-3-7-sonnet", mockLogger());
    const result = await client.runAgent("Fix the bug", "/tmp/workdir", 30_000);

    expect(result.success).toBe(false);
    expect(result.exitCode).toBe(1);
  });

  test("process dies immediately (exitCode non-null on spawn) → returns immediately", async () => {
    // exitCode already set (non-null) → the early-exit branch triggers
    const proc = makeFakeProc({
      exitCode: 1,
      exited: Promise.resolve(1),
      stdoutLines: [],
    });

    spawnSpy = spyOn(Bun, "spawn").mockReturnValue(proc as unknown as ReturnType<typeof Bun.spawn>);

    const client = new CursorCliClient("api-key", "claude-3-7-sonnet", mockLogger());
    const result = await client.runAgent("Fix the bug", "/tmp/workdir", 30_000);

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

  test("binary selection: cursor-agent bin exists → uses cursor-agent path", async () => {
    fileSpy.mockRestore();
    fileSpy = spyOn(Bun, "file").mockReturnValue({
      exists: async () => true,
    } as ReturnType<typeof Bun.file>);

    const proc = makeFakeProc({ exitCode: null, exited: Promise.resolve(0) });
    spawnSpy = spyOn(Bun, "spawn").mockReturnValue(proc as unknown as ReturnType<typeof Bun.spawn>);

    const client = new CursorCliClient("api-key", "claude-3-7-sonnet", mockLogger());
    await client.runAgent("prompt", "/tmp/workdir", 30_000);

    const [cmdArgs] = spawnSpy.mock.calls[0] as [string[], unknown];
    expect(cmdArgs[0]).toContain("cursor-agent");
  });

  test("binary selection: cursor-agent bin absent → falls back to 'cursor'", async () => {
    // fileSpy already mocked to return false in beforeEach
    const proc = makeFakeProc({ exitCode: null, exited: Promise.resolve(0) });
    spawnSpy = spyOn(Bun, "spawn").mockReturnValue(proc as unknown as ReturnType<typeof Bun.spawn>);

    const client = new CursorCliClient("api-key", "claude-3-7-sonnet", mockLogger());
    await client.runAgent("prompt", "/tmp/workdir", 30_000);

    const [cmdArgs] = spawnSpy.mock.calls[0] as [string[], unknown];
    expect(cmdArgs[0]).toBe("cursor");
  });

  test("large prompt (>128KB) → spawn receives the 'Read and follow ALL instructions' redirect", async () => {
    const bigPrompt = "x".repeat(128 * 1024 + 1);

    const proc = makeFakeProc({ exitCode: null, exited: Promise.resolve(0) });
    spawnSpy = spyOn(Bun, "spawn").mockReturnValue(proc as unknown as ReturnType<typeof Bun.spawn>);

    const client = new CursorCliClient("api-key", "claude-3-7-sonnet", mockLogger());
    // Use a real temp workdir so mkdirSync / writeFileSync / rmSync work
    const tmpDir = mkdtempSync(`${tmpdir()}/hu-test-`);

    try {
      await client.runAgent(bigPrompt, tmpDir, 30_000);
    } finally {
      rmSync(tmpDir, { recursive: true, force: true });
    }

    const [cmdArgs] = spawnSpy.mock.calls[0] as [string[], unknown];
    // cmdArgs is [bin, "agent", effectivePrompt, ...cliFlags]
    const effectivePrompt = cmdArgs[2];
    expect(effectivePrompt).toMatch(/Read and follow ALL instructions in the file/);
  });

  test("issueKey option tags the logger with a child", async () => {
    const logger = mockLogger();
    const proc = makeFakeProc({ exitCode: null, exited: Promise.resolve(0) });
    spawnSpy = spyOn(Bun, "spawn").mockReturnValue(proc as unknown as ReturnType<typeof Bun.spawn>);

    const client = new CursorCliClient("api-key", "claude-3-7-sonnet", logger);
    await client.runAgent("Fix this", "/tmp/workdir", 30_000, { issueKey: "PROJ-123" });

    // child() should have been called with the issueKey
    const childMock = logger.child as ReturnType<typeof mock>;
    expect(childMock).toHaveBeenCalledWith({ issueKey: "PROJ-123" });
  });

  test("streams assistant text through the logger as structured cursor_stream chunks", async () => {
    const proc = makeFakeProc({
      stdoutLines: [
        JSON.stringify({ type: "text_delta", content: "hel" }),
        JSON.stringify({ type: "text_delta", content: "lo" }),
        JSON.stringify({ type: "agent_turn_end", result: "hello" }),
      ],
      exitCode: null,
      exited: Promise.resolve(0),
    });
    spawnSpy = spyOn(Bun, "spawn").mockReturnValue(proc as unknown as ReturnType<typeof Bun.spawn>);

    const logger = mockLogger();
    const client = new CursorCliClient("api-key", "claude-3-7-sonnet", logger);
    await client.runAgent("Fix the bug", "/tmp/workdir", 30_000);

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

  test("truncates long stream text into chunks no larger than the field cap", async () => {
    const long = "a".repeat(20_000);
    const proc = makeFakeProc({
      stdoutLines: [JSON.stringify({ type: "text_delta", content: long })],
      exitCode: null,
      exited: Promise.resolve(0),
    });
    spawnSpy = spyOn(Bun, "spawn").mockReturnValue(proc as unknown as ReturnType<typeof Bun.spawn>);

    const logger = mockLogger();
    const client = new CursorCliClient("api-key", "claude-3-7-sonnet", logger);
    await client.runAgent("Fix the bug", "/tmp/workdir", 30_000);

    const infoCalls = (logger.info as ReturnType<typeof mock>).mock.calls;
    const chunks = infoCalls
      .filter((c) => c[1] === "cursor_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);
  });

  test("stdout with multiple events — only last assistant text is returned", async () => {
    const firstAssistant = JSON.stringify({
      type: "assistant",
      message: { content: [{ type: "text", text: "Intermediate step" }] },
    });
    const toolCall = JSON.stringify({ type: "tool_call", name: "bash" });
    const lastAssistant = JSON.stringify({
      type: "assistant",
      message: { content: [{ type: "text", text: "Final answer" }] },
    });

    const proc = makeFakeProc({
      stdoutLines: [firstAssistant, toolCall, lastAssistant],
      exitCode: null,
      exited: Promise.resolve(0),
    });

    spawnSpy = spyOn(Bun, "spawn").mockReturnValue(proc as unknown as ReturnType<typeof Bun.spawn>);

    const client = new CursorCliClient("api-key", "claude-3-7-sonnet", mockLogger());
    const result = await client.runAgent("Fix the bug", "/tmp/workdir", 30_000);

    expect(result.success).toBe(true);
    expect(result.output).toBe("Final answer");
  });

  test("timeout → throws JobTimeoutError", async () => {
    // exited resolves only when kill() is called — simulates a hung process
    let resolveExited!: (code: number) => void;
    const exitedPromise = new Promise<number>((r) => {
      resolveExited = r;
    });

    const proc: FakeProc = {
      pid: 99999,
      exitCode: null,
      exited: exitedPromise,
      stdout: makeEmptyStream(),
      stderr: makeEmptyStream(),
      kill: mock(() => {
        // First kill (SIGTERM) triggers process exit
        resolveExited(137);
      }),
    };

    spawnSpy = spyOn(Bun, "spawn").mockReturnValue(proc as unknown as ReturnType<typeof Bun.spawn>);

    const client = new CursorCliClient("api-key", "claude-3-7-sonnet", mockLogger());
    // Very short timeout — 50ms
    await expect(client.runAgent("Fix the bug", "/tmp/workdir", 50)).rejects.toThrow(
      JobTimeoutError,
    );

    // kill should have been called with SIGTERM
    expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
  }, 5_000);
});

// ---------------------------------------------------------------------------
// CursorCliClient abort (AbortSignal)
// ---------------------------------------------------------------------------

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

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