/**
 * Background process manager — spawns and tracks long-running shell processes
 * per session. Agents create bg processes via bash_bg_create tool (extension),
 * which calls the gateway REST API. The manager broadcasts real-time events
 * (output, exit) to connected WebSocket clients.
 */
import { spawn } from "node:child_process";
import { getShellConfig } from "./shell-util.js";
function defaultSpawn(command, cwd, containerId) {
    const { shell: hostShell, args: hostArgs } = getShellConfig();
    // Inside the container: always /bin/sh, use the caller's cwd
    // (which is the container-internal worktree path for sandboxed sessions)
    const shell = containerId ? "/bin/sh" : hostShell;
    const args = containerId ? ["-c"] : hostArgs;
    const containerCwd = cwd;
    if (containerId) {
        console.log(`[bg-process] Docker exec in container ${containerId.substring(0, 12)}, cwd=${containerCwd}`);
    }
    return containerId
        ? spawn("docker", ["exec", "-w", containerCwd, containerId, shell, ...args, command], {
            stdio: ["ignore", "pipe", "pipe"],
            detached: false, // docker exec manages the process
            env: { ...process.env, MSYS_NO_PATHCONV: "1", MSYS2_ARG_CONV_EXCL: "*" },
        })
        : spawn(shell, [...args, command], {
            cwd,
            stdio: ["ignore", "pipe", "pipe"],
            detached: true,
            env: process.env,
        });
}
const MAX_LOG_LINES = 5000;
const MAX_LOG_BYTES = 512 * 1024; // 512KB per process
// Shell config is provided by the shared shell-util module
export class BgProcessManager {
    /** sessionId → Map<bgId, BgProcess> */
    processes = new Map();
    /** sessionId → Set<WebSocket> — populated by session manager */
    clientsProvider;
    /** sessionId → in-flight wait AbortControllers. Aborted when a steer arrives. */
    waits = new Map();
    /** Per-instance id sequence — keeps test runs isolated from each other. */
    nextId = 1;
    /** Spawner — overridable in tests so unit tests don't touch the OS. */
    spawnFn;
    constructor(clientsProvider, spawnFn = defaultSpawn) {
        this.clientsProvider = clientsProvider;
        this.spawnFn = spawnFn;
    }
    broadcast(sessionId, msg) {
        const clients = this.clientsProvider(sessionId);
        if (!clients)
            return;
        const data = JSON.stringify(msg);
        for (const client of clients) {
            if (client.readyState === 1) {
                client.send(data);
            }
        }
    }
    create(sessionId, command, cwd, containerId, sandboxed, name) {
        if (sandboxed && !containerId) {
            throw new Error("Sandboxed session without containerId — refusing host-side execution");
        }
        const id = `bg-${this.nextId++}`;
        const child = this.spawnFn(command, cwd, containerId);
        // Unref so bg process doesn't prevent gateway from exiting (host spawns only)
        if (!containerId && typeof child.unref === "function")
            child.unref();
        let resolveExited;
        const exited = new Promise((res) => { resolveExited = res; });
        const bg = {
            id,
            name: name || id,
            command,
            pid: child.pid,
            child,
            stdout: [],
            stderr: [],
            log: [],
            status: "running",
            exitCode: null,
            startTime: Date.now(),
            endTime: null,
            cwd,
            containerId,
            exited,
        };
        let logBytes = 0;
        const appendLog = (line) => {
            const entry = { ts: Date.now(), text: line };
            bg.log.push(entry);
            logBytes += line.length;
            // Trim oldest lines if over limits
            while (bg.log.length > MAX_LOG_LINES || logBytes > MAX_LOG_BYTES) {
                const removed = bg.log.shift();
                if (removed)
                    logBytes -= removed.text.length;
            }
        };
        child.stdout?.on("data", (chunk) => {
            const text = chunk.toString("utf-8");
            const ts = Date.now();
            const lines = text.split("\n");
            for (const line of lines) {
                if (line.length > 0) {
                    bg.stdout.push(line);
                    appendLog(line);
                }
            }
            // Trim stdout buffer
            while (bg.stdout.length > MAX_LOG_LINES)
                bg.stdout.shift();
            this.broadcast(sessionId, {
                type: "bg_process_output",
                processId: id,
                stream: "stdout",
                text,
                ts,
            });
        });
        child.stderr?.on("data", (chunk) => {
            const text = chunk.toString("utf-8");
            const ts = Date.now();
            const lines = text.split("\n");
            for (const line of lines) {
                if (line.length > 0) {
                    bg.stderr.push(line);
                    appendLog(line);
                }
            }
            while (bg.stderr.length > MAX_LOG_LINES)
                bg.stderr.shift();
            this.broadcast(sessionId, {
                type: "bg_process_output",
                processId: id,
                stream: "stderr",
                text,
                ts,
            });
        });
        // Listen on 'exit' not 'close' — exit fires when the process itself ends,
        // close waits for all FD holders (grandchildren) to release pipes.
        child.on("exit", (code) => {
            bg.status = "exited";
            bg.exitCode = code;
            bg.endTime = Date.now();
            // Resolve BEFORE broadcast/destroy so any awaiter on `bg.exited`
            // observes status === 'exited' the moment they're scheduled.
            resolveExited();
            // Destroy pipes to avoid lingering from grandchild processes
            child.stdout?.destroy();
            child.stderr?.destroy();
            this.broadcast(sessionId, {
                type: "bg_process_exited",
                processId: id,
                exitCode: code,
                endTime: bg.endTime,
            });
        });
        if (!this.processes.has(sessionId)) {
            this.processes.set(sessionId, new Map());
        }
        this.processes.get(sessionId).set(id, bg);
        this.broadcast(sessionId, {
            type: "bg_process_created",
            process: this.toInfo(bg),
        });
        return this.toInfo(bg);
    }
    list(sessionId) {
        const map = this.processes.get(sessionId);
        if (!map)
            return [];
        return Array.from(map.values()).map((bg) => this.toInfo(bg));
    }
    getLogs(sessionId, processId) {
        const bg = this.processes.get(sessionId)?.get(processId);
        if (!bg)
            return null;
        return { log: bg.log, stdout: bg.stdout, stderr: bg.stderr };
    }
    /** Search logs for a pattern (string or regex). Returns matching lines with context. */
    grepLogs(sessionId, processId, pattern, contextLines = 0, maxResults = 50) {
        const bg = this.processes.get(sessionId)?.get(processId);
        if (!bg)
            return null;
        let regex;
        try {
            regex = new RegExp(pattern, "i");
        }
        catch {
            // Fall back to literal match if invalid regex
            regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
        }
        const log = bg.log;
        const matchIndices = [];
        for (let i = 0; i < log.length; i++) {
            if (regex.test(log[i].text))
                matchIndices.push(i);
        }
        const total = matchIndices.length;
        // Collect matches with context, deduplicating overlapping ranges
        const seen = new Set();
        const matches = [];
        for (const idx of matchIndices.slice(0, maxResults)) {
            const start = Math.max(0, idx - contextLines);
            const end = Math.min(log.length - 1, idx + contextLines);
            for (let i = start; i <= end; i++) {
                if (!seen.has(i)) {
                    seen.add(i);
                    matches.push({ line: i + 1, ts: log[i].ts, text: log[i].text });
                }
            }
        }
        return { matches, total };
    }
    /** Get first N lines of logs. */
    headLogs(sessionId, processId, lines = 50) {
        const bg = this.processes.get(sessionId)?.get(processId);
        if (!bg)
            return null;
        return { log: bg.log.slice(0, lines), totalLines: bg.log.length };
    }
    /** Get a range of log lines (1-indexed). */
    sliceLogs(sessionId, processId, from, to) {
        const bg = this.processes.get(sessionId)?.get(processId);
        if (!bg)
            return null;
        return { log: bg.log.slice(Math.max(0, from - 1), to), totalLines: bg.log.length };
    }
    kill(sessionId, processId) {
        const bg = this.processes.get(sessionId)?.get(processId);
        if (!bg || bg.status !== "running")
            return false;
        try {
            if (bg.containerId) {
                // Docker exec: kill the docker exec process on host, which signals the container process
                try {
                    bg.child.kill("SIGTERM");
                }
                catch { /* ignore */ }
            }
            else if (bg.child.pid) {
                // Kill the process group (detached processes get their own group)
                if (process.platform === "win32") {
                    // Windows: use taskkill to kill the tree
                    spawn("taskkill", ["/pid", String(bg.child.pid), "/T", "/F"], { stdio: "ignore" });
                }
                else {
                    process.kill(-bg.child.pid, "SIGTERM");
                }
            }
        }
        catch {
            // Process may already be dead
            try {
                bg.child.kill("SIGKILL");
            }
            catch { /* ignore */ }
        }
        return true;
    }
    /** Remove an exited process from the map. Returns true if removed. */
    remove(sessionId, processId) {
        const bg = this.processes.get(sessionId)?.get(processId);
        if (!bg)
            return false;
        if (bg.status === "running")
            return false; // must kill first
        this.processes.get(sessionId).delete(processId);
        if (this.processes.get(sessionId).size === 0)
            this.processes.delete(sessionId);
        return true;
    }
    /** Clean up all bg processes for a session (on terminate) */
    cleanup(sessionId) {
        // Release any hanging wait handlers first so the HTTP responses resolve cleanly.
        this.abortAllWaits(sessionId);
        const map = this.processes.get(sessionId);
        if (!map)
            return;
        for (const [, bg] of map) {
            if (bg.status === "running") {
                try {
                    bg.child.kill("SIGTERM");
                }
                catch { /* ignore */ }
            }
        }
        this.processes.delete(sessionId);
    }
    /**
     * Register an AbortController so that `abortAllWaits(sessionId)` (called from
     * live-steer call sites or on session termination) can cancel in-flight waits.
     */
    registerWait(sessionId, controller) {
        let set = this.waits.get(sessionId);
        if (!set) {
            set = new Set();
            this.waits.set(sessionId, set);
        }
        set.add(controller);
    }
    /** Unregister a previously registered controller (called in the handler's finally). */
    unregisterWait(sessionId, controller) {
        const set = this.waits.get(sessionId);
        if (!set)
            return;
        set.delete(controller);
        if (set.size === 0)
            this.waits.delete(sessionId);
    }
    /** Abort every in-flight wait for a session. Leaves the bg processes untouched. */
    abortAllWaits(sessionId) {
        const set = this.waits.get(sessionId);
        if (!set)
            return;
        for (const controller of set) {
            try {
                controller.abort();
            }
            catch { /* ignore */ }
        }
        // cleanup happens via unregisterWait in the handler's finally
    }
    /**
     * Wait for a process to exit, the timeout to fire, or the provided AbortSignal.
     * Return shape is additive — happy-path/timeout set `aborted: false`; on abort
     * the process is left running and `info` is a snapshot (status still running).
     */
    async waitForExit(sessionId, processId, timeoutMs, signal) {
        const bg = this.processes.get(sessionId)?.get(processId);
        if (!bg)
            return null;
        if (bg.status === "exited")
            return { info: this.toInfo(bg), timedOut: false, aborted: false };
        if (signal?.aborted)
            return { info: this.toInfo(bg), timedOut: false, aborted: true };
        // Race exit / timeout / abort. We do NOT attach an "exit" listener to the
        // child — instead we await `bg.exited`, which is resolved by the single
        // listener installed in create() AFTER status/exitCode are updated. That
        // removes both the "50 ms slack" hack and the per-call exit-listener leak.
        let timer = null;
        let onAbort = null;
        const timeoutP = new Promise((res) => {
            timer = setTimeout(() => res("timeout"), timeoutMs);
        });
        const abortP = new Promise((res) => {
            if (!signal)
                return; // never resolves — Promise.race ignores it
            onAbort = () => res("abort");
            signal.addEventListener("abort", onAbort, { once: true });
        });
        const exitP = bg.exited.then(() => "exit");
        try {
            const winner = await Promise.race([exitP, timeoutP, abortP]);
            return {
                info: this.toInfo(bg),
                timedOut: winner === "timeout",
                aborted: winner === "abort",
            };
        }
        finally {
            if (timer)
                clearTimeout(timer);
            if (onAbort && signal)
                signal.removeEventListener("abort", onAbort);
        }
    }
    /** Remove exited processes from the map */
    prune(sessionId) {
        const map = this.processes.get(sessionId);
        if (!map)
            return;
        for (const [id, bg] of map) {
            if (bg.status === "exited")
                map.delete(id);
        }
        if (map.size === 0)
            this.processes.delete(sessionId);
    }
    toInfo(bg) {
        return {
            id: bg.id,
            name: bg.name,
            command: bg.command,
            pid: bg.pid,
            status: bg.status,
            exitCode: bg.exitCode,
            startTime: bg.startTime,
            endTime: bg.endTime,
        };
    }
}
//# sourceMappingURL=bg-process-manager.js.map