import { spawn } from "node:child_process";
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { agentLabel } from "./agent.js";
import { resolveClaudeCodeCli } from "../claude-code/claude-code-cli.js";
import { resolveCodexCli } from "../codex/codex-cli.js";
import { resolveHermesCli } from "../hermes/hermes-cli.js";
import { resolveOpenClawCli } from "../openclaw/openclaw-cli.js";
import { getAgentUpdateLogPath, getConnectorHome, resolveNpmSpawnCommand } from "../../support/operations.js";
import { resolvePiCli } from "../pi/pi-cli.js";
import { redactText } from "../../core/redaction.js";
const AGENT_INSTALL_PACKAGES = {
    codex: "@openai/codex",
    pi: "@earendil-works/pi-coding-agent",
    hermes: "hermes-agent",
    openclaw: "openclaw",
    "claude-code": "@anthropic-ai/claude-code",
};
export class AgentUpdateManager {
    options;
    jobs = new Map();
    home;
    manifestPath;
    aggregateLogPath;
    constructor(options = {}) {
        this.options = options;
        this.home = options.home ?? getConnectorHome();
        this.manifestPath = path.join(this.home, "updates", "jobs.json");
        this.aggregateLogPath = getAgentUpdateLogPath(this.home);
        this.loadPersistedJobs();
    }
    list() {
        return [...this.jobs.values()]
            .sort((left, right) => right.startedAt.localeCompare(left.startedAt))
            .map((job) => this.snapshot(job));
    }
    get(id) {
        const job = this.jobs.get(id);
        return job ? this.snapshot(job) : null;
    }
    readLog(id) {
        const job = this.requireJob(id);
        if (job.logDeletedAt) {
            return { job: this.snapshot(job), plain: `Update log was deleted at ${job.logDeletedAt}.` };
        }
        try {
            return { job: this.snapshot(job), plain: redactText(readFileSync(job.logPath, "utf8")) };
        }
        catch (error) {
            return {
                job: this.snapshot(job),
                plain: `Cannot read update log: ${error instanceof Error ? error.message : String(error)}`,
            };
        }
    }
    deleteLog(id) {
        const job = this.requireJob(id);
        if (job.status === "running") {
            throw new Error("Cannot delete the update log while the update job is still running.");
        }
        const snapshot = this.snapshot(job);
        rmSync(job.logPath, { force: true });
        this.jobs.delete(id);
        this.persistJobs();
        return snapshot;
    }
    start(agentId, context = {}, operation = "update") {
        const running = [...this.jobs.values()].find((job) => job.agentId === agentId && job.status === "running");
        if (running) {
            throw new Error(`${agentLabel(agentId)} update/install job is already running.`);
        }
        const plan = resolveAgentUpdatePlan(agentId, { ...context, env: context.env ?? this.options.env }, operation);
        const now = new Date().toISOString();
        const id = `${agentId.replace(/[^a-z0-9]/gi, "")}-${Date.now().toString(36)}`;
        const logPath = path.join(this.home, "updates", `${id}.log`);
        mkdirSync(path.dirname(logPath), { recursive: true });
        const job = {
            id,
            agentId,
            agentLabel: plan.agentLabel,
            operation,
            status: "running",
            method: plan.method,
            command: plan.command,
            args: plan.args,
            cwd: plan.cwd,
            summary: plan.summary,
            interactive: plan.interactive,
            canInput: true,
            needsInput: false,
            startedAt: now,
            updatedAt: now,
            logPath,
            ownerPid: process.pid,
            output: "",
            outputTail: "",
        };
        this.jobs.set(id, job);
        this.persistJobs();
        this.append(job, [
            `[${now}] Starting ${job.agentLabel} ${job.operation}`,
            `Method: ${job.method}`,
            `Command: ${[job.command, ...job.args].join(" ")}`,
            `Working directory: ${job.cwd}`,
            "",
        ].join("\n"));
        const useShell = process.platform === "win32";
        const child = spawn(useShell ? formatShellCommand(plan.command, plan.args) : plan.command, useShell ? [] : plan.args, {
            cwd: plan.cwd,
            env: { ...process.env, ...(this.options.env ?? {}), ...(context.env ?? {}) },
            shell: useShell,
            windowsHide: true,
            stdio: "pipe",
        });
        job.child = child;
        child.stdin.setDefaultEncoding("utf8");
        child.stdout.on("data", (chunk) => this.append(job, chunk.toString("utf8")));
        child.stderr.on("data", (chunk) => this.append(job, chunk.toString("utf8")));
        child.on("error", (error) => {
            this.finish(job, "failed", null, null, error.message);
        });
        child.on("close", (code, signal) => {
            if (job.status !== "running") {
                return;
            }
            this.finish(job, code === 0 ? "completed" : "failed", code, signal, code === 0 ? undefined : `${capitalize(job.operation)} exited with code ${code ?? "unknown"}`);
        });
        this.emit(job);
        return this.snapshot(job);
    }
    sendInput(id, input) {
        const job = this.requireJob(id);
        if (job.status !== "running" || !job.child?.stdin.writable) {
            throw new Error("Update job is not accepting input.");
        }
        const line = input.endsWith("\n") ? input : `${input}\n`;
        job.child.stdin.write(line);
        job.needsInput = false;
        this.append(job, `[${new Date().toISOString()}] Input sent from dashboard.\n`);
        return this.snapshot(job);
    }
    cancel(id) {
        const job = this.requireJob(id);
        if (job.status !== "running") {
            return this.snapshot(job);
        }
        job.child?.kill("SIGTERM");
        this.finish(job, "cancelled", null, "SIGTERM", "Cancelled from dashboard.");
        return this.snapshot(job);
    }
    cancelAll() {
        for (const job of this.jobs.values()) {
            if (job.status === "running") {
                job.child?.kill("SIGTERM");
            }
        }
    }
    requireJob(id) {
        const job = this.jobs.get(id);
        if (!job) {
            throw new Error(`Unknown update job: ${id}`);
        }
        return job;
    }
    append(job, text) {
        const redacted = redactText(text);
        job.output += redacted;
        if (job.output.length > 120_000) {
            job.output = job.output.slice(-120_000);
        }
        job.outputTail = job.output.slice(-16_000);
        job.updatedAt = new Date().toISOString();
        job.needsInput = job.status === "running" && looksLikePrompt(job.outputTail);
        writeFileSync(job.logPath, redacted, { flag: "a", encoding: "utf8" });
        this.appendAggregate(job, redacted);
        this.persistJobs();
        this.emit(job);
    }
    finish(job, status, code, signal, error) {
        job.status = status;
        job.canInput = false;
        job.needsInput = false;
        job.exitCode = code;
        job.signal = signal;
        job.error = error ? redactText(error) : undefined;
        job.finishedAt = new Date().toISOString();
        job.updatedAt = job.finishedAt;
        job.child = undefined;
        this.append(job, `\n[${job.finishedAt}] ${job.agentLabel} ${job.operation} ${status}${error ? `: ${job.error}` : ""}\n`);
    }
    emit(job) {
        this.options.onUpdate?.(this.snapshot(job));
    }
    snapshot(job) {
        const { child: _child, output: _output, ...snapshot } = job;
        return { ...snapshot };
    }
    loadPersistedJobs() {
        if (!existsSync(this.manifestPath)) {
            return;
        }
        try {
            const parsed = JSON.parse(readFileSync(this.manifestPath, "utf8"));
            let changed = false;
            for (const snapshot of parsed) {
                if (snapshot.logDeletedAt) {
                    changed = true;
                    continue;
                }
                const staleRunning = snapshot.status === "running" && !isProcessRunning(snapshot.ownerPid);
                if (staleRunning) {
                    changed = true;
                }
                const job = {
                    ...snapshot,
                    operation: snapshot.operation ?? "update",
                    status: staleRunning ? "failed" : snapshot.status,
                    canInput: false,
                    needsInput: false,
                    error: staleRunning
                        ? "Update process was still running when NordRelay restarted; inspect the agent update log before retrying."
                        : snapshot.error,
                    finishedAt: staleRunning ? new Date().toISOString() : snapshot.finishedAt,
                    updatedAt: staleRunning ? new Date().toISOString() : snapshot.updatedAt,
                    output: snapshot.outputTail ?? "",
                    outputTail: snapshot.outputTail ?? "",
                };
                this.jobs.set(job.id, job);
            }
            if (changed) {
                this.persistJobs();
            }
        }
        catch {
            this.jobs.clear();
        }
    }
    persistJobs() {
        mkdirSync(path.dirname(this.manifestPath), { recursive: true });
        const snapshots = this.list().slice(0, 100);
        writeFileSync(this.manifestPath, `${JSON.stringify(snapshots, null, 2)}\n`, "utf8");
    }
    appendAggregate(job, text) {
        const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
        if (lines.length === 0) {
            return;
        }
        mkdirSync(path.dirname(this.aggregateLogPath), { recursive: true });
        const now = new Date().toISOString();
        const prefix = `[${now}] INFO [${job.id}]`;
        appendFileSync(this.aggregateLogPath, `${lines.map((line) => `${prefix} ${line}`).join("\n")}\n`, "utf8");
    }
}
function isProcessRunning(pid) {
    if (!Number.isInteger(pid) || pid <= 0) {
        return false;
    }
    try {
        process.kill(pid, 0);
        return true;
    }
    catch {
        return false;
    }
}
export function resolveAgentUpdatePlan(agentId, context = {}, operation = "update") {
    if (operation === "install") {
        return resolveAgentInstallPlan(agentId, context);
    }
    const env = context.env ?? process.env;
    switch (agentId) {
        case "codex": {
            const cli = resolveCodexCli(env);
            if (!cli.path) {
                throw new Error("Codex CLI is not installed on PATH. Install or update it with npm i -g @openai/codex@latest.");
            }
            return plan(agentId, "codex update", cli.path, ["update"], "Runs the Codex CLI self-updater. If this install cannot self-update, use npm i -g @openai/codex@latest.", env);
        }
        case "pi": {
            const cli = resolvePiCli(env, context.piCliPath);
            if (!cli.path) {
                throw new Error("Pi CLI is not installed on PATH. Install or update it with npm install -g @earendil-works/pi-coding-agent@latest.");
            }
            return plan(agentId, "pi update pi", cli.path, ["update", "pi"], "Updates only the Pi coding agent, not installed Pi extensions.", env);
        }
        case "hermes": {
            const cli = resolveHermesCli(env, context.hermesCliPath);
            if (!cli.path) {
                throw new Error("Hermes CLI is not installed on PATH. Install Hermes with the official installer before updating.");
            }
            return plan(agentId, "hermes update --yes", cli.path, ["update", "--yes"], "Runs the Hermes git/dependency updater with confirmation prompts accepted where supported.", env);
        }
        case "openclaw": {
            const cli = resolveOpenClawCli(env, context.openClawCliPath);
            if (!cli.path) {
                throw new Error("OpenClaw CLI is not installed on PATH. Install OpenClaw before updating.");
            }
            return plan(agentId, "openclaw update --yes", cli.path, ["update", "--yes"], "Runs the OpenClaw updater, which detects npm/git installs and may restart the Gateway.", env);
        }
        case "claude-code": {
            const cli = resolveClaudeCodeCli(env, context.claudeCodeCliPath);
            if (!cli.path) {
                throw new Error("Claude Code host CLI is not installed on PATH. Bundled SDK updates arrive with NordRelay releases.");
            }
            return plan(agentId, "claude update", cli.path, ["update"], "Runs the Claude Code updater. Some package-manager installs may print a manual command instead.", env);
        }
    }
}
function resolveAgentInstallPlan(agentId, context = {}) {
    const env = context.env ?? process.env;
    const npm = resolveNpmSpawnCommand(env);
    if (!npm) {
        throw new Error(`Cannot install ${agentLabel(agentId)} because npm was not found on PATH.`);
    }
    const packageName = AGENT_INSTALL_PACKAGES[agentId];
    return {
        agentId,
        agentLabel: agentLabel(agentId),
        operation: "install",
        method: `npm install -g ${packageName}@latest`,
        command: npm.command,
        args: [...npm.argsPrefix, "install", "-g", `${packageName}@latest`],
        cwd: env.HOME || os.homedir(),
        summary: `Installs the latest ${agentLabel(agentId)} CLI with npm. Restart NordRelay after installation if the agent was previously unavailable.`,
        interactive: true,
    };
}
function plan(agentId, method, command, args, summary, env) {
    return {
        agentId,
        agentLabel: agentLabel(agentId),
        operation: "update",
        method,
        command,
        args,
        cwd: env.HOME || os.homedir(),
        summary,
        interactive: true,
    };
}
function looksLikePrompt(text) {
    const tail = text.split(/\r?\n/).slice(-4).join("\n");
    return /\b(y\/n|yes\/no|continue|proceed|confirm|password|passphrase|token|api key|enter|select)\b|[?>]\s*$/i.test(tail);
}
function formatShellCommand(command, args) {
    return [command, ...args].map(quoteWindowsCmdArg).join(" ");
}
function quoteWindowsCmdArg(value) {
    if (value.length === 0) {
        return "\"\"";
    }
    if (!/[\s"&|<>()^%]/.test(value)) {
        return value;
    }
    return `"${value
        .replace(/%/g, "%%")
        .replace(/(\\*)"/g, '$1$1\\"')
        .replace(/(\\+)$/g, "$1$1")}"`;
}
function capitalize(value) {
    return value.charAt(0).toUpperCase() + value.slice(1);
}
