/**
 * SandboxPool — Pre-warmed Docker containers with dedicated git clones.
 *
 * Each pool slot is a (container, clone) pair:
 *   - The clone is a local git clone checked out on the default branch
 *   - The container has that clone bind-mounted as /workspace
 *
 * On claim, the caller can request a branch checkout (for goals/members).
 * On release, the slot is destroyed (container killed, clone deleted).
 *
 * Design: slots are cheap to create (~3-5s — git clone --local + docker create).
 * The worktree setup command is skipped for pool slots because the container
 * entrypoint handles cross-platform dependency installation via its own cache.
 * This keeps the pool simple: no readoption, no idle culling, no health checks
 * trying to keep long-lived containers alive. Fresh containers on every cycle
 * eliminates the entire class of "dead container handed out" race conditions.
 */
import { execFile as execFileCb } from "node:child_process";
import { promisify } from "node:util";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { buildDockerRunArgs } from "./docker-args.js";
import { toDockerPath } from "./rpc-bridge.js";
const execFileAsync = promisify(execFileCb);
// ── Helpers ────────────────────────────────────────────────────────────────
function poolHash(projectDir) {
    return crypto.createHash("sha256").update(projectDir).digest("hex").substring(0, 12);
}
/** Git exec helper with timeout */
async function git(args, cwd, timeoutMs = 30_000) {
    const { stdout } = await execFileAsync("git", args, { cwd, timeout: timeoutMs });
    return stdout.toString().trim();
}
/** Get the default branch name (master or main) */
async function getDefaultBranch(repoPath) {
    try {
        const ref = await git(["symbolic-ref", "refs/remotes/origin/HEAD"], repoPath);
        return ref.replace("refs/remotes/origin/", "");
    }
    catch {
        // Fallback: check if master exists
        try {
            await git(["rev-parse", "--verify", "master"], repoPath);
            return "master";
        }
        catch {
            return "main";
        }
    }
}
// ── Team remote setup (extracted for testability) ──────────────────────────
/**
 * Configure a 'team' git remote and post-commit hook in a clone directory.
 * The remote URL uses the container-internal /team-repos/ path so it resolves
 * correctly inside Docker via bind-mount.
 */
