import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
import type { RoleSkillEntry } from "./models.js";
import { prepareRunSkillPlugin, readSkillStoreRoot } from "./skillStore.js";

const PROVIDER_ENV_KEYS = new Set([
  "ANTHROPIC_BASE_URL",
  "ANTHROPIC_AUTH_TOKEN",
  "ANTHROPIC_API_KEY",
]);

const SAFE_HOST_ENV_KEYS = [
  "PATH",
  "LANG",
  "LC_ALL",
  "LC_CTYPE",
  "TZ",
  "TERM",
  "USER",
  "LOGNAME",
  "SHELL",
  "CPH_BIN",
] as const;

const SANDBOX_HIDDEN_ENV_KEYS = [
  "ANTHROPIC_AUTH_TOKEN",
  "ANTHROPIC_API_KEY",
] as const;

// Linux sockaddr_un.sun_path is 108 bytes including the terminator. Claude's
// sandbox appends its own user directory and randomized bridge socket names,
// so keep our prefix well below that hard limit.
const MAX_AGENT_TMP_PREFIX_BYTES = 56;

export interface AgentSecurityInput {
  readonly runId: string;
  readonly workspaceRoot: string;
  readonly workspaceDir: string;
  readonly skills?: readonly RoleSkillEntry[] | undefined;
  /** Run-scoped loopback proxy capability; customer provider secrets are forbidden here. */
  readonly providerProxyEnv?: Readonly<Record<string, string | undefined>> | undefined;
  readonly hostEnv?: Readonly<Record<string, string | undefined>> | undefined;
}

export interface AgentSandboxPolicy {
  readonly enabled: true;
  readonly failIfUnavailable: true;
  readonly autoAllowBashIfSandboxed: true;
  readonly allowUnsandboxedCommands: false;
  readonly filesystem: {
    readonly allowWrite: string[];
    readonly denyWrite: string[];
    readonly denyRead: string[];
    readonly allowRead: string[];
  };
  readonly credentials: {
    readonly files: Array<{ readonly path: string; readonly mode: "deny" }>;
    readonly envVars: Array<{ readonly name: string; readonly mode: "deny" }>;
  };
}

export interface AgentSecurityPolicy {
  readonly cwd: string;
  readonly workspaceRoot: string;
  readonly env: Record<string, string | undefined>;
  readonly skillIds: readonly string[];
  readonly skillPluginRoot?: string | undefined;
  cleanup(): Promise<void>;
  readonly sandbox: AgentSandboxPolicy;
}

/**
 * Build the complete Claude subprocess boundary from trusted run context.
 * This function intentionally replaces, rather than extends, process.env.
 */
