/**
 * Daemon lifecycle management — spawn, verify, stop, coordinate OwlCoda background daemons.
 * Extracted from cli-core.ts for modularity.
 */
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
import { getOwlcodaDir, getOwlcodaPidPath, getOwlcodaRuntimeMetaPath } from './paths.js';
import { VERSION } from './version.js';
import { resolveClientHost, fetchHealthz, healthzMatchesConfig, healthzMatchesRuntimeMeta, waitForVerifiedHealthz, waitForHealthzGone, } from './healthz-client.js';
import { isPortAvailable } from './port-utils.js';
import { getActiveLiveReplClientsForRuntime } from './repl-lease.js';
// ─── Directory management ───
export function ensureOwlcodaDir() {
    const owlcodaDir = getOwlcodaDir();
    if (!existsSync(owlcodaDir))
        mkdirSync(owlcodaDir, { recursive: true });
}
// ─── Runtime meta management ───
export function writeRuntimeMeta(meta) {
    ensureOwlcodaDir();
    writeFileSync(getOwlcodaRuntimeMetaPath(), JSON.stringify(meta, null, 2) + '\n', 'utf-8');
}
export function readRuntimeMeta() {
    const runtimeMetaPath = getOwlcodaRuntimeMetaPath();
    if (!existsSync(runtimeMetaPath))
        return null;
    try {
        return JSON.parse(readFileSync(runtimeMetaPath, 'utf-8'));
    }
    catch {
        return null;
    }
}
export function removeRuntimeMeta() {
    const runtimeMetaPath = getOwlcodaRuntimeMetaPath();
    try {
        if (existsSync(runtimeMetaPath))
            unlinkSync(runtimeMetaPath);
    }
    catch { /* ok */ }
}
// ─── PID management ───
export function isPidAlive(pid) {
    try {
        process.kill(pid, 0);
        return true;
    }
    catch {
        return false;
    }
}
export function readPid() {
    const pidFile = getOwlcodaPidPath();
    if (!existsSync(pidFile))
        return null;
    try {
        const raw = readFileSync(pidFile, 'utf-8').trim();
        const pid = parseInt(raw, 10);
        return isNaN(pid) ? null : pid;
    }
    catch {
        return null;
    }
}
export function writePid(pid) {
    ensureOwlcodaDir();
    writeFileSync(getOwlcodaPidPath(), String(pid), 'utf-8');
}
export function removePid() {
    const pidFile = getOwlcodaPidPath();
    try {
        if (existsSync(pidFile))
            unlinkSync(pidFile);
    }
    catch { /* ok */ }
}
// ─── Daemon identity verification ───
export function getMetaBaseUrl(meta) {
    return `http://${resolveClientHost(meta.host)}:${meta.port}`;
}
export async function verifyManagedDaemon(meta) {
    if (!isPidAlive(meta.pid))
        return null;
    const healthz = await fetchHealthz(getMetaBaseUrl(meta));
    if (!healthz)
        return null;
    if (!healthzMatchesRuntimeMeta(healthz, meta))
        return null;
    return healthz;
}
export function decideDaemonVersionPolicy(daemonVersion, activeClientCount, cliVersion = VERSION) {
    const normalizedDaemonVersion = daemonVersion?.trim();
    const safeActiveClientCount = Math.max(0, activeClientCount);
    if (!normalizedDaemonVersion || normalizedDaemonVersion === cliVersion) {
        return {
            action: 'reuse_current',
            stale: false,
            daemonVersion: normalizedDaemonVersion,
            cliVersion,
            activeClientCount: safeActiveClientCount,
        };
    }
    return {
        action: safeActiveClientCount > 0 ? 'reuse_active_stale' : 'restart_idle_stale',
        stale: true,
        daemonVersion: normalizedDaemonVersion,
        cliVersion,
        activeClientCount: safeActiveClientCount,
    };
}
// ─── Daemon lifecycle ───
export function safeSendSignal(pid, signal) {
    try {
        process.kill(pid, signal);
        return true;
    }
    catch (err) {
        // Return-false path used to be silent. If the signal couldn't be
        // delivered (permission denied / pid already reaped / wrong pid)
        // the caller loses track of an orphaned daemon that will silently
        // survive. Log to stderr so operators can see which process-lifecycle
        // expectation slipped. Stays return-false so callers can still branch
        // on "did it actually land" without change.
        const reason = err instanceof Error ? err.message : String(err);
        console.error(`[daemon] safeSendSignal: failed to send ${signal} to pid ${pid}: ${reason}`);
        return false;
    }
}
export async function stopAndWait(pid, baseUrl) {
    safeSendSignal(pid, 'SIGTERM');
    const gone = await waitForHealthzGone(baseUrl);
    if (!gone) {
        throw new Error(`Timed out waiting for OwlCoda daemon ${pid} to release ${baseUrl}`);
    }
    removePid();
    removeRuntimeMeta();
}
export function buildDaemonArgs(configPath, port, routerUrl) {
    const args = [process.argv[1], 'server'];
    if (configPath)
        args.push('--config', configPath);
    if (port !== undefined)
        args.push('--port', String(port));
    if (routerUrl)
        args.push('--router', routerUrl);
    return args;
}
export function spawnDaemon(config, configPath, port, routerUrl) {
    const childArgs = buildDaemonArgs(configPath, port, routerUrl);
    const runtimeToken = randomUUID();
    const fullArgs = [...process.execArgv, ...childArgs];
    const child = spawn(process.execPath, fullArgs, {
        argv0: process.argv0,
        detached: true,
        stdio: 'ignore',
        env: {
            ...process.env,
            OWLCODA_RUNTIME_TOKEN: runtimeToken,
        },
    });
    if (child.pid === undefined) {
        console.error('Failed to start background proxy');
        process.exit(1);
    }
    const exitPromise = new Promise((resolve) => {
        child.once('exit', (code, signal) => resolve({ code, signal }));
        child.once('error', () => resolve({ code: 1, signal: null }));
    });
    child.unref();
    const pid = child.pid;
    writePid(pid);
    writeRuntimeMeta({
        pid,
        runtimeToken,
        host: config.host,
        port: config.port,
        routerUrl: config.routerUrl,
        version: VERSION,
        startedAt: new Date().toISOString(),
    });
    return { pid, runtimeToken, exitPromise };
}
export function getBaseUrl(config) {
    return `http://${resolveClientHost(config.host)}:${config.port}`;
}
/**
 * Unified daemon coordination — used by both start and launch.
 * 1. Check existing daemon via PID + healthz identity
 * 2. If matching config + matching version → reuse
 * 3. If matching config but stale version → restart only when no live REPL clients are attached
 * 4. If config mismatch → safe stop + restart
 * 5. If nothing running → start fresh
 * 6. Wait for healthz ready before returning
 */
