import { spawn } from "node:child_process";
import { setTimeout as sleepTimer } from "node:timers/promises";

/* eslint-disable max-classes-per-file -- provider, client, and helpers form one adapter. */
import type {
  AgentOsSandboxClient,
  AgentOsSandboxProcessInfo,
  AgentOsSandboxProcessLogs,
  AgentOsSandboxProcessResult,
  AgentOsSandboxProvider,
} from "@rivet-dev/agentos-core";
import { Effect } from "effect";

import { OrbSandboxError } from "./domain";

// ---------------------------------------------------------------------------
// Docker sandbox — AgentOsSandboxProvider backed by the `docker` CLI
// ---------------------------------------------------------------------------

const DEFAULT_IMAGE = "oven/bun:1.3-debian";
const CONTAINER_WORKSPACE = "/workspace";
const PID_DIR = "/tmp/orb-pids";

// ---------------------------------------------------------------------------
// Shell quoting & shared helpers
// ---------------------------------------------------------------------------

const shellQuote = (value: string): string =>
  `'${value.replaceAll("'", `'"'"'`)}'`;

// eslint-disable-next-line no-empty-function -- shared best-effort rejection swallower
const swallow = (): void => {};

// Node-compatible sleep (works in Bun runtime and vitest/node workers alike).
const sleep = async (ms: number): Promise<void> => {
  await sleepTimer(ms);
};

// ---------------------------------------------------------------------------
// Low-level docker CLI helpers
// ---------------------------------------------------------------------------

interface DockerExecResult {
  exitCode: number;
  stderr: string;
  stdout: string;
}

const runDocker = async (
  args: readonly string[],
  options?: {
    readonly stdin?: string;
    readonly timeoutMs?: number;
  }
): Promise<DockerExecResult> => {
  const controller = new AbortController();
  const timeout = setTimeout(
    () => controller.abort(),
    options?.timeoutMs ?? 120_000
  );
  try {
    // eslint-disable-next-line no-use-before-define -- resolved at call time, after module load
    return await runChild(args, options?.stdin, controller.signal);
  } finally {
    clearTimeout(timeout);
  }
};