export async function createAgentSecurityPolicy(input: AgentSecurityInput): Promise<AgentSecurityPolicy> {
  const hostEnv = input.hostEnv ?? process.env;
  const { workspaceRoot, workspaceDir } = await resolveProjectWorkspace(input.workspaceRoot, input.workspaceDir);

  const cphRoot = await ensureDirectoryTree(workspaceDir, [".cph"]);
  const runtimeRoot = await ensureDirectoryTree(cphRoot, ["agent-runtime"]);
  const agentHome = await ensureDirectoryTree(runtimeRoot, ["home"]);
  const agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]);
  const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]);
  const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]);
  const agentTmp = await ensureDirectoryTree(cphRoot, ["t"]);
  assertShortAgentTemp(agentTmp);

  const path = hostEnv["PATH"]?.trim();
  if (path === undefined || path === "") {
    throw new Error("PATH is required for the Agent subprocess");
  }

  const env: Record<string, string | undefined> = {};
  for (const name of SAFE_HOST_ENV_KEYS) {
    const value = hostEnv[name];
    if (value !== undefined) env[name] = value;
  }
  env.HOME = agentHome;
  env.XDG_CACHE_HOME = agentCache;
  env.XDG_CONFIG_HOME = agentConfig;
  env.XDG_STATE_HOME = agentState;
  // All four variables must use the short path. Claude's sandbox bridge uses
  // the ordinary temp variables, while other SDK paths use CLAUDE_CODE_TMPDIR.
  env.TMPDIR = agentTmp;
  env.TMP = agentTmp;
  env.TEMP = agentTmp;
  env.CLAUDE_CONFIG_DIR = agentConfig;
  env.CLAUDE_CODE_TMPDIR = agentTmp;
  env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
  env.DISABLE_TELEMETRY = "1";
  env.DO_NOT_TRACK = "1";
  env.CLAUDE_AGENT_SDK_CLIENT_APP = "cph-hub/0.0.0";

  for (const [name, value] of Object.entries(input.providerProxyEnv ?? {})) {
    if (!PROVIDER_ENV_KEYS.has(name)) {
      throw new Error(`unsupported provider environment variable: ${name}`);
    }
    env[name] = value;
  }

  const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv);
  const runtimeReadPaths = hostRuntimeReadPaths(hostEnv);
  const selectedSkills = input.skills ?? [];
  const skillPlugin = selectedSkills.length === 0
    ? null
    : await prepareRunSkillPlugin({
        storeRoot: readSkillStoreRoot(hostEnv),
        runId: input.runId,
        skills: selectedSkills,
      });
  return {
    cwd: workspaceDir,
    workspaceRoot,
    env,
    skillIds: skillPlugin?.skillIds ?? [],
    ...(skillPlugin !== null ? { skillPluginRoot: skillPlugin.root } : {}),
    cleanup: skillPlugin?.cleanup ?? (async () => {}),
    sandbox: {
      enabled: true,
      failIfUnavailable: true,
      autoAllowBashIfSandboxed: true,
      allowUnsandboxedCommands: false,
      filesystem: {
        allowWrite: [workspaceDir],
        // Reject every write path by default, then re-open only the canonical
        // workspace. This prevents bubblewrap's ordinary temp exceptions from
        // turning an unauthorized path into a successful ephemeral write.
        denyWrite: ["/"],
        // Deny host reads by default, then re-open exactly this run's
        // workspace plus the named system runtime needed to execute tools.
        // SDK allowRead takes precedence over matching denyRead paths.
        denyRead: ["/"],
        allowRead: [workspaceDir, ...(skillPlugin !== null ? [skillPlugin.root] : []), ...runtimeReadPaths],
      },
      credentials: {
        files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
        envVars: SANDBOX_HIDDEN_ENV_KEYS.map((name) => ({ name, mode: "deny" as const })),
      },
    },
  };
}

function assertShortAgentTemp(agentTmp: string): void {
  const prefixBytes = Buffer.byteLength(agentTmp);
  if (prefixBytes > MAX_AGENT_TMP_PREFIX_BYTES) {
    throw new Error(
      `Agent temp path is too long for sandbox bridge sockets (${prefixBytes} > ${MAX_AGENT_TMP_PREFIX_BYTES} bytes): ${agentTmp}`,
    );
  }
}

function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
  const platformPaths = process.platform === "darwin"
    ? [
        "/bin",
        "/usr/bin",
        "/usr/lib",
        "/usr/share",
        "/System/Library",
        "/Library/Fonts",
        "/private/etc/hosts",
        "/private/etc/resolv.conf",
        "/private/etc/ssl",
        "/dev/null",
        "/dev/random",
        "/dev/urandom",
      ]
    : [
        "/bin",
        "/sbin",
        "/usr/bin",
        "/usr/local/bin",
        "/usr/sbin",
        "/lib",
        "/lib64",
        "/usr/lib",
        "/usr/lib64",
        "/usr/share",
        "/etc/fonts",
        "/etc/hosts",
        "/etc/localtime",
        "/etc/nsswitch.conf",
        "/etc/resolv.conf",
        "/etc/ssl/certs",
        "/dev/null",
        "/dev/random",
        "/dev/urandom",
      ];
  const cphBin = env["CPH_BIN"]?.trim();
  if (cphBin !== undefined && cphBin !== "") {
    if (!isAbsolute(cphBin)) throw new Error("CPH_BIN must be absolute for the Agent subprocess");
    platformPaths.push(resolve(cphBin));
  }
  return [...new Set(platformPaths.map((path) => resolve(path)))];
}

function hostSensitiveReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
  const home = homedir();
  const paths = [
    join(home, ".ssh"),
    join(home, ".aws"),
    join(home, ".config", "gcloud"),
    join(home, ".gnupg"),
  ];
  const extra = env["CPH_SANDBOX_EXTRA_DENY_READ"];
  if (extra !== undefined && extra !== "") {
    for (const path of extra.split(":")) {
      if (path === "") continue;
      if (!isAbsolute(path)) throw new Error("CPH_SANDBOX_EXTRA_DENY_READ entries must be absolute paths");
      paths.push(resolve(path));
    }
  }
  return [...new Set(paths.map((path) => resolve(path)))];
}

async function requireDirectoryRealpath(path: string, label: string): Promise<string> {
  if (!isAbsolute(path)) throw new Error(`${label} must be an absolute path`);
  const configuredMetadata = await lstat(path);
  if (configuredMetadata.isSymbolicLink() || !configuredMetadata.isDirectory()) {
    throw new Error(`${label} is not a real directory: ${path}`);
  }
  const canonical = await realpath(path);
  const metadata = await lstat(canonical);
  if (!metadata.isDirectory()) throw new Error(`${label} is not a directory: ${path}`);
  return canonical;
}

async function resolveProjectWorkspace(
  configuredRoot: string,
  configuredWorkspace: string,
): Promise<{ workspaceRoot: string; workspaceDir: string }> {
  if (!isAbsolute(configuredRoot) || !isAbsolute(configuredWorkspace)) {
    throw new Error("workspace root and project workspace must be absolute paths");
  }
  const rootPath = resolve(configuredRoot);
  const workspacePath = resolve(configuredWorkspace);
  const lexicalRelative = relative(rootPath, workspacePath);
  if (
    lexicalRelative === "" ||
    lexicalRelative === ".." ||
    lexicalRelative.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) ||
    isAbsolute(lexicalRelative)
  ) {
    throw new Error(`project workspace escapes configured workspace root: ${configuredWorkspace}`);
  }

  const workspaceRoot = await requireDirectoryRealpath(rootPath, "workspace root");
  const components = lexicalRelative.split(process.platform === "win32" ? /[\\/]/ : "/");
  let current = rootPath;
  for (const component of components) {
    current = join(current, component);
    const metadata = await lstat(current);
    if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
      throw new Error(`project workspace contains a symlink or non-directory component: ${current}`);
    }
  }
  const workspaceDir = await realpath(workspacePath);
  const expectedWorkspace = join(workspaceRoot, ...components);
  if (workspaceDir !== expectedWorkspace || !isStrictDescendant(workspaceRoot, workspaceDir)) {
    throw new Error(`project workspace escapes configured workspace root: ${configuredWorkspace}`);
  }
  return { workspaceRoot, workspaceDir };
}

async function ensureDirectoryTree(root: string, components: readonly string[]): Promise<string> {
  let current = root;
  for (const component of components) {
    if (component === "" || component === "." || component === ".." || component.includes("/") || component.includes("\\")) {
      throw new Error(`invalid Agent runtime directory component: ${component}`);
    }
    current = join(current, component);
    try {
      const metadata = await lstat(current);
      if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
        throw new Error(`Agent runtime path is not a real directory: ${current}`);
      }
    } catch (error) {
      if (!isErrno(error, "ENOENT")) throw error;
      try {
        await mkdir(current, { mode: 0o700 });
      } catch (mkdirError) {
        if (!isErrno(mkdirError, "EEXIST")) throw mkdirError;
      }
      const metadata = await lstat(current);
      if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
        throw new Error(`Agent runtime path is not a real directory: ${current}`);
      }
    }
  }
  const canonical = await realpath(current);
  if (canonical !== current || !isStrictDescendant(root, canonical)) {
    throw new Error(`Agent runtime path escapes its configured root: ${current}`);
  }
  await chmod(canonical, 0o700);
  return canonical;
}

function isStrictDescendant(parent: string, child: string): boolean {
  const candidate = relative(parent, child);
  return candidate !== "" && candidate !== ".." && !candidate.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(candidate);
}

function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException {
  return error instanceof Error && "code" in error && error.code === code;
}
