import {
  existsSync,
  mkdirSync,
  readFileSync,
  rmSync,
  writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createLogger } from "./logger.js";

const log = createLogger("container-manager");

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface ContainerConfig {
  /** Docker image to use (e.g. "zencode:latest", "node:22-slim") */
  image: string;
  /** Container ports to expose (e.g. [3000, 8080]) */
  ports: number[];
  /** Extra volume mounts in "host:container[:opts]" format */
  volumes?: string[];
  /** Extra env vars to inject into the container */
  env?: Record<string, string>;
}

export interface PortMapping {
  containerPort: number;
  hostPort: number;
}

export interface ContainerInfo {
  containerId: string;
  name: string;
  image: string;
  portMappings: PortMapping[];
  hostCwd: string;
  containerCwd: string;
  state: "creating" | "running" | "stopped" | "removed";
  /** Named Docker volume for isolated workspace (absent for legacy bind-mount containers). */
  volumeName?: string;
}

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

const QUICK_EXEC_TIMEOUT_MS = 8_000;
const STANDARD_EXEC_TIMEOUT_MS = 30_000;
const CONTAINER_BOOT_TIMEOUT_MS = 20_000;
const WORKSPACE_COPY_TIMEOUT_MS = 15 * 60_000; // 15 min for large repos
const IMAGE_PULL_TIMEOUT_MS = 300_000; // 5 min for pulling images

const DOCKER_REGISTRY = "docker.io/zebbern";

/**
 * Async exec via Bun.spawn — avoids blocking the event loop.
 * Takes an args array (no shell interpretation) and returns trimmed stdout.
 * Throws on non-zero exit or timeout.
 */
async function execSpawn(args: string[], opts?: { timeout?: number }): Promise<string> {
  const timeout = opts?.timeout ?? STANDARD_EXEC_TIMEOUT_MS;
  const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });

  let timeoutId: ReturnType<typeof setTimeout> | undefined;
  const timeoutPromise = new Promise<never>((_, reject) => {
    timeoutId = setTimeout(() => {
      proc.kill();
      reject(new Error(`Command timed out after ${timeout}ms`));
    }, timeout);
  });

  const stdoutPromise = new Response(proc.stdout).text();
  const stderrPromise = new Response(proc.stderr).text();

  const exitCode = await Promise.race([proc.exited, timeoutPromise]);
  clearTimeout(timeoutId);

  if (exitCode !== 0) {
    const stderr = await stderrPromise;
    throw new Error(stderr.trim() || `Command failed (exit ${exitCode})`);
  }

  return (await stdoutPromise).trim();
}

// ---------------------------------------------------------------------------
// Volume mount validation
// ---------------------------------------------------------------------------

/**
 * Deny-list of container mount targets that must never be exposed.
 * Paths are normalized to lowercase for case-insensitive matching on Windows.
 * Both Unix and Windows sensitive paths are covered so the check works
 * regardless of the host or container OS.
 */
const SENSITIVE_MOUNT_TARGETS: readonly string[] = [
  // Unix roots & system directories
  "/",
  "/etc",
  "/proc",
  "/sys",
  "/dev",
  "/boot",
  // User secrets
  "/root/.ssh",
  "/home",
  "/root",
  // Docker socket (container escape vector)
  "/var/run/docker.sock",
  "/run/docker.sock",
  // Windows equivalents (when mounting into Windows containers or from Windows host)
  "c:\\",
  "c:\\windows",
  "c:\\windows\\system32",
  "c:\\users",
  "c:\\programdata",
];

/**
 * Parse a Docker volume spec ("host:container[:opts]") and return the
 * container-side mount target.
 *
 * Handles both Unix paths (split on `:`) and Windows paths where drive
 * letters produce extra segments (e.g. `D:\data:C:\Windows` splits into
 * `["D", "\data", "C", "\Windows"]`).
 */
function parseVolumeMountTarget(volumeSpec: string): string {
  const parts = volumeSpec.split(":");

  // Detect Windows drive letters: a single alpha character as a segment
  const isDriveLetter = (s: string): boolean => s.length === 1 && /^[a-z]$/i.test(s);

  let containerPath: string;

  if (parts.length >= 4 && isDriveLetter(parts[0]) && isDriveLetter(parts[2])) {
    // Both host and container are Windows paths:
    // "D:\data:C:\Windows[:opts]" → ["D", "\data", "C", "\Windows", ...]
    containerPath = `${parts[2]}:${parts[3]}`;
  } else if (parts.length >= 3 && isDriveLetter(parts[0])) {
    // Host is a Windows path, container is a Unix path:
    // "C:\path:/workspace[:opts]" → ["C", "\path", "/workspace", ...]
    containerPath = parts[2];
  } else if (parts.length >= 2) {
    // Standard Unix "host:container[:opts]" or "volume_name:container[:opts]"
    containerPath = parts[1];
  } else {
    // Single segment — unusual but not blockable
    containerPath = volumeSpec;
  }

  return containerPath;
}

