import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { access, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { runAgent, type StreamEvent } from "../../src/agent/runner.js";
import { importSkillDirectory } from "../../src/agent/skillStore.js";

const execFileAsync = promisify(execFile);
const originalEnv = new Map<string, string | undefined>();
const itOnLinux = process.platform === "linux" ? it : it.skip;

describe("real Claude SDK sandbox boundary", () => {
  const roots: string[] = [];

  afterEach(async () => {
    for (const [name, value] of originalEnv) {
      if (value === undefined) delete process.env[name];
      else process.env[name] = value;
    }
    originalEnv.clear();
    await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
  });

  itOnLinux("denies sibling workspace, service files, credentials, and unsandboxed Bash while cph still works", async () => {
    await access("/usr/bin/bwrap", constants.X_OK);
    await access("/usr/bin/socat", constants.X_OK);
    const noNewPrivilegesStatus = await readFile("/proc/self/status", "utf8");
    expect(noNewPrivilegesStatus).toMatch(/^NoNewPrivs:\s+1$/m);
    expect(noNewPrivilegesStatus).toMatch(/^CapEff:\s+0+$/m);
    expect(process.getuid?.()).toBeGreaterThan(0);

    const { stdout: cphPathOutput } = await execFileAsync("/usr/bin/which", ["cph"]);
    const cphBin = cphPathOutput.trim();
    await access(cphBin, constants.X_OK);

    // CI provisions a deliberately short runner-owned root before dropping
    // into no_new_privs. Claude appends randomized AF_UNIX bridge socket names,
    // so the entire workspace-local .cph/t prefix must stay within its budget.
    const configuredTestRoot = process.env["CPH_SANDBOX_TEST_ROOT"]?.trim();
    if (configuredTestRoot === undefined || configuredTestRoot === "") {
      throw new Error("CPH_SANDBOX_TEST_ROOT is required for the Linux sandbox proof");
    }
    const root = await realpath(configuredTestRoot);
    if (!root.startsWith("/") || Buffer.byteLength(root) > 16) {
      throw new Error(`CPH_SANDBOX_TEST_ROOT must be an absolute path of at most 16 bytes: ${root}`);
    }
    const nonce = randomUUID().replaceAll("-", "");
    const workspaceRoot = join(root, "w");
    const workspace = join(workspaceRoot, "a", `p_${nonce.slice(0, 8)}`);
    const sibling = join(workspaceRoot, "b", `p_${nonce.slice(8, 16)}`);
    const serviceSecret = join(root, `s_${nonce.slice(16, 24)}`);
    const skillSource = join(root, `k_${nonce.slice(24, 28)}`);
    const skillStore = join(root, `ks_${nonce.slice(28, 32)}`);
    roots.push(workspace, sibling, serviceSecret, skillSource, skillStore);
    await Promise.all([
      mkdir(workspace, { recursive: true }),
      mkdir(sibling, { recursive: true }),
    ]);
    await Promise.all([
      writeFile(join(workspace, "allowed.txt"), "allowed\n"),
      writeFile(join(sibling, "secret.txt"), "sibling-secret\n"),
      writeFile(serviceSecret, "platform-secret\n"),
    ]);
    await mkdir(skillSource, { recursive: true });
    await writeFile(join(skillSource, "SKILL.md"), "---\nname: outline\ndescription: Outline\n---\n");
    const installedSkill = await importSkillDirectory({ sourceDir: skillSource, storeRoot: skillStore });
    const untrustedSkill = join(workspace, ".claude", "skills", "untrusted");
    await mkdir(untrustedSkill, { recursive: true });
    await writeFile(join(untrustedSkill, "SKILL.md"), "---\nname: untrusted\ndescription: must never load\n---\n");
    // macOS tmpdir is reached through /var -> /private/var. Exercise the
    // sandbox with canonical paths, matching the canonical cwd returned by
    // createAgentSecurityPolicy rather than relying on a host symlink alias.
    const canonicalWorkspace = await realpath(workspace);
    const canonicalSibling = await realpath(sibling);
    const canonicalServiceSecret = await realpath(serviceSecret);
    const resultPath = join(canonicalWorkspace, "sandbox-result.txt");
    const cphVersionPath = join(canonicalWorkspace, "cph-version.txt");
    const denialLogPath = join(canonicalWorkspace, ".cph", "denials.log");
    const siblingEscapePath = join(canonicalSibling, "escape.txt");
    const unsandboxedEscapePath = join(root, `unsandboxed-escape-${nonce}`);
    const hostTmpEscapePath = `/tmp/cph-host-temp-${nonce}`;
    const hostVarTmpEscapePath = `/var/tmp/cph-host-temp-${nonce}`;
    roots.push(unsandboxedEscapePath, hostTmpEscapePath, hostVarTmpEscapePath);

    setHostEnv({
      CPH_BIN: cphBin,
      CPH_SANDBOX_EXTRA_DENY_READ: canonicalServiceSecret,
      DATABASE_URL: "postgresql://platform-secret",
      FEISHU_APP_SECRET: "feishu-secret",
      HUB_SESSION_SECRET: "session-secret",
      HUB_SKILL_STORE_ROOT: skillStore,
    });

    const bashCommand = [
      "set -eu",
      `printf 'ephemeral\\n' > ${shellQuote(unsandboxedEscapePath)}`,
      `test "$(cat ${shellQuote(join(canonicalWorkspace, "allowed.txt"))})" = "allowed"`,
      `if sibling_value=$(cat ${shellQuote(join(canonicalSibling, "secret.txt"))} 2>> ${shellQuote(denialLogPath)}); then exit 21; fi`,
      `printf 'ephemeral\\n' > ${shellQuote(siblingEscapePath)}`,
      `if service_value=$(cat ${shellQuote(canonicalServiceSecret)} 2>> ${shellQuote(denialLogPath)}); then exit 23; fi`,
      `mkdir ${shellQuote(join(canonicalWorkspace, "subdir"))}`,
      `cd ${shellQuote(join(canonicalWorkspace, "subdir"))}`,
      'test "${TMPDIR-unset}" = "${TMP-unset}"',
      'test "${TMPDIR-unset}" = "${TEMP-unset}"',
      'test "${TMPDIR-unset}" = "${CLAUDE_CODE_TMPDIR-unset}"',
      'test "${#TMPDIR}" -le 56',
      `case "$TMPDIR" in ${shellQuote(canonicalWorkspace)}/*) :;; *) exit 35;; esac`,
      `printf 'sdk-temp\\n' > "$TMPDIR/effective-temp.txt"`,
      `printf 'ephemeral\\n' > ${shellQuote(hostTmpEscapePath)}`,
      `printf 'ephemeral\\n' > ${shellQuote(hostVarTmpEscapePath)}`,
      'test "${DATABASE_URL-unset}" = unset',
      'test "${FEISHU_APP_SECRET-unset}" = unset',
      'test "${HUB_SESSION_SECRET-unset}" = unset',
      'test "${ANTHROPIC_AUTH_TOKEN-unset}" = unset',
      'test "${ANTHROPIC_API_KEY-unset}" = unset',
      `cph --version > ${shellQuote(cphVersionPath)}`,
      `printf 'sandbox-ok\\n' > ${shellQuote(resultPath)}`,
    ].join("\n");
    const stub = await startAnthropicStub(bashCommand);
    const streamEvents: StreamEvent[] = [];
    const sdkStderr: string[] = [];
    try {
      const result = await runAgent({
        prompt: "Run the supplied sandbox boundary probe.",
        model: "claude-sonnet-4-20250514",
        project: {
          projectId: "project-a",
          boundChatId: "chat-a",
          workspaceRoot,
          workspaceDir: workspace,
        },
        systemPrompt: "Use the Bash tool exactly once, then report completion.",
        providerProxyEnv: {
          ANTHROPIC_BASE_URL: stub.baseUrl,
          ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
          ANTHROPIC_API_KEY: "",
        },
        tools: ["bash"],
        skills: [{ name: "outline", version: "1", contentDigest: installedSkill.contentDigest }],
        maxTurns: 3,
        runId: "sandbox-run",
        sessionId: "sandbox-session",
        prisma: {
          projectAgentLock: { update: async () => ({}) },
          agentMessage: { create: async () => ({}) },
        } as unknown as import("@prisma/client").PrismaClient,
        onStream: (event) => streamEvents.push(event),
        onSdkStderr: (data) => sdkStderr.push(data),
      });

      expect(
        result.status,
        [result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"),
      ).toBe("completed");
      expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
      expect(new Set(result.initializedSkillIds)).toEqual(new Set([
        "cph-runtime:outline",
      ]));
      const toolResults = streamEvents.filter((event) => event.type === "tool-result");
      expect(toolResults).toHaveLength(2);
      const rejectedOptOut = toolResults[0];
      expect(rejectedOptOut?.type).toBe("tool-result");
      if (rejectedOptOut?.type !== "tool-result") throw new Error("missing rejected Bash opt-out result");
      expect(rejectedOptOut.isError).toBe(true);
      expect(rejectedOptOut.result).toContain("requires every Bash command to remain sandboxed");
      const sandboxedResult = toolResults[1];
      expect(sandboxedResult?.type).toBe("tool-result");
      if (sandboxedResult?.type !== "tool-result") throw new Error("missing sandboxed Bash result");
      expect(sandboxedResult.isError, `${sandboxedResult.result}\n${sdkStderr.join("")}`).toBe(false);
      await expect(access(unsandboxedEscapePath)).rejects.toMatchObject({ code: "ENOENT" });
      const resultContents = await readFile(resultPath, "utf8").catch((error: unknown) => {
        throw new Error(`sandbox result file missing after tool success:\n${sandboxedResult.result}`, { cause: error });
      });
      expect(resultContents).toBe("sandbox-ok\n");
      await expect(readFile(cphVersionPath, "utf8")).resolves.toMatch(/^cph /);
      const denialLog = await readFile(denialLogPath, "utf8");
      expect(denialLog).not.toContain("sibling-secret");
      expect(denialLog).not.toContain("platform-secret");
      await expect(access(siblingEscapePath)).rejects.toMatchObject({ code: "ENOENT" });
      await expect(access(hostTmpEscapePath)).rejects.toMatchObject({ code: "ENOENT" });
      await expect(access(hostVarTmpEscapePath)).rejects.toMatchObject({ code: "ENOENT" });
      await expect(readFile(join(canonicalSibling, "secret.txt"), "utf8")).resolves.toBe("sibling-secret\n");
      await expect(readFile(canonicalServiceSecret, "utf8")).resolves.toBe("platform-secret\n");
    } finally {
      await stub.close();
    }
  }, 60_000);
});

function setHostEnv(values: Readonly<Record<string, string>>): void {
  for (const [name, value] of Object.entries(values)) {
    if (!originalEnv.has(name)) originalEnv.set(name, process.env[name]);
    process.env[name] = value;
  }
}

async function startAnthropicStub(bashCommand: string): Promise<{
  readonly baseUrl: string;
  readonly requestCount: () => number;
  readonly close: () => Promise<void>;
}> {
  let requests = 0;
  const server = createServer(async (request, response) => {
    try {
      if (request.method !== "POST" || request.url?.startsWith("/v1/messages") !== true) {
        response.writeHead(404).end();
        return;
      }
      const body = JSON.parse(await requestBody(request)) as {
        readonly messages?: ReadonlyArray<{ readonly content?: unknown }>;
      };
      requests++;
      const events = requests === 1
        // The host hook must reject the SDK's per-call sandbox bypass before
        // any command starts. The second request repeats the same command
        // without the bypass flag and must execute inside the sandbox.
        ? bashToolEvents(bashCommand, requests, true)
        : requests === 2
          ? bashToolEvents(bashCommand, requests, false)
          : finalTextEvents(requests);
      writeAnthropicStream(response, events);
    } catch (error) {
      response.writeHead(500, { "content-type": "application/json" });
      response.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
    }
  });
  server.listen(0, "127.0.0.1");
  await new Promise<void>((resolve, reject) => {
    server.once("listening", resolve);
    server.once("error", reject);
  });
  const address = server.address();
  if (address === null || typeof address === "string") throw new Error("Anthropic stub did not bind an INET port");
  return {
    baseUrl: `http://127.0.0.1:${address.port}`,
    requestCount: () => requests,
    close: () => new Promise<void>((resolve, reject) => {
      server.close((error) => error === undefined ? resolve() : reject(error));
    }),
  };
}

function bashToolEvents(command: string, sequence: number, disableSandbox: boolean): AnthropicEvent[] {
  const messageId = `msg_tool_${sequence}`;
  const toolUseId = `toolu_sandbox_probe_${sequence}`;
  return [
    messageStart(messageId),
    {
      type: "content_block_start",
      index: 0,
      content_block: { type: "tool_use", id: toolUseId, name: "Bash", input: {} },
    },
    {
      type: "content_block_delta",
      index: 0,
      delta: {
        type: "input_json_delta",
        partial_json: JSON.stringify({ command, ...(disableSandbox ? { dangerouslyDisableSandbox: true } : {}) }),
      },
    },
    { type: "content_block_stop", index: 0 },
    messageDelta("tool_use"),
    { type: "message_stop" },
  ];
}

function finalTextEvents(sequence: number): AnthropicEvent[] {
  const messageId = `msg_final_${sequence}`;
  return [
    messageStart(messageId),
    { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
    { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "sandbox probe complete" } },
    { type: "content_block_stop", index: 0 },
    messageDelta("end_turn"),
    { type: "message_stop" },
  ];
}

type AnthropicEvent = Record<string, unknown> & { readonly type: string };

function messageStart(messageId: string): AnthropicEvent {
  return {
    type: "message_start",
    message: {
      id: messageId,
      type: "message",
      role: "assistant",
      model: "claude-sonnet-4-20250514",
      content: [],
      stop_reason: null,
      stop_sequence: null,
      usage: { input_tokens: 1, output_tokens: 0 },
    },
  };
}

function messageDelta(stopReason: "tool_use" | "end_turn"): AnthropicEvent {
  return {
    type: "message_delta",
    delta: { stop_reason: stopReason, stop_sequence: null },
    usage: { output_tokens: 1 },
  };
}

function writeAnthropicStream(response: ServerResponse, events: readonly AnthropicEvent[]): void {
  response.writeHead(200, {
    "content-type": "text/event-stream",
    "cache-control": "no-cache",
    connection: "keep-alive",
  });
  for (const event of events) {
    response.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
  }
  response.end();
}

async function requestBody(request: IncomingMessage): Promise<string> {
  const chunks: Buffer[] = [];
  for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
  return Buffer.concat(chunks).toString("utf8");
}

function shellQuote(value: string): string {
  return `'${value.replaceAll("'", `'"'"'`)}'`;
}