// Spawn `docker` via node:child_process so the adapter works under the Bun
// runtime (proof fixture) and vitest/node workers (test suite) alike. Aborting
// the signal surfaces as a rejected promise with name "AbortError".
const runChild = (
  args: readonly string[],
  stdin: string | undefined,
  signal: AbortSignal
): Promise<DockerExecResult> =>
  // eslint-disable-next-line promise/avoid-new -- a single Promise wrapper models one-shot child resolution
  new Promise((resolve, reject) => {
    const proc = spawn("docker", [...args], {
      signal,
      stdio: [stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
    });
    const stdoutChunks: Buffer[] = [];
    const stderrChunks: Buffer[] = [];
    proc.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
    proc.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
    if (stdin !== undefined && proc.stdin) {
      proc.stdin.end(stdin);
    }
    let settled = false;
    proc.on("error", (error: NodeJS.ErrnoException) => {
      if (settled) {
        return;
      }
      settled = true;
      reject(error);
    });
    proc.on("close", (code: number | null) => {
      if (settled) {
        return;
      }
      settled = true;
      resolve({
        exitCode: code ?? -1,
        stderr: Buffer.concat(stderrChunks).toString("utf-8"),
        stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
      });
    });
  });

const checkDockerAvailable = Effect.fn("Docker.checkAvailable")(
  function* checkDockerAvailable() {
    const result = yield* Effect.tryPromise({
      catch: (cause) =>
        new OrbSandboxError({
          message: `Docker CLI is not available: ${cause instanceof Error ? cause.message : String(cause)}`,
          reason: "DockerUnavailable",
        }),
      try: () => runDocker(["version", "--format", "{{.Server.Version}}"]),
    });
    if (result.exitCode !== 0) {
      return yield* Effect.fail(
        new OrbSandboxError({
          message: `Docker daemon is not running: ${result.stderr.trim()}`,
          reason: "DockerUnavailable",
        })
      );
    }
  }
);

type ContainerState = "running" | "stopped" | "not-found";

const inspectContainer = async (name: string): Promise<ContainerState> => {
  const result = await runDocker([
    "inspect",
    "--format",
    "{{.State.Running}}",
    name,
  ]);
  if (result.exitCode !== 0) {
    return "not-found";
  }
  return result.stdout.trim() === "true" ? "running" : "stopped";
};

// ---------------------------------------------------------------------------
// Container file helpers — secrets never touch docker CLI args or listings
// ---------------------------------------------------------------------------

const parentDir = (filePath: string): string =>
  filePath.replace(/\/[^/]+$/u, "") || "/";

const writeContainerFile = async (
  container: string,
  filePath: string,
  content: string
): Promise<void> => {
  // -i keeps stdin open so the payload reaches `cat` inside the container.
  const result = await runDocker(
    [
      "exec",
      "-i",
      container,
      "sh",
      "-c",
      `mkdir -p ${shellQuote(parentDir(filePath))} && cat > ${shellQuote(filePath)}`,
    ],
    { stdin: content }
  );
  if (result.exitCode !== 0) {
    throw new OrbSandboxError({
      message: `Failed to write ${filePath}: ${result.stderr.trim()}`,
      reason: "CommandFailed",
    });
  }
};

/** Read a container file; returns "" when it does not exist. */
const readContainerFile = async (
  container: string,
  filePath: string
): Promise<string> => {
  const result = await runDocker(
    ["exec", container, "sh", "-c", `cat ${shellQuote(filePath)} 2>/dev/null`],
    { timeoutMs: 10_000 }
  );
  return result.exitCode === 0 ? result.stdout : "";
};

const removeContainerFiles = async (
  container: string,
  files: readonly (string | undefined)[]
): Promise<void> => {
  const paths = files.filter(
    (file): file is string => typeof file === "string" && file.length > 0
  );
  if (paths.length === 0) {
    return;
  }
  await runDocker(["exec", container, "rm", "-f", ...paths], {
    timeoutMs: 10_000,
  }).catch(swallow);
};

/**
 * Signal a whole detached process group (negative PGID) and fall back to the
 * leader PID, so spawned children are not orphaned. The PGID is captured via
 * `setsid`, so signalling `-<pgid>` reaches every member of the group.
 */
const signalProcessGroup = async (
  container: string,
  pid: number,
  signal: "TERM" | "KILL" | "0"
): Promise<void> => {
  await runDocker(
    [
      "exec",
      container,
      "sh",
      "-c",
      `kill -${signal} -- -${pid} 2>/dev/null; kill -${signal} ${pid} 2>/dev/null; true`,
    ],
    { timeoutMs: 10_000 }
  ).catch(swallow);
};

const isProcessGroupAlive = async (
  container: string,
  pid: number
): Promise<boolean> => {
  const result = await runDocker(
    [
      "exec",
      container,
      "sh",
      "-c",
      `kill -0 -- -${pid} 2>/dev/null || kill -0 ${pid} 2>/dev/null`,
    ],
    { timeoutMs: 5000 }
  );
  return result.exitCode === 0;
};

const stageEnvFile = async (
  container: string,
  env: Readonly<Record<string, string>>,
  id: string
): Promise<string> => {
  const envPath = `${PID_DIR}/.env-${id}`;
  const lines = Object.entries(env)
    .filter(([, value]) => value !== undefined)
    .map(([key, value]) => `${key}=${shellQuote(value)}`)
    .join("\n");
  await writeContainerFile(container, envPath, `${lines}\n`);
  return envPath;
};

// ---------------------------------------------------------------------------
// DockerSandboxProvider — owns the container lifecycle (idempotent)
// ---------------------------------------------------------------------------

export interface DockerSandboxOptions {
  readonly containerName: string;
  readonly hostWorkspacePath: string;
  readonly image?: string;
  readonly workDir?: string;
}

export class DockerSandboxProvider implements AgentOsSandboxProvider {
  private readonly containerName: string;
  private readonly hostWorkspace: string;
  private readonly image: string;
  private readonly workDir: string;
  private client: DockerSandboxClient | null = null;
  private starting: Promise<AgentOsSandboxClient> | null = null;
  private containerOwned = false;

  constructor(options: DockerSandboxOptions) {
    this.containerName = options.containerName;
    this.hostWorkspace = options.hostWorkspacePath;
    this.image = options.image ?? DEFAULT_IMAGE;
    this.workDir = options.workDir ?? CONTAINER_WORKSPACE;
  }

  static readonly create = Effect.fn("DockerSandboxProvider.create")(
    function* createProvider(options: DockerSandboxOptions) {
      yield* checkDockerAvailable();
      return new DockerSandboxProvider(options);
    }
  );

  /**
   * Start the Docker container idempotently. Returns the same client on
   * repeated calls. Safe to call from multiple paths concurrently — a single
   * in-flight start promise is shared.
   */
  async start(): Promise<AgentOsSandboxClient> {
    if (this.client) {
      return this.client;
    }
    if (this.starting) {
      return this.starting;
    }
    this.starting = this.doStart();
    try {
      return await this.starting;
    } finally {
      this.starting = null;
    }
  }

  private async doStart(): Promise<AgentOsSandboxClient> {
    await Effect.runPromise(checkDockerAvailable());
    const state = await inspectContainer(this.containerName);
    if (state === "not-found") {
      await this.createContainer();
      this.containerOwned = true;
    } else if (state === "stopped") {
      const result = await runDocker(["start", this.containerName]);
      if (result.exitCode !== 0) {
        throw new OrbSandboxError({
          message: `Failed to start container ${this.containerName}: ${result.stderr.trim()}`,
          reason: "ContainerStart",
        });
      }
      this.containerOwned = true;
    }
    const verified = await inspectContainer(this.containerName);
    if (verified !== "running") {
      throw new OrbSandboxError({
        message: `Container ${this.containerName} is not running after start (state: ${verified})`,
        reason: "ContainerStart",
      });
    }
    // eslint-disable-next-line no-use-before-define -- client is defined below in the same module
    this.client = new DockerSandboxClient(this.containerName, this.workDir);
    return this.client;
  }

  private async createContainer(): Promise<void> {
    const result = await runDocker([
      "run",
      "-d",
      "--name",
      this.containerName,
      "--workdir",
      this.workDir,
      "-v",
      `${this.hostWorkspace}:${CONTAINER_WORKSPACE}`,
      "--cap-drop=ALL",
      "--security-opt=no-new-privileges",
      "--memory=2g",
      "--cpus=2",
      this.image,
      "sleep",
      "infinity",
    ]);
    if (result.exitCode !== 0) {
      throw new OrbSandboxError({
        message: `Failed to create container ${this.containerName}: ${result.stderr.trim()}`,
        reason: "ContainerStart",
      });
    }
  }

  /**
   * Remove the container unconditionally (idempotent). The provider owns
   * container removal so partial startup — container created, client never
   * assigned — still cleans up.
   */
  private async removeContainer(): Promise<void> {
    const result = await runDocker(["rm", "-f", this.containerName], {
      timeoutMs: 30_000,
    });
    this.containerOwned = false;
    if (result.exitCode !== 0 && !/no such/iu.test(result.stderr)) {
      throw new OrbSandboxError({
        message: `Failed to remove container ${this.containerName}: ${result.stderr.trim()}`,
        reason: "ContainerCleanup",
      });
    }
  }

  async dispose(): Promise<void> {
    this.starting = null;
    const { client } = this;
    this.client = null;
    if (client) {
      await client.dispose().catch(swallow);
    }
    if (this.containerOwned) {
      await this.removeContainer();
    }
  }

  get containerId(): string {
    return this.containerName;
  }

  get isStarted(): boolean {
    return this.client !== null;
  }
}

// ---------------------------------------------------------------------------
// DockerSandboxClient — real process lifecycle via process-group tracking
// ---------------------------------------------------------------------------

interface TrackedProcess {
  readonly args?: string[];
  readonly command: string;
  readonly errFile: string;
  exitCode: number | null;
  readonly exitFile: string;
  readonly id: string;
  readonly outFile: string;
  pid: number | null;
  readonly pidFile: string;
  readonly scriptFile: string;
  readonly startedAt: number;
  status: string;
}

class DockerSandboxClient implements AgentOsSandboxClient {
  private readonly container: string;
  private readonly workDir: string;
  private nextId = 0;
  private readonly processes = new Map<string, TrackedProcess>();

  constructor(container: string, workDir: string) {
    this.container = container;
    this.workDir = workDir;
  }

  async runProcess(options: {
    readonly command: string;
    readonly args?: readonly string[];
    readonly cwd?: string;
    readonly env?: Readonly<Record<string, string>>;
    readonly timeoutMs?: number;
  }): Promise<AgentOsSandboxProcessResult> {
    this.nextId += 1;
    const id = `run-${this.nextId}`;
    const cwd = options.cwd ?? this.workDir;
    const fullCommand = options.args?.length
      ? `${options.command} ${options.args.map(shellQuote).join(" ")}`
      : options.command;

    let envPrefix = "";
    let envPath: string | undefined;
    if (options.env && Object.keys(options.env).length > 0) {
      envPath = await stageEnvFile(this.container, options.env, id);
      envPrefix = `set -a; . ${shellQuote(envPath)}; set +a; rm -f ${shellQuote(envPath)}; `;
    }

    const wrapped = `${envPrefix}cd ${shellQuote(cwd)} && ${fullCommand}`;
    const start = Date.now();

    try {
      const result = await runDocker(
        ["exec", this.container, "sh", "-c", wrapped],
        { timeoutMs: options.timeoutMs }
      );
      return {
        durationMs: Date.now() - start,
        exitCode: result.exitCode,
        stderr: result.stderr,
        stdout: result.stdout,
        timedOut: false,
      };
    } catch (error) {
      if (envPath) {
        await removeContainerFiles(this.container, [envPath]);
      }
      const timedOut = error instanceof Error && error.name === "AbortError";
      return {
        durationMs: Date.now() - start,
        exitCode: null,
        stderr: timedOut ? "Process timed out" : String(error),
        stdout: "",
        timedOut,
      };
    }
  }

  async createProcess(options: {
    readonly command: string;
    readonly args?: readonly string[];
    readonly cwd?: string;
    readonly env?: Readonly<Record<string, string>>;
  }): Promise<AgentOsSandboxProcessInfo> {
    this.nextId += 1;
    const id = `proc-${this.nextId}`;
    const cwd = options.cwd ?? this.workDir;
    const fullCommand = options.args?.length
      ? `${options.command} ${options.args.map(shellQuote).join(" ")}`
      : options.command;
    const pidFile = `${PID_DIR}/${id}.pid`;
    const outFile = `${PID_DIR}/${id}.out.log`;
    const errFile = `${PID_DIR}/${id}.err.log`;
    const exitFile = `${PID_DIR}/${id}.exit`;
    const scriptFile = `${PID_DIR}/${id}.sh`;

    let envPath: string | undefined;
    if (options.env && Object.keys(options.env).length > 0) {
      envPath = `${PID_DIR}/.env-${id}`;
    }
    // The launcher is staged as a file to avoid nested shell quoting; it
    // sources (then deletes) the env file and records the real exit code.
    const launcherLines = [
      ...(envPath === undefined
        ? []
        : [
            "set -a",
            `. ${shellQuote(envPath)}`,
            "set +a",
            `rm -f ${shellQuote(envPath)}`,
          ]),
      `cd ${shellQuote(cwd)}`,
      fullCommand,
      `echo "$?" > ${shellQuote(exitFile)}`,
    ];

    const tracked: TrackedProcess = {
      args: options.args ? [...options.args] : undefined,
      command: options.command,
      errFile,
      exitCode: null,
      exitFile,
      id,
      outFile,
      pid: null,
      pidFile,
      scriptFile,
      startedAt: Date.now(),
      status: "running",
    };
    this.processes.set(id, tracked);

    await writeContainerFile(
      this.container,
      scriptFile,
      `${launcherLines.join("\n")}\n`
    );
    if (envPath) {
      const envEntries = options.env ? Object.entries(options.env) : [];
      const envLines = envEntries
        .filter(([, value]) => value !== undefined)
        .map(([key, value]) => `${key}=${shellQuote(value)}`)
        .join("\n");
      await writeContainerFile(this.container, envPath, `${envLines}\n`);
    }

    // Post-write assertion: the staged script must be nonempty before launch.
    const stagedScript = await readContainerFile(this.container, scriptFile);
    if (stagedScript.length === 0) {
      throw new OrbSandboxError({
        message: `Staged launcher ${scriptFile} is empty; refusing to launch`,
        reason: "CommandFailed",
      });
    }

    // Launch detached in its own session/process group, redirecting stdout and
    // stderr to durable files, and capture the group PID via reliable $!.
    const launch = `mkdir -p ${shellQuote(PID_DIR)} && setsid sh ${shellQuote(scriptFile)} > ${shellQuote(outFile)} 2> ${shellQuote(errFile)} < /dev/null & echo $! > ${shellQuote(pidFile)}`;
    const result = await runDocker(
      ["exec", this.container, "sh", "-c", launch],
      { timeoutMs: 15_000 }
    );
    if (result.exitCode !== 0) {
      tracked.status = "failed";
      tracked.exitCode = result.exitCode;
      await removeContainerFiles(this.container, [scriptFile, envPath]);
      return this.toInfo(tracked);
    }

    await this.refreshStatus(tracked);
    return this.toInfo(tracked);
  }

  async listProcesses(): Promise<{ processes: AgentOsSandboxProcessInfo[] }> {
    for (const tracked of this.processes.values()) {
      // eslint-disable-next-line no-await-in-loop -- reconcile each tracked process before reporting
      await this.refreshStatus(tracked).catch(swallow);
    }
    return {
      processes: [...this.processes.values()].map((p) => this.toInfo(p)),
    };
  }

  async stopProcess(id: string): Promise<AgentOsSandboxProcessInfo> {
    return await this.signalProcess(id, "TERM");
  }

  async killProcess(id: string): Promise<AgentOsSandboxProcessInfo> {
    return await this.signalProcess(id, "KILL");
  }

  private async signalProcess(
    id: string,
    requested: "TERM" | "KILL"
  ): Promise<AgentOsSandboxProcessInfo> {
    const tracked = this.processes.get(id);
    if (!tracked) {
      throw new OrbSandboxError({
        message: `Unknown process ${id}`,
        reason: "CommandFailed",
      });
    }
    await this.refreshStatus(tracked);
    if (tracked.status !== "running") {
      return this.toInfo(tracked);
    }

    const pid = tracked.pid ?? (await this.readPid(tracked));
    tracked.pid = pid;
    if (pid === null) {
      tracked.status = requested === "KILL" ? "killed" : "stopped";
      return this.toInfo(tracked);
    }

    if (requested === "KILL") {
      await signalProcessGroup(this.container, pid, "KILL");
      tracked.status = "killed";
    } else {
      await signalProcessGroup(this.container, pid, "TERM");
      const settled = await this.waitForExit(tracked, 5000);
      if (settled) {
        tracked.status = "stopped";
      } else {
        await signalProcessGroup(this.container, pid, "KILL");
        tracked.status = "killed";
      }
    }
    await this.refreshStatus(tracked);
    return this.toInfo(tracked);
  }

  async getProcessLogs(
    id: string,
    options?: {
      readonly stream?: "stdout" | "stderr" | "combined";
      readonly tail?: number;
    }
  ): Promise<AgentOsSandboxProcessLogs> {
    const tracked = this.processes.get(id);
    if (!tracked) {
      throw new OrbSandboxError({
        message: `Unknown process ${id}`,
        reason: "CommandFailed",
      });
    }
    await this.refreshStatus(tracked);
    const wantStdout = options?.stream !== "stderr";
    const wantStderr = options?.stream !== "stdout";
    const entries: {
      data: string;
      stream: "stdout" | "stderr";
      timestampMs?: number;
    }[] = [];
    if (wantStdout) {
      const out = await readContainerFile(this.container, tracked.outFile);
      if (out.length > 0) {
        entries.push({
          data: out,
          stream: "stdout",
          timestampMs: tracked.startedAt,
        });
      }
    }
    if (wantStderr) {
      const err = await readContainerFile(this.container, tracked.errFile);
      if (err.length > 0) {
        entries.push({
          data: err,
          stream: "stderr",
          timestampMs: tracked.startedAt,
        });
      }
    }
    const tail = options?.tail;
    if (tail === undefined) {
      return { entries };
    }
    return {
      entries: entries.map((entry) => ({
        ...entry,
        data: entry.data.split("\n").slice(-tail).join("\n"),
      })),
    };
  }

  // eslint-disable-next-line class-methods-use-this, require-await -- throws intentionally; async satisfies the interface contract
  async sendProcessInput(): Promise<unknown> {
    throw new OrbSandboxError({
      message:
        "Interactive process input is not supported by the Docker sandbox",
      reason: "CommandFailed",
    });
  }

  async dispose(): Promise<void> {
    for (const id of this.processes.keys()) {
      const tracked = this.processes.get(id);
      if (!tracked) {
        continue;
      }
      // eslint-disable-next-line no-await-in-loop -- each process is reconciled before the container is removed
      await this.refreshStatus(tracked).catch(swallow);
      if (tracked.status === "running") {
        // eslint-disable-next-line no-await-in-loop -- sequential kill must settle before container removal
        await this.killProcess(id).catch(swallow);
      }
    }
    this.processes.clear();
  }

  // ---------------------------------------------------------------------
  // Internal helpers
  // ---------------------------------------------------------------------

  private async readPid(tracked: TrackedProcess): Promise<number | null> {
    const raw = await readContainerFile(this.container, tracked.pidFile);
    const pidText = raw.trim();
    return /^\d+$/u.test(pidText) ? Number(pidText) : null;
  }

  /**
   * Reconcile tracked status with the container: a recorded exit file means
   * natural completion, otherwise the process group liveness decides.
   */
  private async refreshStatus(tracked: TrackedProcess): Promise<void> {
    const exitContents = await readContainerFile(
      this.container,
      tracked.exitFile
    );
    const exitRaw = exitContents.trim();
    if (/^-?\d+$/u.test(exitRaw)) {
      tracked.exitCode = Number(exitRaw);
      if (tracked.status === "running") {
        tracked.status = "exited";
      }
      return;
    }
    if (tracked.status !== "running") {
      return;
    }
    const pid = tracked.pid ?? (await this.readPid(tracked));
    tracked.pid = pid;
    if (pid === null) {
      tracked.status = "failed";
      return;
    }
    const alive = await isProcessGroupAlive(this.container, pid);
    if (alive) {
      return;
    }
    tracked.status = "exited";
    tracked.exitCode = null;
  }

  private async waitForExit(
    tracked: TrackedProcess,
    timeoutMs: number
  ): Promise<boolean> {
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
      // eslint-disable-next-line no-await-in-loop -- polling must probe sequentially
      await sleep(250);
      // eslint-disable-next-line no-await-in-loop -- polling must probe sequentially
      await this.refreshStatus(tracked);
      if (tracked.status !== "running") {
        return true;
      }
    }
    return false;
  }

  // eslint-disable-next-line class-methods-use-this -- instance method kept for readability
  private toInfo(tracked: TrackedProcess): AgentOsSandboxProcessInfo {
    return {
      args: tracked.args,
      command: tracked.command,
      exitCode: tracked.exitCode,
      id: tracked.id,
      pid: tracked.pid,
      status: tracked.status,
    };
  }
}