/**
 * Normalize a path for comparison: lowercase, forward-slashes, trailing slash
 * stripped, and collapse repeated separators.
 */
function normalizeMountPath(p: string): string {
  return p
    .toLowerCase()
    .replace(/\\/g, "/")
    .replace(/\/+/g, "/")
    .replace(/\/+$/, "") || "/";
}

/**
 * Validate a Docker volume mount spec against the sensitive-path deny-list.
 * Throws if the container-side target matches (exact) or is a parent of a
 * sensitive path. Does NOT block mounts to project sub-directories.
 */
export function validateVolumeMountTarget(volumeSpec: string): void {
  const target = normalizeMountPath(parseVolumeMountTarget(volumeSpec));
  const deniedNormalized = SENSITIVE_MOUNT_TARGETS.map(normalizeMountPath);

  for (const denied of deniedNormalized) {
    // Exact match
    if (target === denied) {
      throw new Error(
        `Refused to mount volume: container target "${target}" is a sensitive path`,
      );
    }
    // Target is a child of a denied path (e.g. "/etc/passwd" under "/etc")
    if (target.startsWith(denied + "/")) {
      throw new Error(
        `Refused to mount volume: container target "${target}" is inside sensitive path "${denied}"`,
      );
    }
  }
}

// ---------------------------------------------------------------------------
// ContainerManager
// ---------------------------------------------------------------------------

export class ContainerManager {
  private containers = new Map<string, ContainerInfo>();

  /** Check whether Docker daemon is reachable. */
  async checkDocker(): Promise<boolean> {
    try {
      await execSpawn(["docker", "info", "--format", "{{.ServerVersion}}"], {
        timeout: QUICK_EXEC_TIMEOUT_MS,
      });
      return true;
    } catch {
      return false;
    }
  }

  /** Return Docker version string, or null if unavailable. */
  async getDockerVersion(): Promise<string | null> {
    try {
      return await execSpawn(["docker", "version", "--format", "{{.Server.Version}}"], {
        timeout: QUICK_EXEC_TIMEOUT_MS,
      });
    } catch {
      return null;
    }
  }

  /** List images available locally. Returns image:tag strings. */
  async listImages(): Promise<string[]> {
    try {
      const raw = await execSpawn(["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"], {
        timeout: QUICK_EXEC_TIMEOUT_MS,
      });
      if (!raw) return [];
      return raw
        .split("\n")
        .filter((l: string) => l && !l.startsWith("<none>"))
        .sort();
    } catch {
      return [];
    }
  }

  /** Check if a specific image exists locally. */
  async imageExists(image: string): Promise<boolean> {
    try {
      await execSpawn(["docker", "image", "inspect", image], {
        timeout: QUICK_EXEC_TIMEOUT_MS,
      });
      return true;
    } catch {
      return false;
    }
  }

