import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdir } from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { RuntimeLifecycleError, createRuntimeLifecycleDiagnostic, diagnosticFromLifecycleError, } from "../runtimeLifecycleDiagnostics.js";
import { OpenCodeHttpClient } from "./OpenCodeHttpClient.js";
import { AsyncLock } from "../../utils/AsyncLock.js";
import { DEFAULT_OPENCODE_READINESS_TIMEOUT_MS, OPENCODE_SIGKILL_GRACE_MS, OPENCODE_SIGTERM_GRACE_MS, } from "../timing.js";
const NO_CREDENTIAL_SCOPE = "none";
const PATCHTRAIL_OWNED_PROVIDER_ENV_KEYS = new Set(["OPENAI_API_KEY"]);
const SAFE_INHERITED_ENV_KEYS = new Set([
    "PATH",
    "USER",
    "TMPDIR",
    "TMP",
    "TEMP",
    "SHELL",
    "LANG",
    "LC_ALL",
    "LC_CTYPE",
    "SSL_CERT_FILE",
    "NODE_EXTRA_CA_CERTS",
]);
const STATIC_RUNTIME_ENV_ALLOWLIST = new Set(["OPENCODE_SERVER_PASSWORD"]);
const DYNAMIC_RUNTIME_ENV_ALLOWLIST = PATCHTRAIL_OWNED_PROVIDER_ENV_KEYS;
export class OpenCodeProcessManager {
    #binaryManager;
    #host;
    #configuredPort;
    #readinessTimeoutMs;
    #extraEnv;
    #runtimeHomeDir;
    #child;
    #status;
    #stopping = false;
    #activeCredentialScopeKey;
    #redactDiagnostic = (value) => value;
    #operationLock = new AsyncLock();
    constructor(options) {
        this.#binaryManager = options.binaryManager;
        this.#host = options.host ?? "127.0.0.1";
        this.#configuredPort = options.port;
        this.#readinessTimeoutMs =
            options.readinessTimeoutMs ?? DEFAULT_OPENCODE_READINESS_TIMEOUT_MS;
        this.#extraEnv = options.extraEnv ?? {};
        this.#runtimeHomeDir =
            options.runtimeHomeDir ??
                path.join(os.tmpdir(), "patchtrail-agent-bridge", "opencode-home");
        this.#status = { state: "stopped", host: this.#host };
    }
    status() {
        return cloneProcessStatus(this.#status);
    }
    client() {
        if (this.#status.state !== "running" || !this.#status.port) {
            throw new Error("OpenCode HTTP client is unavailable until the process is running.");
        }
        const options = {
            baseUrl: `http://${formatUrlHost(this.#host)}:${this.#status.port}`,
        };
        const serverPassword = this.#extraEnv.OPENCODE_SERVER_PASSWORD;
        return new OpenCodeHttpClient(serverPassword ? { ...options, serverPassword } : options);
    }
    async start(options = {}) {
        return this.#operationLock.runExclusive(() => this.#startLocked(options));
    }
    async stop() {
        return this.#operationLock.runExclusive(() => this.#stopLocked());
    }
    async #startLocked(options) {
        const requestedCredentialScope = options.credentialScopeKey ?? NO_CREDENTIAL_SCOPE;
        if (!isLoopbackRuntimeHost(this.#host)) {
            const diagnostic = createRuntimeLifecycleDiagnostic({
                reasonCode: "non_loopback_host",
                phase: "configuration",
            });
            this.#status = {
                ...statusWithoutCredentialScope(this.#status),
                state: "failed",
                lastError: diagnostic.reason,
                stoppedAt: diagnostic.timestamp,
            };
            this.#child = undefined;
            this.#activeCredentialScopeKey = undefined;
            this.#stopping = false;
            throw new RuntimeLifecycleError(diagnostic.reasonCode, diagnostic.phase);
        }
        const liveChild = this.#child && isChildAlive(this.#child);
        if (this.#status.state === "running" ||
            this.#status.state === "starting" ||
            liveChild) {
            if (this.#activeCredentialScopeKey !== requestedCredentialScope) {
                throw new Error("OpenCode process is already running with a different credential scope. Stop the runtime before switching provider credentials.");
            }
            return this.status();
        }
        let binary;
        try {
            binary = await this.#binaryManager.resolveVerifiedBinary();
        }
        catch (error) {
            const diagnostic = diagnosticFromLifecycleError(error, {
                reasonCode: "verification_rejected",
            });
            this.#status = {
                ...statusWithoutCredentialScope(this.#status),
                state: "failed",
                lastError: diagnostic.reason,
                stoppedAt: diagnostic.timestamp,
            };
            this.#child = undefined;
            this.#activeCredentialScopeKey = undefined;
            this.#stopping = false;
            throw new RuntimeLifecycleError(diagnostic.reasonCode, diagnostic.phase, {
                cause: error,
            });
        }
        const port = this.#configuredPort ?? (await allocateFreePort(this.#host));
        this.#stopping = false;
        this.#status = {
            state: "starting",
            host: this.#host,
            port,
            binaryPath: binary.executablePath,
            opencodeVersion: binary.version,
            startedAt: new Date().toISOString(),
            ...(options.credentialScope
                ? { credentialScope: { ...options.credentialScope } }
                : {}),
        };
        await ensureRuntimeHomeDirs(this.#runtimeHomeDir);
        const runtimeHomeDir = runtimeHomeDirForScope(this.#runtimeHomeDir, requestedCredentialScope);
        await ensureRuntimeHomeDirs(runtimeHomeDir);
        const childEnv = buildChildEnvironment(this.#extraEnv, options.extraEnv, runtimeHomeDir);
        this.#redactDiagnostic = createDiagnosticRedactor(childEnv.sensitiveValues);
        this.#activeCredentialScopeKey = requestedCredentialScope;
        const child = spawn(binary.executablePath, ["serve", "--hostname", this.#host, "--port", String(port)], {
            env: childEnv.env,
            stdio: ["ignore", "pipe", "pipe"],
            detached: process.platform !== "win32",
        });
        this.#child = child;
        if (child.pid !== undefined) {
            this.#status.pid = child.pid;
        }
        child.stderr?.on("data", (chunk) => {
            if (this.#child !== child)
                return;
            this.#status.lastError = this.#redactDiagnostic(chunk.toString("utf8").trim().slice(-2_000));
        });
        child.once("error", (error) => {
            if (this.#child !== child)
                return;
            this.#status = {
                ...statusWithoutCredentialScope(this.#status),
                state: "failed",
                lastError: this.#redactDiagnostic(error.message),
                stoppedAt: new Date().toISOString(),
            };
            this.#child = undefined;
            this.#activeCredentialScopeKey = undefined;
            this.#stopping = false;
        });
        child.once("exit", (code, signal) => {
            if (this.#child !== child)
                return;
            const stoppedAt = new Date().toISOString();
            this.#child = undefined;
            if (this.#stopping) {
                this.#status = {
                    ...statusWithoutCredentialScope(this.#status),
                    state: "stopped",
                    stoppedAt,
                };
                this.#activeCredentialScopeKey = undefined;
                this.#stopping = false;
                return;
            }
            this.#activeCredentialScopeKey = undefined;
            this.#status = {
                ...statusWithoutCredentialScope(this.#status),
                state: "failed",
                stoppedAt,
                lastError: `OpenCode process exited unexpectedly with code=${code ?? "null"} signal=${signal ?? "null"}.`,
            };
        });
        try {
            await waitForHealth(this.#host, port, this.#readinessTimeoutMs, this.#extraEnv.OPENCODE_SERVER_PASSWORD);
            this.#status = { ...this.#status, state: "running" };
            return this.status();
        }
        catch (error) {
            await this.#stopLocked();
            const diagnostic = diagnosticFromLifecycleError(error, {
                reasonCode: "readiness_failed",
                phase: "readiness",
            });
            this.#status = {
                ...this.#status,
                state: "failed",
                lastError: diagnostic.reason,
                stoppedAt: diagnostic.timestamp,
            };
            if (!this.#child || !isChildAlive(this.#child)) {
                this.#activeCredentialScopeKey = undefined;
            }
            throw new RuntimeLifecycleError(diagnostic.reasonCode, diagnostic.phase, {
                cause: error,
            });
        }
    }
    async #stopLocked() {
        if (!this.#child || !isChildAlive(this.#child)) {
            this.#child = undefined;
            this.#stopping = false;
            this.#status = {
                ...statusWithoutCredentialScope(this.#status),
                state: "stopped",
                stoppedAt: new Date().toISOString(),
            };
            this.#activeCredentialScopeKey = undefined;
            return this.status();
        }
        this.#stopping = true;
        const child = this.#child;
        const exited = new Promise((resolve) => {
            child.once("exit", () => resolve());
        });
        sendSignalToProcessTree(child, "SIGTERM");
        await Promise.race([exited, delay(OPENCODE_SIGTERM_GRACE_MS)]);
        sendSignalToProcessTree(child, "SIGKILL");
        await Promise.race([exited, delay(OPENCODE_SIGKILL_GRACE_MS)]);
        if (isChildAlive(child)) {
            this.#status = {
                ...this.#status,
                state: "failed",
                stoppedAt: new Date().toISOString(),
                lastError: "OpenCode process did not exit after SIGTERM/SIGKILL.",
            };
            this.#stopping = false;
            return this.status();
        }
        this.#child = undefined;
        this.#stopping = false;
        this.#status = {
            ...statusWithoutCredentialScope(this.#status),
            state: "stopped",
            stoppedAt: new Date().toISOString(),
        };
        this.#activeCredentialScopeKey = undefined;
        return this.status();
    }
}
function cloneProcessStatus(status) {
    return {
        ...status,
        ...(status.credentialScope
            ? { credentialScope: { ...status.credentialScope } }
            : {}),
    };
}
function statusWithoutCredentialScope(status) {
    const { credentialScope: _credentialScope, ...rest } = status;
    return rest;
}
function isChildAlive(child) {
    return child.exitCode === null && child.signalCode === null;
}
async function waitForHealth(host, port, timeoutMs, serverPassword) {
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
        try {
            const remainingMs = Math.max(1, deadline - Date.now());
            const init = {
                signal: AbortSignal.timeout(Math.min(2_000, remainingMs)),
            };
            if (serverPassword) {
                init.headers = {
                    authorization: `Basic ${Buffer.from(`opencode:${serverPassword}`, "utf8").toString("base64")}`,
                };
            }
            const response = await fetch(`http://${formatUrlHost(host)}:${port}/global/health`, init);
            if (response.ok && (await isHealthyResponse(response)))
                return;
        }
        catch {
            // Keep probing until the bounded readiness deadline expires. Raw fetch
            // errors can include local networking details, so callers receive only
            // the whitelist lifecycle diagnostic below.
        }
        await delay(Math.min(50, Math.max(1, deadline - Date.now())));
    }
    throw new RuntimeLifecycleError("readiness_timeout", "readiness");
}
async function isHealthyResponse(response) {
    try {
        const payload = (await response.json());
        return (typeof payload === "object" &&
            payload !== null &&
            !Array.isArray(payload) &&
            payload.healthy === true);
    }
    catch {
        return false;
    }
}
function isLoopbackRuntimeHost(host) {
    return host === "127.0.0.1" || host === "::1" || host === "localhost";
}
function formatUrlHost(host) {
    return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
async function allocateFreePort(host) {
    return new Promise((resolve, reject) => {
        const server = net.createServer();
        server.once("error", reject);
        server.listen(0, host, () => {
            const address = server.address();
            if (typeof address !== "object" || address === null) {
                server.close(() => reject(new Error("Could not allocate TCP port.")));
                return;
            }
            const port = address.port;
            server.close((error) => {
                if (error)
                    reject(error);
                else
                    resolve(port);
            });
        });
    });
}
function buildChildEnvironment(staticExtraEnv, dynamicExtraEnv, runtimeHomeDir) {
    const env = {};
    const sensitiveValues = new Set();
    for (const key of SAFE_INHERITED_ENV_KEYS) {
        const value = process.env[key];
        if (value !== undefined)
            env[key] = value;
    }
    env.HOME = runtimeHomeDir;
    env.XDG_CONFIG_HOME = path.join(runtimeHomeDir, "config");
    env.XDG_DATA_HOME = path.join(runtimeHomeDir, "data");
    env.XDG_CACHE_HOME = path.join(runtimeHomeDir, "cache");
    if (process.platform === "win32") {
        env.USERPROFILE = runtimeHomeDir;
    }
    for (const [key, value] of Object.entries(staticExtraEnv)) {
        if (!STATIC_RUNTIME_ENV_ALLOWLIST.has(key))
            continue;
        env[key] = value;
        if (isSensitiveEnvKey(key))
            sensitiveValues.add(value);
    }
    for (const [key, value] of Object.entries(dynamicExtraEnv ?? {})) {
        if (!DYNAMIC_RUNTIME_ENV_ALLOWLIST.has(key))
            continue;
        env[key] = value;
        if (isSensitiveEnvKey(key))
            sensitiveValues.add(value);
    }
    return {
        env,
        sensitiveValues: [...sensitiveValues].filter((value) => value.length >= 8),
    };
}
function runtimeHomeDirForScope(baseDir, credentialScope) {
    const scopeHash = createHash("sha256")
        .update(credentialScope)
        .digest("hex")
        .slice(0, 24);
    return path.join(baseDir, `scope-${scopeHash}`);
}
function sendSignalToProcessTree(child, signal) {
    if (!child.pid) {
        child.kill(signal);
        return;
    }
    if (process.platform === "win32") {
        if (signal === "SIGKILL") {
            const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
                stdio: "ignore",
            });
            killer.once("error", () => undefined);
            return;
        }
        child.kill(signal);
        return;
    }
    try {
        process.kill(-child.pid, signal);
    }
    catch (error) {
        if (isNodeError(error) && error.code === "ESRCH")
            return;
        child.kill(signal);
    }
}
async function ensureRuntimeHomeDirs(runtimeHomeDir) {
    await Promise.all([
        mkdir(runtimeHomeDir, { recursive: true, mode: 0o700 }),
        mkdir(path.join(runtimeHomeDir, "config"), {
            recursive: true,
            mode: 0o700,
        }),
        mkdir(path.join(runtimeHomeDir, "data"), { recursive: true, mode: 0o700 }),
        mkdir(path.join(runtimeHomeDir, "cache"), { recursive: true, mode: 0o700 }),
    ]);
}
function isSensitiveEnvKey(key) {
    return /(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i.test(key);
}
function isNodeError(error) {
    return error instanceof Error && "code" in error;
}
function createDiagnosticRedactor(sensitiveValues) {
    const ordered = sensitiveValues
        .filter((value) => value.length > 0)
        .sort((left, right) => right.length - left.length);
    return (value) => {
        let redacted = value;
        for (const secret of ordered) {
            redacted = redacted.split(secret).join("[redacted]");
        }
        return redacted;
    };
}
function delay(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
