import { describe, expect, it } from "vitest";
import { existsSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { LocalSandbox } from "./local-sandbox.js";

describe("LocalSandbox", () => {
  it("keeps /dev/null available in read-only command sandboxes", async () => {
    const sandbox = new LocalSandbox();
    const cwd = mkdtempSync(join(tmpdir(), "best-agent-local-sandbox-dev-null-"));

    const result = await sandbox.runCommandCapture(
      [
        process.execPath,
        "-e",
        "const fs = require('node:fs'); const fd = fs.openSync('/dev/null', 'r+'); fs.closeSync(fd);",
      ],
      cwd,
      { timeoutMs: 2_000 },
    );

    expect(result.exitCode).toBe(0);
    expect(result.stderr).toBe("");
  });

  it("allows writes for no-network command sandboxes", async () => {
    const sandbox = new LocalSandbox();
    const cwd = mkdtempSync(join(tmpdir(), "best-agent-local-sandbox-no-network-write-"));

    const result = await sandbox.runCommandCapture(
      ["/bin/sh", "-lc", "printf ok > sandbox-write.txt"],
      cwd,
      { sandboxProfile: "no-network", timeoutMs: 2_000 },
    );

    expect(result.exitCode).toBe(0);
    expect(existsSync(join(cwd, "sandbox-write.txt"))).toBe(true);
  });

  it("reports live command output chunks before command completion", async () => {
    const sandbox = new LocalSandbox();
    const cwd = mkdtempSync(join(tmpdir(), "best-agent-local-sandbox-live-output-"));
    const outputs: Array<{ stream: "stderr" | "stdout"; text: string }> = [];

    const pending = sandbox.runCommandCapture(
      [
        process.execPath,
        "-e",
        "process.stdout.write('ready'); setTimeout(() => { process.stderr.write('warn'); process.stdout.write('done'); }, 150);",
      ],
      cwd,
      {
        onOutput: (output) => {
          outputs.push(output);
        },
        timeoutMs: 5_000,
      },
    );

    await waitUntil(() =>
      outputs.some((output) => output.stream === "stdout" && output.text.includes("ready")),
    );
    expect(outputs.some((output) => output.text.includes("done"))).toBe(false);

    const result = await pending;
    expect(result.stdout).toBe("readydone");
    expect(result.stderr).toBe("warn");
    expect(outputs).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ stream: "stdout", text: expect.stringContaining("ready") }),
        expect.objectContaining({ stream: "stderr", text: expect.stringContaining("warn") }),
      ]),
    );
  });

  it("heartbeats while a command is running without output", async () => {
    const sandbox = new LocalSandbox();
    const cwd = mkdtempSync(join(tmpdir(), "best-agent-local-sandbox-heartbeat-"));
    let heartbeats = 0;

    const result = await sandbox.runCommandCapture(
      [process.execPath, "-e", "setTimeout(() => {}, 1200);"],
      cwd,
      {
        onHeartbeat: () => {
          heartbeats += 1;
        },
        timeoutMs: 5_000,
      },
    );

    expect(result.exitCode).toBe(0);
    expect(heartbeats).toBeGreaterThan(0);
  });

  it("times out commands even when a background child keeps stdout or stderr open", async () => {
    const sandbox = new LocalSandbox();
    const cwd = mkdtempSync(join(tmpdir(), "best-agent-local-sandbox-"));
    const startedAt = Date.now();

    const result = await sandbox.runCommandCapture(
      [
        process.execPath,
        "-e",
        [
          "const { spawn } = require('node:child_process');",
          "const child = spawn(process.execPath, ['-e', 'process.on(\"SIGTERM\", () => {}); setInterval(() => {}, 1000)'], { stdio: 'inherit' });",
          "console.log(child.pid);",
          "process.on('SIGTERM', () => process.exit(143));",
          "setInterval(() => {}, 1000);",
        ].join(" "),
      ],
      cwd,
      { timeoutMs: 200 },
    );

    expect(result.timedOut).toBe(true);
    expect(Date.now() - startedAt).toBeLessThan(5_000);
    const childPid = Number(result.stdout.trim());
    await waitForProcessExit(childPid);
  });

  it("kills detached descendants when a command times out", async () => {
    const sandbox = new LocalSandbox();
    const cwd = mkdtempSync(join(tmpdir(), "best-agent-local-sandbox-detached-timeout-"));

    const result = await sandbox.runCommandCapture(
      [
        process.execPath,
        "-e",
        [
          "const { spawn } = require('node:child_process');",
          "const child = spawn(process.execPath, ['-e', 'process.on(\"SIGTERM\", () => {}); setInterval(() => {}, 1000)'], { detached: true, stdio: 'ignore' });",
          "console.log(child.pid);",
          "setInterval(() => {}, 1000);",
        ].join(" "),
      ],
      cwd,
      { timeoutMs: 200 },
    );

    expect(result.timedOut).toBe(true);
    const childPid = Number(result.stdout.trim());
    await waitForProcessExit(childPid, 5_000);
  });

  it("kills detached descendants when a command is aborted", async () => {
    const sandbox = new LocalSandbox();
    const cwd = mkdtempSync(join(tmpdir(), "best-agent-local-sandbox-detached-abort-"));
    const controller = new AbortController();
    const startedAt = Date.now();

    const pending = sandbox.runCommandCapture(
      [
        process.execPath,
        "-e",
        [
          "const { spawn } = require('node:child_process');",
          "const child = spawn(process.execPath, ['-e', 'process.on(\"SIGTERM\", () => {}); setInterval(() => {}, 1000)'], { detached: true, stdio: 'ignore' });",
          "console.log(child.pid);",
          "setInterval(() => {}, 1000);",
        ].join(" "),
      ],
      cwd,
      { signal: controller.signal, timeoutMs: 10_000 },
    );

    await new Promise((resolve) => setTimeout(resolve, 200));
    controller.abort();
    const result = await pending;

    expect(result.aborted).toBe(true);
    expect(Date.now() - startedAt).toBeLessThan(5_000);
    const childPid = Number(result.stdout.trim());
    await waitForProcessExit(childPid, 5_000);
  });

  it("returns soon after command exit when a background child keeps stdout open", async () => {
    const sandbox = new LocalSandbox();
    const cwd = mkdtempSync(join(tmpdir(), "best-agent-local-sandbox-bg-"));
    const startedAt = Date.now();

    const result = await sandbox.runCommandCapture(
      ["/bin/sh", "-lc", "sleep 5 & echo $!; echo done"],
      cwd,
      { timeoutMs: 10_000 },
    );

    expect(result.timedOut).toBe(false);
    expect(result.exitCode).toBe(0);
    expect(result.stdout).toContain("done\n");
    expect(Date.now() - startedAt).toBeLessThan(2_000);
    const childPid = Number(result.stdout.split("\n")[0]);
    await waitForProcessExit(childPid);
  });

  it("cleans up a background child when the command exits by signal", async () => {
    const sandbox = new LocalSandbox();
    const cwd = mkdtempSync(join(tmpdir(), "best-agent-local-sandbox-signal-"));
    const startedAt = Date.now();

    const result = await sandbox.runCommandCapture(
      ["/bin/sh", "-lc", "sleep 5 & echo $!; kill -TERM $$"],
      cwd,
      { timeoutMs: 10_000 },
    );

    expect(result.timedOut).toBe(false);
    expect(result.signalCode).toBe("SIGTERM");
    expect(Date.now() - startedAt).toBeLessThan(2_000);
    const childPid = Number(result.stdout.split("\n")[0]);
    await waitForProcessExit(childPid);
  });

  it("reports foreground command signal termination", async () => {
    const sandbox = new LocalSandbox();
    const cwd = mkdtempSync(join(tmpdir(), "best-agent-local-sandbox-foreground-signal-"));

    const result = await sandbox.runCommandCapture(
      ["/bin/sh", "-lc", "printf before; kill -SEGV $$"],
      cwd,
      { timeoutMs: 10_000 },
    );

    expect(result.exitCode).not.toBe(0);
    expect(result.signalCode ?? result.exitCode).toBeTruthy();
    expect(result.stdout).toContain("before");
  });

  it("runs concurrent commands independently", async () => {
    const sandbox = new LocalSandbox();
    const cwd = mkdtempSync(join(tmpdir(), "best-agent-local-sandbox-concurrent-"));

    const results = await Promise.all(
      [1, 2, 3].map((value) =>
        sandbox.runCommandCapture(["/bin/sh", "-lc", `sleep 0.${value}; echo ${value}`], cwd, {
          timeoutMs: 5_000,
        }),
      ),
    );

    expect(results.map((result) => result.stdout).sort()).toEqual(["1\n", "2\n", "3\n"]);
  });

  it("stops process sessions with final output and evicts retained state", async () => {
    const sandbox = new LocalSandbox();
    const cwd = mkdtempSync(join(tmpdir(), "best-agent-process-session-stop-"));

    const started = sandbox.startProcessSession(
      [
        process.execPath,
        "-e",
        "process.stdout.write('ready\\n'); process.on('SIGTERM', () => process.exit(143)); setInterval(() => {}, 1000);",
      ],
      cwd,
      "node session",
    );
    const running = await sandbox.readProcessSession(started.id, { waitMs: 1_000 });
    expect(running.stdout).toContain("ready");

    const stopped = await sandbox.stopProcessSession(started.id);

    expect(stopped.status).toBe("completed");
    expect(stopped.stdout).toContain("ready");
    expect(stopped.exitCode ?? stopped.signalCode).toBeTruthy();
    await expect(sandbox.readProcessSession(started.id)).rejects.toThrow(/Unknown process session/);
  });

  it("can keep waiting on a process session after output arrives", async () => {
    const sandbox = new LocalSandbox();
    const cwd = mkdtempSync(join(tmpdir(), "best-agent-process-session-wait-"));

    const started = sandbox.startProcessSession(
      [
        process.execPath,
        "-e",
        "setTimeout(() => process.stdout.write('tick\\n'), 30); setInterval(() => {}, 1000);",
      ],
      cwd,
      "node session",
    );
    const startedAt = Date.now();
    const running = await sandbox.readProcessSession(started.id, {
      waitMs: 180,
      wakeOnOutput: false,
    });

    expect(Date.now() - startedAt).toBeGreaterThanOrEqual(140);
    expect(running.status).toBe("running");
    expect(running.stdout).toContain("tick");

    await sandbox.stopProcessSession(started.id);
  });

  it("aborts a waiting process session read", async () => {
    const sandbox = new LocalSandbox();
    const cwd = mkdtempSync(join(tmpdir(), "best-agent-process-session-abort-read-"));
    const controller = new AbortController();

    const started = sandbox.startProcessSession(
      [process.execPath, "-e", "setInterval(() => {}, 1000);"],
      cwd,
      "node session",
    );
    const read = sandbox.readProcessSession(started.id, {
      signal: controller.signal,
      waitMs: 5_000,
    });
    controller.abort();

    await expect(read).rejects.toMatchObject({ name: "AbortError" });
    await sandbox.stopProcessSession(started.id);
  });

  it("closes active process sessions", async () => {
    const sandbox = new LocalSandbox();
    const cwd = mkdtempSync(join(tmpdir(), "best-agent-process-session-close-"));

    const started = sandbox.startProcessSession(
      [process.execPath, "-e", "console.log(process.pid); setInterval(() => {}, 1000);"],
      cwd,
      "node session",
    );
    const running = await sandbox.readProcessSession(started.id, { waitMs: 1_000 });
    const pid = Number(running.stdout.trim());

    await sandbox.close();

    await expect(sandbox.readProcessSession(started.id)).rejects.toThrow(/Unknown process session/);
    await waitForProcessExit(pid, 5_000);
  });
});

async function waitUntil(predicate: () => boolean, timeoutMs = 1_000): Promise<void> {
  const startedAt = Date.now();
  while (!predicate() && Date.now() - startedAt < timeoutMs) {
    await new Promise((resolve) => setTimeout(resolve, 10));
  }
  expect(predicate()).toBe(true);
}

async function waitForProcessExit(pid: number, timeoutMs = 2_000): Promise<void> {
  const startedAt = Date.now();
  while (isProcessAlive(pid) && Date.now() - startedAt < timeoutMs) {
    await new Promise((resolve) => setTimeout(resolve, 25));
  }
  expect(isProcessAlive(pid)).toBe(false);
}

function isProcessAlive(pid: number): boolean {
  try {
    process.kill(pid, 0);
    return true;
  } catch {
    return false;
  }
}