  /**
   * Create and start a container for a session.
   *
   * - Mounts `~/.claude` read-only at `/zencode-host-claude` (auth seed)
   * - Uses a writable tmpfs at `/root/.claude` for runtime state
   * - Mounts `hostCwd` at `/workspace`
   * - Publishes requested ports with auto-assigned host ports (`-p 0:PORT`)
   */
  async createContainer(
    sessionId: string,
    hostCwd: string,
    config: ContainerConfig,
  ): Promise<ContainerInfo> {
    const name = `zencode-${sessionId.slice(0, 8)}`;
    const homedir = process.env.HOME || process.env.USERPROFILE || "/root";

    // Validate port numbers
    for (const port of config.ports) {
      if (!Number.isInteger(port) || port < 1 || port > 65535) {
        throw new Error(`Invalid port number: ${port} (must be 1-65535)`);
      }
    }

    // Create a named volume for workspace isolation (each container gets its own copy)
    const volumeName = `zencode-ws-${sessionId.slice(0, 8)}`;
    await execSpawn(["docker", "volume", "create", volumeName], {
      timeout: QUICK_EXEC_TIMEOUT_MS,
    });

    // Build docker create args
    const args: string[] = [
      "docker", "create",
      "--name", name,
      // Ensure host.docker.internal resolves (automatic on Mac/Win Docker
      // Desktop, but required explicitly on Linux)
      "--add-host=host.docker.internal:host-gateway",
      // Seed auth/config from host home, but keep runtime writes inside container.
      "-v", `${homedir}/.claude:/zencode-host-claude:ro`,
      "--tmpfs", "/root/.claude",
      // Seed Codex auth/config from host (if present)
      ...(existsSync(join(homedir, ".codex"))
        ? ["-v", `${homedir}/.codex:/zencode-host-codex:ro`, "--tmpfs", "/root/.codex"]
        : []),
      // Isolated workspace: named volume populated later via docker cp
      "-v", `${volumeName}:/workspace`,
      "-w", "/workspace",
    ];

    // Mount host .gitconfig at a staging path (not /root/.gitconfig) so the
    // container keeps a writable global git config. seedGitAuth() copies
    // user.name / user.email from the staged file into /root/.gitconfig and
    // can also write container-specific overrides (e.g. gpgsign=false).
    const gitconfigPath = join(homedir, ".gitconfig");
    if (existsSync(gitconfigPath)) {
      args.push("-v", `${gitconfigPath}:/zencode-host-gitconfig:ro`);
    }

    // Port mappings: -p 0:{containerPort}
    for (const port of config.ports) {
      args.push("-p", `0:${port}`);
    }

    // Extra volumes (validated against sensitive-path deny-list)
    if (config.volumes) {
      for (const vol of config.volumes) {
        validateVolumeMountTarget(vol);
        args.push("-v", vol);
      }
    }

    // Environment variables
    if (config.env) {
      for (const [k, v] of Object.entries(config.env)) {
        args.push("-e", `${k}=${v}`);
      }
    }

    // Image + default command (keep container alive)
    args.push(config.image, "sleep", "infinity");

    const info: ContainerInfo = {
      containerId: "",
      name,
      image: config.image,
      portMappings: [],
      hostCwd,
      containerCwd: "/workspace",
      state: "creating",
      volumeName,
    };

    try {
      // Create
      const containerId = await execSpawn(args, {
        timeout: CONTAINER_BOOT_TIMEOUT_MS,
      });
      info.containerId = containerId;

      // Start
      await execSpawn(["docker", "start", containerId], {
        timeout: CONTAINER_BOOT_TIMEOUT_MS,
      });
      info.state = "running";

      await this.seedAuthFiles(containerId);
      await this.seedCodexFiles(containerId);
      await this.seedGitAuth(containerId);

      // Resolve actual port mappings
      info.portMappings = await this.resolvePortMappings(containerId, config.ports);

      this.containers.set(sessionId, info);
      log.info(
        { containerName: name, containerId: containerId.slice(0, 12), ports: info.portMappings.map((p) => `${p.containerPort}->${p.hostPort}`).join(", ") },
        "Created container",
      );

      return info;
    } catch (e) {
      // Cleanup partial creation (container + volume)
      try { await execSpawn(["docker", "rm", "-f", name]); } catch (cleanupErr) {
        log.debug({ name, err: cleanupErr }, "Failed to remove container during error cleanup");
      }
      try { await execSpawn(["docker", "volume", "rm", volumeName]); } catch (cleanupErr) {
        log.debug({ volumeName, err: cleanupErr }, "Failed to remove volume during error cleanup");
      }
      info.state = "removed";
      throw new Error(
        `Failed to create container: ${e instanceof Error ? e.message : String(e)}`,
      );
    }
  }

  /**
   * Copy auth & config files from the read-only bind mount into the tmpfs home dir.
   * Called after both initial create and restart (tmpfs is wiped on stop).
   *
   * Uses semicolons (not &&) deliberately: individual file copies are best-effort
   * because not all auth files exist on every system. The trailing `true` ensures
   * the overall exec succeeds even when some `cp` commands fail for missing files.
   */
  private async seedAuthFiles(containerId: string): Promise<void> {
    try {
      await this.execInContainer(containerId, [
        "sh", "-lc",
        [
          "mkdir -p /root/.claude",
          "for f in .credentials.json auth.json .auth.json credentials.json; do " +
            "[ -f /zencode-host-claude/$f ] && cp /zencode-host-claude/$f /root/.claude/$f 2>/dev/null; done",
          "for f in settings.json settings.local.json; do " +
            "[ -f /zencode-host-claude/$f ] && cp /zencode-host-claude/$f /root/.claude/$f 2>/dev/null; done",
          "[ -d /zencode-host-claude/skills ] && cp -r /zencode-host-claude/skills /root/.claude/skills 2>/dev/null",
          "true",
        ].join("; "),
      ]);
    } catch { /* best-effort — container may not have /zencode-host-claude mounted */ }
  }