export function setupTeamRemote(worktreePath, teamRepoPath) {
    const repoName = path.basename(teamRepoPath); // "team-<goalId>.git"
    const containerRepoPath = `/team-repos/${repoName}`;
    // Add team remote — URL is the container-internal path.
    // Written directly to .git/config to avoid MSYS path mangling on Windows
    // (Git Bash converts /team-repos/... to C:/Program Files/Git/team-repos/...).
    const gitConfigPath = path.join(worktreePath, ".git", "config");
    const remoteSection = `\n[remote "team"]\n\turl = ${containerRepoPath}\n\tfetch = +refs/heads/*:refs/remotes/team/*\n`;
    fs.appendFileSync(gitConfigPath, remoteSection);
    // Install non-blocking post-commit hook
    const hookDir = path.join(worktreePath, ".git", "hooks");
    fs.mkdirSync(hookDir, { recursive: true });
    const hookScript = '#!/bin/sh\nbranch=$(git symbolic-ref --short HEAD 2>/dev/null)\n[ -n "$branch" ] && git push team "$branch" 2>/dev/null &\n';
    fs.writeFileSync(path.join(hookDir, "post-commit"), hookScript, { mode: 0o755 });
}
// ── SandboxPool ────────────────────────────────────────────────────────────
export class SandboxPool {
    options;
    slots = new Map(); // keyed by containerId
    _shutdownRequested = false;
    _replenishing = false;
    label;
    _poolDir; // host path to pool clone directories
    _defaultBranch = "master";
    constructor(options) {
        this.options = options;
        this.label = poolHash(options.projectDir);
        this._poolDir = path.join(path.resolve(options.projectDir, "..", `${path.basename(options.projectDir)}-wt`), ".sandbox-pool");
    }
    /** Host path to the pool directory (used to resolve team repo paths on restore). */
    get poolDir() { return this._poolDir; }
    /** Initialize the pool: detect branch, ensure dirs, kill old containers.
     *  Call warmup() separately to pre-warm spare containers in the background. */
    async init() {
        console.log(`[sandbox-pool] Initializing (size=${this.options.poolSize}, image=${this.options.image}, label=bobbit-sandbox=${this.label})`);
        // Detect default branch
        try {
            this._defaultBranch = await getDefaultBranch(this.options.repoPath);
        }
        catch {
            this._defaultBranch = "master";
        }
        // Ensure pool directory exists
        fs.mkdirSync(this._poolDir, { recursive: true });
        // Kill ALL containers from previous runs — no readoption. Fresh slots are
        // cheap (~3-5s) and eliminate stale-container bugs entirely.
        await this._killAllPreviousContainers();
    }
    /** Pre-warm spare containers and clean up stale directories.
     *  Safe to call fire-and-forget — errors are caught and logged. */
    async warmup() {
        try {
            // Clean up stale clone directories on disk (can be slow on Windows
            // with hundreds of deep node_modules trees)
            this._cleanupStaleCloneDirs();
            // Pre-warm to target size
            console.log(`[sandbox-pool] Pre-warming ${this.options.poolSize} slot(s) in background...`);
            await Promise.all(Array.from({ length: this.options.poolSize }, () => this._createSlot()));
            console.log(`[sandbox-pool] Ready — ${this._statsString()}`);
        }
        catch (err) {
            console.error("[sandbox-pool] Pre-warming failed:", err);
        }
    }
    /**
     * Claim an idle slot. Optionally check out a branch.
     * If no idle slots are available, creates one on-demand.
     * Validates the container is alive before handing it out.
     */
    async claim(sessionId, opts) {
        // Find an idle slot, verifying the container is actually alive.
        // Dead containers are evicted and we retry with the next idle slot.
        let slot;
        const maxRetries = 3;
        for (let attempt = 0; attempt < maxRetries; attempt++) {
            slot = undefined;
            for (const s of this.slots.values()) {
                if (s.state === "idle") {
                    slot = s;
                    break;
                }
            }
            if (!slot)
                break; // no idle slots left — fall through to on-demand creation
            // Liveness probe: verify the container is still running
            if (!(await this._isContainerAlive(slot))) {
                console.warn(`[sandbox-pool] Idle slot ${slot.shortId} has a dead container — evicting and retrying`);
                await this._destroySlot(slot);
                slot = undefined;
                continue;
            }
            break; // slot is alive
        }
        if (!slot) {
            console.log(`[sandbox-pool] Pool exhausted — creating on-demand slot for session ${sessionId.slice(0, 8)}`);
            slot = await this._createSlot();
            if (!slot)
                return null;
        }
        slot.state = "claimed";
        slot.sessions.add(sessionId);
        // Checkout the requested branch
        if (opts?.branch) {
            try {
                await this._checkoutBranch(slot, opts.branch, opts.from);
            }
            catch (err) {
                console.error(`[sandbox-pool] Branch checkout failed for slot ${slot.shortId}:`, err);
                // Destroy broken slot — don't try to reset, just kill it
                slot.sessions.delete(sessionId);
                await this._destroySlot(slot);
                return null;
            }
        }
        else {
            // Pull latest default branch for regular sessions
            try {
                await git(["pull", "--ff-only", "origin", this._defaultBranch], slot.worktreePath, 30_000);
            }
            catch {
                // Pull failure is non-fatal — slot is usable on current HEAD
            }
        }
        // Set up team remote and post-commit hook for shared team repo
        if (opts?.teamRepoPath) {
            try {
                setupTeamRemote(slot.worktreePath, opts.teamRepoPath);
                // Fetch existing commits from the team bare repo — recovers work
                // pushed by a previous container's post-commit hook (e.g. after restart)
                await git(["fetch", "team"], slot.worktreePath, 30_000);
            }
            catch (err) {
                console.warn(`[sandbox-pool] Failed to set up team remote for slot ${slot.shortId}:`, err);
                // Non-fatal — the slot is still usable, just without auto-push
            }
        }
        console.log(`[sandbox-pool] Claimed slot ${slot.shortId} for session ${sessionId.slice(0, 8)} (branch: ${slot.branch}) — ${this._statsString()}`);
        // Replenish in background
        this._replenish().catch(() => { });
        return { containerId: slot.containerId, worktreePath: slot.worktreePath };
    }
    /** Release a session from its slot. Destroys the slot when all sessions are done. */
    async release(sessionId, containerId) {
        const slot = this.slots.get(containerId);
        if (!slot)
            return;
        slot.sessions.delete(sessionId);
        if (slot.sessions.size > 0)
            return; // other sessions still using this slot
        // Destroy the slot — fresh containers on every cycle
        await this._destroySlot(slot);
        console.log(`[sandbox-pool] Released and destroyed slot ${containerId.slice(0, 12)} — ${this._statsString()}`);
        // Replenish in background
        this._replenish().catch(() => { });
    }
    /** Get pool statistics. */
    getStats() {
        return {
            enabled: true,
            total: this.slots.size,
            idle: this._countByState("idle"),
            claimed: this._countByState("claimed"),
            warming: this._countByState("warming"),
        };
    }
    /** Graceful shutdown: drain sessions, stop containers, clean up directories. */
    async shutdown() {
        this._shutdownRequested = true;
        // Wait briefly for claimed containers to drain
        const drainTimeout = 10_000;
        const start = Date.now();
        while (this._countByState("claimed") > 0 && Date.now() - start < drainTimeout) {
            await new Promise(r => setTimeout(r, 500));
        }
        // Destroy all slots (kill container + remove directory)
        const destroyPromises = [...this.slots.values()].map(slot => this._destroySlot(slot));
        await Promise.allSettled(destroyPromises);
        this.slots.clear();
    }
    /** No-op — kept for API compatibility. Simplified pool has no timers. */
    dispose() {
        // Nothing to dispose — simplified pool has no periodic timers
    }
    // ── Slot lifecycle ─────────────────────────────────────────────────────
    /** Create a new pool slot: clone repo + create container. Returns the slot, or undefined on failure. */
    async _createSlot() {
        if (this._shutdownRequested)
            return undefined;
        const slotId = crypto.randomUUID().slice(0, 8);
        const slotName = `pool-${slotId}`;
        const worktreePath = path.join(this._poolDir, slotName);
        try {
            // Clone the repo into the slot directory (not a git worktree — avoids
            // absolute path issues in Docker where .git pointers don't resolve).
            // Uses --local for speed (hard-links objects).
            await execFileAsync("git", [
                "clone", "--local", "--no-checkout",
                this.options.repoPath, worktreePath,
            ], {
                timeout: 60_000,
                cwd: this.options.repoPath,
            });
            // Checkout the default branch
            await git(["checkout", this._defaultBranch], worktreePath, 30_000);
            // Fix origin URL: `git clone --local` sets origin to the local host path,
            // which is inaccessible from inside a Docker container. Replace it with the
            // real upstream remote URL so push/fetch/PR work inside the container.
            try {
                const { stdout: realOrigin } = await execFileAsync("git", ["remote", "get-url", "origin"], { cwd: this.options.repoPath, timeout: 5_000 });
                const upstreamUrl = realOrigin.trim();
                if (upstreamUrl && upstreamUrl !== this.options.repoPath) {
                    await git(["remote", "set-url", "origin", upstreamUrl], worktreePath, 5_000);
                }
            }
            catch {
                // No upstream remote — local-only repo, push won't work but that's expected
            }
            // NOTE: worktreeSetupCommand is intentionally NOT run for pool slots.
            // The container entrypoint (bobbit-entrypoint.sh) handles cross-platform
            // dependency installation with its own cache. Running npm ci on the host
            // would install host-platform (e.g. Windows) native modules that the Linux
            // container replaces anyway — wasted work.
            // Create Docker container with this clone mounted as /workspace
            const dockerArgs = this._buildDockerArgs(worktreePath);
            const { stdout } = await execFileAsync("docker", dockerArgs, {
                timeout: 30_000,
                env: { ...process.env, MSYS_NO_PATHCONV: "1", MSYS2_ARG_CONV_EXCL: "*" },
            });
            const containerId = stdout.trim();
            if (!containerId) {
                console.warn(`[sandbox-pool] docker run returned empty container ID for ${slotName}`);
                await this._removeCloneDir(worktreePath);
                return undefined;
            }
            // Defense-in-depth: mask /proc/1/environ so env vars can't be read by the agent
            try {
                await execFileAsync("docker", [
                    "exec", containerId, "sh", "-c",
                    "mount --bind /dev/null /proc/1/environ 2>/dev/null || chmod 0400 /proc/1/environ 2>/dev/null || true",
                ], {
                    timeout: 5_000,
                    env: { ...process.env, MSYS_NO_PATHCONV: "1", MSYS2_ARG_CONV_EXCL: "*" },
                });
            }
            catch {
                // Non-fatal — the primary defense is not passing sensitive env vars
                // to `docker run` (they go via `docker exec -e` instead).
            }
            const slot = {
                containerId,
                shortId: containerId.substring(0, 12),
                worktreePath,
                state: "idle",
                sessions: new Set(),
                branch: this._defaultBranch,
            };
            this.slots.set(containerId, slot);
            console.log(`[sandbox-pool] Created slot ${slot.shortId} at ${slotName}`);
            return slot;
        }
        catch (err) {
            console.warn(`[sandbox-pool] Failed to create slot ${slotName}:`, err);
            await this._removeCloneDir(worktreePath);
            return undefined;
        }
    }
    /** Build docker run args for a pool container. */
    _buildDockerArgs(worktreePath) {
        const args = buildDockerRunArgs({
            image: this.options.image,
            workspaceDir: worktreePath,
            label: this.label,
            labelPrefix: "bobbit-sandbox",
            labelVersion: "2",
            worktreePath,
            sandboxMounts: this.options.sandboxMounts,
            sandboxCredentials: this.options.sandboxCredentials,
            sandboxNetwork: this.options.sandboxNetwork,
        });
        // Mount the pool directory so team shared repos are accessible inside containers.
        // New subdirs created on the host (e.g. team-<goalId>.git) are immediately
        // visible inside running containers — standard Docker bind-mount behavior.
        // Insert before the last 3 elements (image, "sleep", "infinity") so the
        // -v flag is in the correct position for docker run.
        const insertIdx = args.length - 3;
        args.splice(insertIdx, 0, "-v", `${toDockerPath(this._poolDir)}:/team-repos`);
        return args;
    }
    // ── Team shared repo ──────────────────────────────────────────────────
    /**
     * Create a shared bare git repo for a sandboxed team.
     * Idempotent — returns existing path if already created.
     */
    async createTeamRepo(goalId, sourceRepo, branch) {
        const repoPath = path.join(this._poolDir, `team-${goalId}.git`);
        if (fs.existsSync(repoPath))
            return repoPath; // idempotent
        await execFileAsync("git", ["clone", "--bare", sourceRepo, repoPath], {
            timeout: 60_000,
            cwd: this.options.repoPath,
        });
        // Fetch the goal branch into the bare repo
        try {
            await execFileAsync("git", ["fetch", "origin", branch], { cwd: repoPath, timeout: 30_000 });
        }
        catch { /* branch may not exist on origin yet */ }
        console.log(`[sandbox-pool] Created team repo for goal ${goalId} at ${repoPath}`);
        return repoPath;
    }
    /**
     * Destroy the shared bare repo for a team. Retries on Windows lock failures.
     */
    async destroyTeamRepo(goalId) {
        const repoPath = path.join(this._poolDir, `team-${goalId}.git`);
        const maxAttempts = 3;
        for (let attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                await fs.promises.rm(repoPath, { recursive: true, force: true });
                console.log(`[sandbox-pool] Destroyed team repo for goal ${goalId}`);
                return;
            }
            catch (err) {
                if (attempt < maxAttempts && (err?.code === "EBUSY" || err?.code === "EPERM" || err?.code === "ENOTEMPTY")) {
                    await new Promise(r => setTimeout(r, 500 * attempt));
                    continue;
                }
                console.warn(`[sandbox-pool] Failed to remove team repo for goal ${goalId} after ${attempt} attempt(s): ${err?.code || err}`);
            }
        }
    }
    /** Checkout a branch in a slot's clone. */
    async _checkoutBranch(slot, branch, from) {
        const wt = slot.worktreePath;
        // Fetch latest from origin
        await git(["fetch", "origin"], wt, 30_000);
        // Try to checkout the branch — may already exist on the remote
        try {
            if (from) {
                await git(["checkout", "-b", branch, from], wt, 15_000);
            }
            else {
                await git(["checkout", "-b", branch], wt, 15_000);
            }
        }
        catch {
            // Branch already exists — checkout the existing remote branch
            try {
                await git(["checkout", branch], wt, 15_000);
            }
            catch {
                await git(["checkout", "-B", branch, `origin/${branch}`], wt, 15_000);
            }
        }
        // Pull latest from remote
        try {
            await git(["pull", "--ff-only", "origin", branch], wt, 30_000);
        }
        catch {
            // Pull may fail if no upstream or diverged — non-fatal
        }
        slot.branch = branch;
        // Set upstream tracking (non-fatal)
        try {
            await git(["branch", "--set-upstream-to", `origin/${branch}`], wt, 10_000);
        }
        catch { /* may not exist on remote yet */ }
        // Push if this is a new branch (non-fatal)
        try {
            await git(["push", "-u", "origin", branch], wt, 30_000);
        }
        catch { /* push may fail — not fatal */ }
    }
    /** Quick liveness probe — returns true if the container is running. */
    async _isContainerAlive(slot) {
        try {
            const { stdout } = await execFileAsync("docker", [
                "inspect", "--format", "{{.State.Running}}", slot.containerId,
            ], {
                timeout: 5_000,
                env: { ...process.env, MSYS_NO_PATHCONV: "1" },
            });
            return stdout.trim() === "true";
        }
        catch {
            return false; // container doesn't exist or inspect failed
        }
    }
    /** Destroy a slot entirely (kill container, remove clone directory). */
    async _destroySlot(slot) {
        this.slots.delete(slot.containerId);
        try {
            await execFileAsync("docker", ["rm", "-f", slot.containerId], {
                timeout: 15_000,
                env: { ...process.env, MSYS_NO_PATHCONV: "1" },
            });
        }
        catch { /* already gone */ }
        await this._removeCloneDir(slot.worktreePath);
    }
    /** Remove a pool slot's cloned repo from disk. Retries on Windows lock failures. */
    async _removeCloneDir(clonePath) {
        const maxAttempts = 3;
        for (let attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                await fs.promises.rm(clonePath, { recursive: true, force: true });
                return;
            }
            catch (err) {
                // EBUSY/EPERM/ENOTEMPTY are common on Windows (locked files, antivirus)
                if (attempt < maxAttempts && (err?.code === "EBUSY" || err?.code === "EPERM" || err?.code === "ENOTEMPTY")) {
                    await new Promise(r => setTimeout(r, 500 * attempt));
                    continue;
                }
                console.warn(`[sandbox-pool] Failed to remove ${path.basename(clonePath)} after ${attempt} attempt(s): ${err?.code || err}`);
            }
        }
    }
    // ── Pool maintenance ───────────────────────────────────────────────────
    /** Kill all containers from previous gateway runs. No readoption — start fresh. */
    async _killAllPreviousContainers() {
        try {
            const { stdout } = await execFileAsync("docker", [
                "ps", "-a",
                "--filter", `label=bobbit-sandbox=${this.label}`,
                "--format", "{{.ID}}",
            ], {
                timeout: 10_000,
                env: { ...process.env, MSYS_NO_PATHCONV: "1" },
            });
            const ids = stdout.trim().split("\n").filter(Boolean);
            if (ids.length === 0)
                return;
            console.log(`[sandbox-pool] Removing ${ids.length} container(s) from previous run...`);
            await Promise.allSettled(ids.map(id => execFileAsync("docker", ["rm", "-f", id], {
                timeout: 10_000,
                env: { ...process.env, MSYS_NO_PATHCONV: "1" },
            }).catch(() => { })));
        }
        catch { /* no containers */ }
    }
    /** Remove stale clone directories that have no matching container (async, non-blocking). */
    _cleanupStaleCloneDirs() {
        try {
            if (!fs.existsSync(this._poolDir))
                return;
            const entries = fs.readdirSync(this._poolDir)
                .filter(e => e.startsWith("pool-"));
            if (entries.length === 0)
                return;
            console.log(`[sandbox-pool] Cleaning up ${entries.length} stale clone dir(s) in background...`);
            // Delete sequentially via async chain to avoid blocking the event loop.
            // Each rm is awaited individually so Node can interleave other work.
            (async () => {
                let removed = 0;
                for (const entry of entries) {
                    try {
                        await fs.promises.rm(path.join(this._poolDir, entry), { recursive: true, force: true });
                        removed++;
                    }
                    catch { /* best effort */ }
                }
                console.log(`[sandbox-pool] Cleaned up ${removed}/${entries.length} stale clone dir(s)`);
            })().catch(() => { });
        }
        catch { /* ignore */ }
    }
    /** Fill pool back to target size. */
    async _replenish() {
        if (this._replenishing || this._shutdownRequested)
            return;
        this._replenishing = true;
        try {
            const needed = Math.max(0, this.options.poolSize - this._countByState("idle"));
            if (needed > 0) {
                console.log(`[sandbox-pool] Replenishing ${needed} slot(s)...`);
                await Promise.allSettled(Array.from({ length: needed }, () => this._createSlot()));
            }
        }
        finally {
            this._replenishing = false;
        }
    }
    // ── Utilities ──────────────────────────────────────────────────────────
    _countByState(state) {
        let count = 0;
        for (const s of this.slots.values()) {
            if (s.state === state)
                count++;
        }
        return count;
    }
    _statsString() {
        return `idle=${this._countByState("idle")} claimed=${this._countByState("claimed")} warming=${this._countByState("warming")} total=${this.slots.size}`;
    }
}
//# sourceMappingURL=sandbox-pool.js.map