export async function ensureProxyRunning(config, configPath, port, routerUrl, options = {}) {
    const baseUrl = getBaseUrl(config);
    const log = (message) => {
        if (!options.quiet)
            console.error(message);
    };
    // 1. Check PID file + identity
    const existingPid = readPid();
    if (existingPid !== null) {
        const meta = readRuntimeMeta();
        if (!isPidAlive(existingPid)) {
            removePid();
            removeRuntimeMeta();
        }
        else if (meta && meta.pid === existingPid) {
            const healthz = await verifyManagedDaemon(meta);
            if (healthz && healthzMatchesConfig(healthz, config)) {
                const activeClientCount = getActiveLiveReplClientsForRuntime(meta).length;
                const versionDecision = decideDaemonVersionPolicy(healthz.version ?? meta.version, activeClientCount);
                if (versionDecision.action === 'restart_idle_stale') {
                    log(`Existing daemon (PID ${existingPid}) is v${versionDecision.daemonVersion}, ` +
                        `CLI is v${versionDecision.cliVersion}; no active live REPL clients — restarting`);
                    await stopAndWait(existingPid, getMetaBaseUrl(meta));
                }
                else if (versionDecision.action === 'reuse_active_stale') {
                    applyActiveStaleDaemonPolicy(existingPid, baseUrl, versionDecision);
                    return {
                        pid: existingPid,
                        reused: true,
                        staleDaemon: {
                            daemonVersion: versionDecision.daemonVersion,
                            cliVersion: versionDecision.cliVersion,
                            activeClientCount: versionDecision.activeClientCount,
                        },
                    };
                }
                else {
                    return { pid: existingPid, reused: true };
                }
            }
            else if (healthz) {
                log(`Existing daemon (PID ${existingPid}) config mismatch — restarting`);
                await stopAndWait(existingPid, getMetaBaseUrl(meta));
            }
            else {
                log(`PID ${existingPid} alive but runtime metadata no longer matches a live OwlCoda daemon — clearing stale state`);
                removePid();
                removeRuntimeMeta();
            }
        }
        else {
            log(`PID ${existingPid} alive but runtime metadata missing or mismatched — clearing stale state without signaling`);
            removePid();
            removeRuntimeMeta();
        }
    }
    // 2. Probe target address for orphan daemon
    const orphanHealthz = await fetchHealthz(baseUrl);
    if (orphanHealthz && healthzMatchesConfig(orphanHealthz, config, { requireConfigFingerprint: true })) {
        log(`Found matching OwlCoda proxy at ${baseUrl} (no PID file)`);
        // Synthesize runtime.json from the orphan's healthz so downstream
        // readRuntimeMeta() calls succeed. Without this heal step the CLI
        // immediately errored with "Failed to resolve live REPL lease
        // metadata" and exited — user couldn't resume a session whenever
        // the daemon was alive but its runtime.json had been removed (e.g.
        // after a stop + fresh owlcoda, or a disk-level cleanup). A daemon
        // with a null runtimeToken can't be a trusted lease target, so we
        // fall through to the restart path for that case.
        if (orphanHealthz.runtimeToken !== null) {
            const orphanMeta = {
                pid: orphanHealthz.pid,
                runtimeToken: orphanHealthz.runtimeToken,
                host: orphanHealthz.host,
                port: orphanHealthz.port,
                routerUrl: orphanHealthz.routerUrl,
                version: orphanHealthz.version,
                startedAt: new Date().toISOString(),
            };
            const activeClientCount = getActiveLiveReplClientsForRuntime(orphanMeta).length;
            const versionDecision = decideDaemonVersionPolicy(orphanHealthz.version, activeClientCount);
            if (versionDecision.action === 'restart_idle_stale') {
                log(`Orphan daemon (PID ${orphanHealthz.pid}) is v${versionDecision.daemonVersion}, ` +
                    `CLI is v${versionDecision.cliVersion}; no active live REPL clients — restarting`);
                safeSendSignal(orphanHealthz.pid, 'SIGTERM');
                await waitForHealthzGone(baseUrl, 1000);
            }
            else if (versionDecision.action === 'reuse_active_stale') {
                writeRuntimeMeta(orphanMeta);
                applyActiveStaleDaemonPolicy(orphanHealthz.pid, baseUrl, versionDecision);
                return {
                    pid: orphanHealthz.pid,
                    reused: true,
                    staleDaemon: {
                        daemonVersion: versionDecision.daemonVersion,
                        cliVersion: versionDecision.cliVersion,
                        activeClientCount: versionDecision.activeClientCount,
                    },
                };
            }
            else {
                writeRuntimeMeta({
                    pid: orphanHealthz.pid,
                    runtimeToken: orphanHealthz.runtimeToken,
                    host: orphanHealthz.host,
                    port: orphanHealthz.port,
                    routerUrl: orphanHealthz.routerUrl,
                    version: orphanHealthz.version,
                    startedAt: new Date().toISOString(),
                });
                return { pid: orphanHealthz.pid, reused: true };
            }
        }
        if (orphanHealthz.runtimeToken === null) {
            log('Orphan daemon has no runtimeToken — cannot claim lease, restarting.');
            safeSendSignal(orphanHealthz.pid, 'SIGTERM');
            await waitForHealthzGone(baseUrl, 1000);
        }
    }
    else if (orphanHealthz && healthzMatchesConfig(orphanHealthz, config)) {
        log(`Found OwlCoda proxy at ${baseUrl}, but its config fingerprint differs — restarting`);
        safeSendSignal(orphanHealthz.pid, 'SIGTERM');
        await waitForHealthzGone(baseUrl, 1000);
    }
    const targetPortAvailable = await isPortAvailable(config.port, resolveClientHost(config.host));
    if (!targetPortAvailable) {
        throw new Error(`Port ${config.port} is already in use by a non-OwlCoda process at ${baseUrl}`);
    }
    // 3. Start fresh
    const { pid, runtimeToken, exitPromise } = spawnDaemon(config, configPath, port, routerUrl);
    // 4. Wait for the exact daemon we spawned to become ready
    const readyState = await Promise.race([
        waitForVerifiedHealthz(baseUrl, healthz => ['ok', 'healthy', 'degraded', 'unhealthy'].includes(healthz.status) && healthz.pid === pid && healthz.runtimeToken === runtimeToken).then((healthz) => ({ kind: 'ready', healthz })),
        exitPromise.then((exit) => ({ kind: 'exit', exit })),
    ]);
    if (readyState.kind === 'ready' && readyState.healthz) {
        return { pid, reused: false };
    }
    if (readyState.kind === 'exit') {
        removePid();
        removeRuntimeMeta();
        throw new Error(`OwlCoda daemon exited before becoming ready at ${baseUrl} (${formatDaemonExit(readyState.exit.code, readyState.exit.signal)})`);
    }
    safeSendSignal(pid, 'SIGTERM');
    await waitForHealthzGone(baseUrl, 1000);
    removePid();
    removeRuntimeMeta();
    throw new Error(`OwlCoda daemon failed to become ready at ${baseUrl}`);
}
function formatActiveStaleDaemonWarning(pid, baseUrl, decision) {
    const noun = decision.activeClientCount === 1 ? 'client is' : 'clients are';
    return `⚠ OwlCoda daemon PID ${pid} on ${baseUrl} is v${decision.daemonVersion}, ` +
        `but this CLI is v${decision.cliVersion}. ${decision.activeClientCount} active live REPL ${noun} attached, ` +
        'so OwlCoda will not restart it automatically. Close those clients, or run `owlcoda stop --force && owlcoda` to align.';
}
/**
 * 2026-05-28: version-drift hard-block.
 *
 * Previously this was a warn-and-continue path: the CLI would emit the
 * stale-daemon warning and proceed to reuse the mismatched daemon. In
 * practice that produced subtle inconsistencies — the CLI runs the
 * latest agent / classifier / wrapper code, but HTTP error
 * classification (e.g. detailLooksLikeRateLimit from a66383e) lives in
 * the daemon process, so a stale daemon silently disabled retry logic
 * even though the CLI thought it was active. Dogfood across the
 * 0.14.36 → 0.14.39 release window made this drift visible only in
 * post-mortem audit-log inspection.
 *
 * New behaviour: refuse to proceed. The user must either close the
 * active REPL clients (daemon auto-restarts on next start) or
 * `owlcoda stop --force && owlcoda` to align. An escape hatch
 * `OWLCODA_ALLOW_VERSION_DRIFT=1` is provided for emergency cases
 * (e.g. interactive long-running session a user explicitly wants to
 * keep going on a frozen daemon).
 */
export function applyActiveStaleDaemonPolicy(pid, baseUrl, decision) {
    console.error(formatActiveStaleDaemonWarning(pid, baseUrl, decision));
    if (process.env.OWLCODA_ALLOW_VERSION_DRIFT === '1') {
        console.error('OWLCODA_ALLOW_VERSION_DRIFT=1 set — continuing with mismatched daemon ' +
            '(proceed at your own risk; HTTP error classification / model registry / ' +
            'shared state may behave per the older daemon version).');
        return;
    }
    console.error('Refusing to start with version-drifted daemon. Close active REPL clients ' +
        'or run `owlcoda stop --force && owlcoda` to align. ' +
        'Set OWLCODA_ALLOW_VERSION_DRIFT=1 to bypass this check.');
    process.exit(2);
}
function formatDaemonExit(code, signal) {
    if (signal)
        return `signal ${signal}`;
    if (code !== null)
        return `exit code ${code}`;
    return 'unknown exit';
}
//# sourceMappingURL=daemon.js.map