  /**
   * Copy Codex auth & config files from the read-only bind mount into the
   * tmpfs home dir. Similar to seedAuthFiles but for Codex's ~/.codex directory.
   * Called after both initial create and restart (tmpfs is wiped on stop).
   */
  private async seedCodexFiles(containerId: string): Promise<void> {
    try {
      await this.execInContainer(containerId, [
        "sh", "-lc",
        [
          "[ -d /zencode-host-codex ] || exit 0",
          "mkdir -p /root/.codex",
          "for f in auth.json config.toml models_cache.json version.json; do " +
            "[ -f /zencode-host-codex/$f ] && cp /zencode-host-codex/$f /root/.codex/$f 2>/dev/null; done",
          "for d in skills vendor_imports prompts rules; do " +
            "[ -d /zencode-host-codex/$d ] && cp -r /zencode-host-codex/$d /root/.codex/$d 2>/dev/null; done",
          "true",
        ].join("; "),
      ]);
    } catch { /* best-effort — container may not have /zencode-host-codex mounted */ }
  }

  /**
   * Seed git authentication inside the container.
   * - Extracts GitHub CLI token from host keyring and logs in inside container
   * - Always sets up `gh` as the git credential helper for HTTPS operations
   * - Disables GPG commit signing (host tools like 1Password aren't available)
   *
   * Called after both initial create and restart (tmpfs wipes gh config on stop).
   */
  private async seedGitAuth(containerId: string): Promise<void> {
    // Track whether we could read the host token. Containers may still have gh
    // auth via copied files, so setup-git must run even when this is unavailable.
    let token = "";

    // Extract GitHub token from host (may be stored in macOS keyring)
    try {
      token = await execSpawn(["gh", "auth", "token"], {
        timeout: QUICK_EXEC_TIMEOUT_MS,
      });
    } catch { /* best-effort — gh may not be installed on host */ }

    // If host token exists, seed gh auth state in the container.
    // Pipe token via stdin to avoid shell interpolation of secrets.
    if (token) {
      try {
        const loginProc = Bun.spawn(
          ["docker", "exec", "-i", containerId, "gh", "auth", "login", "--with-token"],
          { stdin: "pipe", stdout: "pipe", stderr: "pipe" },
        );
        loginProc.stdin.write(token + "\n");
        loginProc.stdin.end();
        await loginProc.exited;
      } catch { /* best-effort */ }
    }

    // Always wire git credentials to gh token flow.
    try {
      await this.execInContainer(containerId, [
        "sh", "-lc",
        "gh auth setup-git 2>/dev/null; true",
      ]);
    } catch { /* best-effort */ }

    // Copy host git identity (user.name, user.email) from the staged
    // read-only .gitconfig into the container's writable global config,
    // then apply container-specific overrides (disable GPG signing, mark
    // /workspace as safe, rewrite SSH remotes to HTTPS since containers
    // lack host SSH keys).
    try {
      await this.execInContainer(containerId, [
        "sh", "-lc",
        [
          // Import user.name and user.email from host gitconfig (if mounted)
          "if [ -f /zencode-host-gitconfig ]; then " +
            "NAME=$(git config -f /zencode-host-gitconfig user.name 2>/dev/null); " +
            "EMAIL=$(git config -f /zencode-host-gitconfig user.email 2>/dev/null); " +
            '[ -n "$NAME" ] && git config --global user.name "$NAME"; ' +
            '[ -n "$EMAIL" ] && git config --global user.email "$EMAIL"; ' +
          "fi",
          // Disable GPG/SSH commit signing — host tools (1Password, GPG agent)
          // aren't available inside the container.
          "git config --global commit.gpgsign false 2>/dev/null",
          // Mark /workspace as safe — the workspace volume may be owned by a
          // different uid (e.g. ubuntu) than the container user (root), which
          // triggers git's "dubious ownership" check.
          "git config --global safe.directory /workspace 2>/dev/null",
          // Rewrite git@github.com:org/repo → https://github.com/org/repo for all remotes
          "cd /workspace 2>/dev/null && " +
            "git remote -v 2>/dev/null | grep 'git@github.com:' | awk '{print $1}' | sort -u | " +
            "while read remote; do " +
              "url=$(git remote get-url \"$remote\" 2>/dev/null); " +
              "https_url=$(echo \"$url\" | sed 's|git@github.com:|https://github.com/|'); " +
              "git remote set-url \"$remote\" \"$https_url\" 2>/dev/null; " +
            "done",
          "true",
        ].join("; "),
      ]);
    } catch { /* best-effort */ }
  }

  /**
   * Copy host workspace files into a running container's /workspace volume.
   * Uses a tar stream piped into `docker exec` for better throughput on Docker
   * Desktop (macOS) while preserving file structure and dotfiles.
   */
  async copyWorkspaceToContainer(
    containerId: string,
    hostCwd: string,
  ): Promise<void> {
    validateContainerId(containerId);

    // Pipe tar output directly into docker exec stdin — no shell needed.
    const tarProc = Bun.spawn(["tar", "-C", hostCwd, "-cf", "-", "."], {
      stdout: "pipe",
      stderr: "pipe",
      env: { ...process.env, COPYFILE_DISABLE: "1" },
    });

    const dockerProc = Bun.spawn(
      ["docker", "exec", "-i", containerId, "tar", "-xf", "-", "-C", "/workspace"],
      { stdin: tarProc.stdout, stdout: "pipe", stderr: "pipe" },
    );

    const timeout = new Promise<number>((resolve) => {
      setTimeout(() => resolve(-1), WORKSPACE_COPY_TIMEOUT_MS);
    });

    const stderrPromise = new Response(dockerProc.stderr).text();
    const exitCode = await Promise.race([dockerProc.exited, timeout]);

    if (exitCode === -1) {
      try { tarProc.kill(); } catch { /* best-effort */ }
      try { dockerProc.kill(); } catch { /* best-effort */ }
      throw new Error(`workspace copy timed out after ${Math.floor(WORKSPACE_COPY_TIMEOUT_MS / 1000)}s`);
    }

    if (exitCode !== 0) {
      const stderrText = await stderrPromise;
      throw new Error(
        `workspace copy failed (exit ${exitCode}): ${stderrText.trim() || "unknown error"}`,
      );
    }
  }

  /**
   * Re-seed git auth inside a container. Call this after workspace files have
   * been copied so SSH→HTTPS remote rewriting can find the `.git` directory.
   */
  async reseedGitAuth(containerId: string): Promise<void> {
    await this.seedGitAuth(containerId);
  }

  /** Parse `docker port` output to get host port mappings. */
  private async resolvePortMappings(containerId: string, ports: number[]): Promise<PortMapping[]> {
    const mappings: PortMapping[] = [];
    for (const containerPort of ports) {
      try {
        const raw = await execSpawn(
          ["docker", "port", containerId, String(containerPort)],
        );
        // Output like "0.0.0.0:49152" or "[::]:49152"
        const match = raw.match(/:(\d+)$/m);
        if (match) {
          mappings.push({
            containerPort,
            hostPort: parseInt(match[1], 10),
          });
        }
      } catch {
        log.warn(
          { containerId: containerId.slice(0, 12), containerPort },
          "Could not resolve port",
        );
      }
    }
    return mappings;
  }

  /**
   * Execute a command inside a running container.
   * Returns the stdout output. Throws on failure.
   */
  async execInContainer(containerId: string, cmd: string[], timeout = STANDARD_EXEC_TIMEOUT_MS): Promise<string> {
    validateContainerId(containerId);
    return execSpawn(["docker", "exec", containerId, ...cmd], { timeout });
  }

  /**
   * Execute a command inside a running container asynchronously.
   * Uses Bun.spawn for longer-running operations (like init scripts).
   * Returns exit code and combined stdout+stderr output.
   */
  async execInContainerAsync(
    containerId: string,
    cmd: string[],
    opts?: { timeout?: number; onOutput?: (line: string) => void },
  ): Promise<{ exitCode: number; output: string }> {
    validateContainerId(containerId);
    const timeout = opts?.timeout ?? 120_000;
    const dockerCmd = [
      "docker", "exec",
      containerId,
      ...cmd,
    ];

    const proc = Bun.spawn(dockerCmd, {
      stdout: "pipe",
      stderr: "pipe",
    });

    const lines: string[] = [];
    const decoder = new TextDecoder();

    // Read stdout
    const stdoutReader = proc.stdout.getReader();
    let stdoutBuffer = "";
    const readStdout = (async () => {
      try {
        while (true) {
          const { done, value } = await stdoutReader.read();
          if (done) break;
          stdoutBuffer += decoder.decode(value, { stream: true });
          const parts = stdoutBuffer.split("\n");
          stdoutBuffer = parts.pop() || "";
          for (const line of parts) {
            lines.push(line);
            opts?.onOutput?.(line);
          }
        }
        if (stdoutBuffer.trim()) {
          lines.push(stdoutBuffer);
          opts?.onOutput?.(stdoutBuffer);
        }
      } finally {
        stdoutReader.releaseLock();
      }
    })();

    // Read stderr
    const stderrPromise = new Response(proc.stderr).text();

    // Apply timeout — capture timer ID so we can clear it on normal exit
    const exitPromise = proc.exited;
    let timeoutId: ReturnType<typeof setTimeout> | undefined;
    const timeoutPromise = new Promise<never>((_, reject) => {
      timeoutId = setTimeout(() => {
        proc.kill();
        reject(new Error(`Command timed out after ${timeout}ms`));
      }, timeout);
    });

    try {
      const exitCode = await Promise.race([exitPromise, timeoutPromise]);
      clearTimeout(timeoutId);
      await readStdout;
      const stderrText = await stderrPromise;
      if (stderrText.trim()) {
        for (const line of stderrText.split("\n")) {
          if (line.trim()) {
            lines.push(line);
            opts?.onOutput?.(line);
          }
        }
      }
      return { exitCode, output: lines.join("\n") };
    } catch (e) {
      clearTimeout(timeoutId);
      await readStdout.catch((drainErr) => {
        log.debug({ err: drainErr }, "Failed to drain stdout during exec error cleanup");
      });
      throw e;
    }
  }

  /**
   * Re-track a container under a new key (e.g. when the real sessionId
   * is assigned after container creation).
   */
  retrack(containerId: string, newSessionId: string): void {
    for (const [oldKey, info] of this.containers) {
      if (info.containerId === containerId) {
        this.containers.delete(oldKey);
        this.containers.set(newSessionId, info);
        return;
      }
    }
  }

  /** Stop and remove a container. */
  async removeContainer(sessionId: string): Promise<void> {
    const info = this.containers.get(sessionId);
    if (!info) return;

    try {
      await execSpawn(["docker", "rm", "-f", info.containerId]);
      info.state = "removed";
      log.info(
        { containerName: info.name, containerId: info.containerId.slice(0, 12) },
        "Removed container",
      );
    } catch (e) {
      log.warn(
        { containerName: info.name, err: e instanceof Error ? e.message : String(e) },
        "Failed to remove container",
      );
    }

    // Clean up the named workspace volume if one was created
    if (info.volumeName) {
      try {
        await execSpawn(["docker", "volume", "rm", info.volumeName], {
          timeout: QUICK_EXEC_TIMEOUT_MS,
        });
        log.info({ volumeName: info.volumeName }, "Removed volume");
      } catch (e) {
        log.warn(
          { volumeName: info.volumeName, err: e instanceof Error ? e.message : String(e) },
          "Failed to remove volume",
        );
      }
    }

    this.containers.delete(sessionId);
  }

  /** Get container info for a session. */
  getContainer(sessionId: string): ContainerInfo | undefined {
    return this.containers.get(sessionId);
  }

  /** List all tracked containers. */
  listContainers(): ContainerInfo[] {
    return Array.from(this.containers.values());
  }

  /** Attempt to start a stopped container. Re-seeds auth files (tmpfs is wiped on stop). */
  async startContainer(containerId: string): Promise<void> {
    validateContainerId(containerId);
    await execSpawn(["docker", "start", containerId], {
      timeout: CONTAINER_BOOT_TIMEOUT_MS,
    });
    await this.seedAuthFiles(containerId);
    await this.seedCodexFiles(containerId);
    await this.seedGitAuth(containerId);
  }

  /**
   * Check whether a Docker container exists and its running state.
   * Returns "running", "stopped", or "missing".
   */
  async isContainerAlive(containerId: string): Promise<"running" | "stopped" | "missing"> {
    validateContainerId(containerId);
    try {
      const state = await execSpawn(
        ["docker", "inspect", "--format", "{{.State.Running}}", containerId],
        { timeout: QUICK_EXEC_TIMEOUT_MS },
      );
      return state === "true" ? "running" : "stopped";
    } catch {
      return "missing";
    }
  }

  /**
   * Check if a binary is available inside a running container.
   * Uses `bash -lc` so PATH includes nvm/bun/deno/etc.
   */
  async hasBinaryInContainer(containerId: string, binary: string): Promise<boolean> {
    validateContainerId(containerId);
    if (!/^[a-zA-Z0-9._-]+$/.test(binary)) {
      throw new Error(`Invalid binary name: ${binary}`);
    }
    try {
      await execSpawn(
        ["docker", "exec", containerId, "bash", "-lc", `which ${binary}`],
        { timeout: QUICK_EXEC_TIMEOUT_MS },
      );
      return true;
    } catch {
      return false;
    }
  }

  /**
   * Re-register a container that was persisted across a server restart.
   * Verifies the container still exists in Docker before tracking it.
   */
  async restoreContainer(sessionId: string, info: ContainerInfo): Promise<boolean> {
    try {
      const state = await execSpawn(
        ["docker", "inspect", "--format", "{{.State.Running}}", info.containerId],
      );
      if (state === "true") {
        info.state = "running";
      } else {
        info.state = "stopped";
      }
      this.containers.set(sessionId, info);
      log.info(
        { containerName: info.name, containerId: info.containerId.slice(0, 12), state: info.state },
        "Restored container",
      );
      return true;
    } catch {
      // Container no longer exists in Docker
      log.warn(
        { containerName: info.name, containerId: info.containerId.slice(0, 12) },
        "Container no longer exists, skipping restore",
      );
      return false;
    }
  }

  // ---------------------------------------------------------------------------
  // Persistence — survive server restarts
  // ---------------------------------------------------------------------------

  /** Persist all tracked container info to disk. */
  persistState(filePath: string): void {
    try {
      const entries: { sessionId: string; info: ContainerInfo }[] = [];
      for (const [sessionId, info] of this.containers) {
        if (info.state !== "removed") {
          entries.push({ sessionId, info });
        }
      }
      writeFileSync(filePath, JSON.stringify(entries, null, 2), "utf-8");
    } catch (e) {
      log.warn(
        { err: e instanceof Error ? e.message : String(e) },
        "Failed to persist state",
      );
    }
  }

  /** Restore container tracking from disk, verifying each container still exists. */
  async restoreState(filePath: string): Promise<number> {
    if (!existsSync(filePath)) return 0;
    try {
      const raw = readFileSync(filePath, "utf-8");
      const entries: { sessionId: string; info: ContainerInfo }[] = JSON.parse(raw);
      let restored = 0;
      for (const { sessionId, info } of entries) {
        if (await this.restoreContainer(sessionId, info)) {
          restored++;
        }
      }
      if (restored > 0) {
        log.info({ count: restored }, "Restored containers from disk");
      }
      return restored;
    } catch (e) {
      log.warn(
        { err: e instanceof Error ? e.message : String(e) },
        "Failed to restore state",
      );
      return 0;
    }
  }

  // ---------------------------------------------------------------------------
  // Image building
  // ---------------------------------------------------------------------------

  /**
   * Build a Docker image from a provided Dockerfile path.
   * Returns the build output log. Throws on failure.
   */
  async buildImage(dockerfilePath: string, tag: string = "zencode:latest"): Promise<string> {
    const contextDir = dockerfilePath.replace(/\/[^/]+$/, "") || ".";
    try {
      const output = await execSpawn(
        ["docker", "build", "-t", tag, "-f", dockerfilePath, contextDir],
        { timeout: 300_000 }, // 5 min for image builds
      );
      log.info({ tag }, "Built image");
      return output;
    } catch (e) {
      throw new Error(
        `Failed to build image ${tag}: ${e instanceof Error ? e.message : String(e)}`,
      );
    }
  }

  /**
   * Build a Docker image from inline Dockerfile content using Bun.spawn for streaming output.
   * Writes the Dockerfile to a temp directory and runs docker build.
   */
  async buildImageStreaming(
    dockerfileContent: string,
    tag: string,
    onProgress?: (line: string) => void,
  ): Promise<{ success: boolean; log: string }> {
    // Write Dockerfile to temp dir
    const buildDir = join(tmpdir(), `zencode-build-${Date.now()}`);
    mkdirSync(buildDir, { recursive: true });
    const dockerfilePath = join(buildDir, "Dockerfile");
    writeFileSync(dockerfilePath, dockerfileContent, "utf-8");

    try {
      const args = [
        "docker", "build",
        "-t", tag,
        "-f", dockerfilePath,
        buildDir,
      ];

      const proc = Bun.spawn(args, {
        stdout: "pipe",
        stderr: "pipe",
      });

      const lines: string[] = [];

      // Read stdout and stderr concurrently to avoid deadlock.
      // Docker BuildKit sends build progress to stderr; if we read them
      // sequentially, the stderr pipe buffer fills up and blocks Docker
      // while we're still waiting on stdout.
      const readStdout = (async () => {
        const reader = proc.stdout.getReader();
        const decoder = new TextDecoder();
        let buffer = "";
        try {
          while (true) {
            const { done, value } = await reader.read();
            if (done) break;
            buffer += decoder.decode(value, { stream: true });
            const parts = buffer.split("\n");
            buffer = parts.pop() || "";
            for (const line of parts) {
              if (line.trim()) {
                lines.push(line);
                onProgress?.(line);
              }
            }
          }
          if (buffer.trim()) {
            lines.push(buffer);
            onProgress?.(buffer);
          }
        } finally {
          reader.releaseLock();
        }
      })();

      const readStderr = (async () => {
        const text = await new Response(proc.stderr).text();
        if (text.trim()) {
          for (const line of text.split("\n")) {
            if (line.trim()) {
              lines.push(line);
              onProgress?.(line);
            }
          }
        }
      })();

      await Promise.all([readStdout, readStderr]);
      const exitCode = await proc.exited;
      const buildLog = lines.join("\n");

      if (exitCode === 0) {
        log.info({ tag }, "Built image (streaming)");
        return { success: true, log: buildLog };
      }

      return { success: false, log: buildLog };
    } finally {
      // Clean up temp build directory
      try { rmSync(buildDir, { recursive: true, force: true }); } catch { /* ignore */ }
    }
  }

  /**
   * Return the Docker Hub remote path for a default image, or null for non-default images.
   */
  static getRegistryImage(localTag: string): string | null {
    if (localTag === "zencode:latest") {
      return `${DOCKER_REGISTRY}/zencode:latest`;
    }
    return null;
  }

  /**
   * Pull a Docker image from a registry and optionally tag it locally.
   * Returns true on success, false on failure (never throws).
   */
  async pullImage(
    remoteImage: string,
    localTag: string,
    onProgress?: (line: string) => void,
  ): Promise<boolean> {
    try {
      const proc = Bun.spawn(["docker", "pull", remoteImage], {
        stdout: "pipe",
        stderr: "pipe",
      });

      const readOutput = async (stream: ReadableStream<Uint8Array>) => {
        const reader = stream.getReader();
        const decoder = new TextDecoder();
        let buffer = "";
        try {
          while (true) {
            const { done, value } = await reader.read();
            if (done) break;
            buffer += decoder.decode(value, { stream: true });
            const parts = buffer.split("\n");
            buffer = parts.pop() || "";
            for (const line of parts) {
              if (line.trim()) onProgress?.(line);
            }
          }
          if (buffer.trim()) onProgress?.(buffer);
        } finally {
          reader.releaseLock();
        }
      };

      let timeoutId: ReturnType<typeof setTimeout> | undefined;
      const timeoutPromise = new Promise<never>((_, reject) => {
        timeoutId = setTimeout(() => {
          proc.kill();
          reject(new Error("Pull timed out"));
        }, IMAGE_PULL_TIMEOUT_MS);
      });

      const exitPromise = (async () => {
        await Promise.all([readOutput(proc.stdout), readOutput(proc.stderr)]);
        return proc.exited;
      })();

      const exitCode = await Promise.race([exitPromise, timeoutPromise]);
      clearTimeout(timeoutId);

      if (exitCode !== 0) {
        log.warn({ remoteImage, exitCode }, "docker pull failed");
        return false;
      }

      // Tag as local name if different
      if (remoteImage !== localTag) {
        await execSpawn(["docker", "tag", remoteImage, localTag], {
          timeout: QUICK_EXEC_TIMEOUT_MS,
        });
      }

      log.info({ remoteImage, localTag }, "Pulled image");
      return true;
    } catch (e) {
      log.warn(
        { remoteImage, err: e instanceof Error ? e.message : String(e) },
        "Pull failed",
      );
      return false;
    }
  }

  /** Clean up all tracked containers (e.g. on server shutdown). */
  async cleanupAll(): Promise<void> {
    for (const [sessionId] of this.containers) {
      await this.removeContainer(sessionId);
    }
  }
}

// ---------------------------------------------------------------------------
// Helpers — validation
// ---------------------------------------------------------------------------

/** Validate that a container ID is a hex string (Docker format) or a safe container name. */
function validateContainerId(id: string): void {
  // Docker container IDs are 64-char hex, but we accept short IDs too.
  // Container names are alphanumeric with hyphens and underscores.
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_.\-]*$/.test(id)) {
    throw new Error(`Invalid container ID or name: ${id.slice(0, 40)}`);
  }
}

// Singleton
export const containerManager = new ContainerManager();
