/**
 * port-forward.ts — kubectl port-forward lifecycle manager for olam-cli.
 *
 * Phase 1b C1 of olam-host-suite-phase-1b-k3s-beta-flavour (plan
 * ~/.claude/plans/olam-host-suite-phase-1b-k3s-beta-flavour.md).
 *
 * Phase 2 Phase B B2 (olam-host-suite-phase-2-peripheral-services-on-k3s):
 *   Extended with registry-driven multi-peripheral support:
 *     spawnAllPeripheralPortForwards() — iterate PERIPHERALS; spawn each in parallel
 *     stopAllPeripheralPortForwards()  — SIGTERM each peripheral's port-forward
 *     getPeripheralLivenessSnapshot()  — TCP probe all 4 peripherals; return {name, alive}[]
 *   Each peripheral uses its own independent flock + PID file named by peripheral.name.
 *   host-cp port-forward retained as a named special-case; Phase 1b callers unchanged.
 *
 * Decisions consumed:
 *   D17 — port-forward spawn via flock advisory lock (race-safe)
 *   D23 — PID file at ~/.olam/state/port-forward.pid (mode 0600)
 *   D24 — probePortForwardLiveness uses TCP probe, NOT kill(pid, 0)
 *   D26 — per-peripheral PID/lock at ~/.olam/state/port-forward-<name>.{pid,lock}
 *
 * ## Flock strategy (why O_CREAT|O_EXCL, not POSIX flock(2))
 *
 * Node.js has no built-in `flock(2)` syscall binding. Instead we use
 * `fs.openSync(lockPath, 'wx')` which maps to `open(O_CREAT|O_EXCL|O_WRONLY)`.
 * On POSIX this is atomic: exactly ONE caller will succeed; concurrent callers
 * receive EEXIST. This is semantically equivalent to a non-blocking `flock`
 * for our scenario (guard the read-check-spawn sequence against concurrent
 * `olam upgrade` / `olam status` invocations). POSIX flock would additionally
 * survive fd inheritance across forks — we don't need that here because the
 * lock scope is only the spawn decision, not the port-forward subprocess lifetime.
 * Lock release: `unlinkSync(lockPath)` in a finally block.
 *
 * ## PID file lifecycle
 *   1. Acquire lock (O_CREAT|O_EXCL on lock file).
 *   2. Read PID file (if present) → TCP-probe liveness of existing forward.
 *   3. If live → release lock; return (nothing to do).
 *   4. Write PID file to a tmp path, set mode 0600, rename atomically.
 *   5. Spawn kubectl port-forward.
 *   6. Release lock.
 *   7. Port-forward runs detached (stderr suppressed); PID file removed on exit.
 *
 * ## TCP probe (D24)
 *
 * `probePortForwardLiveness` opens a TCP connection to 127.0.0.1:19000.
 * `kill(pid, 0)` would only check process existence — it cannot distinguish
 * "port-forward process alive but tunnel broken" from "tunnel working". TCP
 * is the ground truth.
 */
import { spawn } from 'node:child_process';
import * as fs from 'node:fs';
import * as net from 'node:net';
import * as path from 'node:path';
import * as os from 'node:os';
import { OLAM_STATE_DIR } from './config.js';
import { PERIPHERALS } from './peripheral-registry.js';
export const PORT_FORWARD_PORT = 19000;
export const PORT_FORWARD_LOCK_PATH = path.join(OLAM_STATE_DIR, 'port-forward.lock');
export const PORT_FORWARD_PID_PATH = path.join(OLAM_STATE_DIR, 'port-forward.pid');
/** TCP probe timeout (ms). Fast enough to feel snappy; generous enough for loopback. */
const TCP_PROBE_TIMEOUT_MS = 2_000;
/**
 * Probe liveness of the port-forward tunnel via a TCP connection attempt.
 *
 * Returns true only when a TCP connection to 127.0.0.1:PORT_FORWARD_PORT
 * succeeds. PID-alive check (kill(pid, 0)) is intentionally NOT used —
 * the process could be alive but the tunnel broken (per D24).
 *
 * Injectable: `deps.tcpProbeImpl` replaces the real TCP socket for tests.
 */
export async function probePortForwardLiveness(deps = {}) {
    const probe = deps.tcpProbeImpl ?? realTcpProbe;
    return probe('127.0.0.1', PORT_FORWARD_PORT, TCP_PROBE_TIMEOUT_MS);
}
function realTcpProbe(host, port, timeoutMs) {
    return new Promise((resolve) => {
        const socket = new net.Socket();
        let done = false;
        function finish(result) {
            if (done)
                return;
            done = true;
            socket.destroy();
            resolve(result);
        }
        const timer = setTimeout(() => finish(false), timeoutMs);
        socket.once('connect', () => { clearTimeout(timer); finish(true); });
        socket.once('error', () => { clearTimeout(timer); finish(false); });
        socket.once('timeout', () => { clearTimeout(timer); finish(false); });
        socket.connect(port, host);
    });
}
/**
 * Acquire the flock advisory lock (O_CREAT|O_EXCL). Returns a release function.
 * Throws with code EEXIST when the lock is already held by a concurrent caller.
 *
 * The lock file is only held for the duration of the read-check-spawn sequence;
 * it is not held for the port-forward subprocess lifetime.
 */
function acquireAdvisoryLock(lockPath) {
    // O_CREAT|O_EXCL|O_WRONLY — POSIX atomic create; throws EEXIST if already exists
    const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
    fs.closeSync(fd);
    return () => {
        try {
            fs.unlinkSync(lockPath);
        }
        catch { /* already removed; best-effort */ }
    };
}
function pidFilePath(deps) {
    const stateDir = deps.stateDir ?? OLAM_STATE_DIR;
    return path.join(stateDir, 'port-forward.pid');
}
function lockFilePath(deps) {
    const stateDir = deps.stateDir ?? OLAM_STATE_DIR;
    return path.join(stateDir, 'port-forward.lock');
}
/**
 * Ensure the state directory exists, then atomically write a PID file
 * to a tmp path and rename into place (mode 0600).
 */
function writePidFile(pidPath, pid) {
    const dir = path.dirname(pidPath);
    if (!fs.existsSync(dir)) {
        fs.mkdirSync(dir, { recursive: true });
    }
    const tmp = `${pidPath}.tmp.${process.pid}`;
    fs.writeFileSync(tmp, String(pid) + '\n', { encoding: 'utf8', mode: 0o600 });
    fs.renameSync(tmp, pidPath);
}
/**
 * Read the PID from the PID file. Returns null when the file is absent
 * or its content is not a valid positive integer.
 */
function readPidFile(pidPath) {
    if (!fs.existsSync(pidPath))
        return null;
    try {
        const raw = fs.readFileSync(pidPath, 'utf8').trim();
        const n = Number.parseInt(raw, 10);
        if (!Number.isInteger(n) || n <= 0)
            return null;
        return n;
    }
    catch {
        return null;
    }
}
/**
 * Spawn a `kubectl port-forward` subprocess, guarded by an advisory lock
 * so concurrent callers produce exactly ONE running port-forward process.
 *
 * Protocol:
 *   1. Acquire advisory lock (throws on concurrent access → returns 'lock-held').
 *   2. TCP-probe liveness of any existing port-forward.
 *   3. If live → release lock; return { spawned: false, reason: 'live' }.
 *   4. Spawn kubectl port-forward (detached, stdio ignored).
 *   5. Write PID file atomically (mode 0600).
 *   6. Release lock.
 *   7. Unref child so the CLI process can exit without waiting for it.
 *   8. Register 'exit' listener on child to remove the PID file on teardown.
 *
 * @param context - kubectl context name (--context flag)
 * @param namespace - target namespace
 * @param target - service or pod target (e.g. 'service/olam-host-cp')
 * @param localPort - local port to bind (default: PORT_FORWARD_PORT)
 * @param remotePort - remote port on the target (default: PORT_FORWARD_PORT)
 * @param deps - injectable dependencies for testing
 */
export async function spawnPortForward(context, namespace, target, localPort = PORT_FORWARD_PORT, remotePort = PORT_FORWARD_PORT, deps = {}) {
    const lockPath = lockFilePath(deps);
    const pidPath = pidFilePath(deps);
    const spawnImpl = deps.spawnImpl ?? spawn;
    // Ensure state dir exists before trying to create lock file
    const stateDir = deps.stateDir ?? OLAM_STATE_DIR;
    if (!fs.existsSync(stateDir)) {
        fs.mkdirSync(stateDir, { recursive: true });
    }
    let releaseLock = null;
    try {
        releaseLock = acquireAdvisoryLock(lockPath);
    }
    catch (err) {
        const code = err.code;
        if (code === 'EEXIST') {
            return { spawned: false, reason: 'lock-held' };
        }
        throw err;
    }
    try {
        // TCP-probe any existing port-forward before deciding to spawn.
        const live = await probePortForwardLiveness(deps);
        if (live) {
            return { spawned: false, reason: 'live' };
        }
        // Spawn kubectl port-forward detached so the CLI can exit independently.
        const child = spawnImpl('kubectl', [
            '--context', context,
            'port-forward',
            '-n', namespace,
            target,
            `${localPort}:${remotePort}`,
        ], {
            stdio: ['ignore', 'ignore', 'ignore'],
            detached: true,
        });
        if (typeof child.pid !== 'number' || child.pid <= 0) {
            return { spawned: false, reason: 'lock-held' }; // spawn failed; surface as non-fatal
        }
        // Atomic PID file write (tmp → rename, mode 0600).
        writePidFile(pidPath, child.pid);
        // Unref so the parent CLI process can exit without waiting for the tunnel.
        child.unref();
        // Best-effort PID file cleanup when the port-forward exits.
        child.once('exit', () => {
            try {
                fs.unlinkSync(pidPath);
            }
            catch { /* already gone */ }
        });
        return { spawned: true, pid: child.pid };
    }
    finally {
        releaseLock();
    }
}
/**
 * Kill a running port-forward identified by the PID file and remove the file.
 * No-op when no PID file exists. Returns true when a process was signalled.
 */
export function stopPortForward(deps = {}) {
    const pidPath = pidFilePath(deps);
    const pid = readPidFile(pidPath);
    if (pid === null)
        return false;
    try {
        process.kill(pid, 'SIGTERM');
    }
    catch {
        // ESRCH: already dead; fine.
    }
    try {
        fs.unlinkSync(pidPath);
    }
    catch { /* already gone */ }
    return true;
}
/**
 * Build the PID file path for a peripheral by name.
 * Path: <stateDir>/port-forward-<name>.pid
 */
function peripheralPidPath(name, stateDir) {
    return path.join(stateDir, `port-forward-${name}.pid`);
}
/**
 * Build the lock file path for a peripheral by name.
 * Path: <stateDir>/port-forward-<name>.lock
 * Each peripheral gets its own independent lock file (D26: locking auth-service
 * must NOT block mcp-auth-service).
 */
function peripheralLockPath(name, stateDir) {
    return path.join(stateDir, `port-forward-${name}.lock`);
}
/**
 * Spawn a kubectl port-forward for a single peripheral, guarded by an
 * independent advisory lock per peripheral. Mirrors the Phase 1b host-cp
 * pattern (D17/D23/D24) generalised to any Peripheral entry.
 *
 * Returns true when a new process was spawned; false when the tunnel was
 * already live or the lock was held by a concurrent caller.
 *
 * @internal — called by spawnAllPeripheralPortForwards; exported for test injection.
 */
export async function spawnPeripheralPortForward(peripheral, context, namespace, deps = {}) {
    const stateDir = deps.stateDir ?? OLAM_STATE_DIR;
    const spawnImpl = deps.spawnImpl ?? spawn;
    const tcpProbeImpl = deps.tcpProbeImpl ?? realTcpProbe;
    // Ensure state dir exists.
    if (!fs.existsSync(stateDir)) {
        fs.mkdirSync(stateDir, { recursive: true });
    }
    const lockPath = peripheralLockPath(peripheral.name, stateDir);
    const pidPath = peripheralPidPath(peripheral.name, stateDir);
    let releaseLock = null;
    try {
        releaseLock = acquireAdvisoryLock(lockPath);
    }
    catch (err) {
        const code = err.code;
        if (code === 'EEXIST') {
            return false; // lock held by concurrent caller
        }
        throw err;
    }
    try {
        // TCP-probe any existing port-forward before deciding to spawn (D24).
        const alive = await tcpProbeImpl('127.0.0.1', peripheral.port, TCP_PROBE_TIMEOUT_MS);
        if (alive) {
            return false; // tunnel already live
        }
        // Spawn kubectl port-forward detached (stdio ignored).
        const child = spawnImpl('kubectl', [
            '--context', context,
            'port-forward',
            '-n', namespace,
            `service/${peripheral.k8sResourceName}`,
            `${peripheral.port}:${peripheral.port}`,
        ], {
            stdio: ['ignore', 'ignore', 'ignore'],
            detached: true,
        });
        if (typeof child.pid !== 'number' || child.pid <= 0) {
            return false; // spawn failed; non-fatal
        }
        // Atomic PID file write (tmp → rename, mode 0600).
        writePidFile(pidPath, child.pid);
        // Unref so the parent CLI process can exit without waiting for the tunnel.
        child.unref();
        // Best-effort PID file cleanup when the port-forward exits.
        child.once('exit', () => {
            try {
                fs.unlinkSync(pidPath);
            }
            catch { /* already gone */ }
        });
        return true;
    }
    finally {
        releaseLock();
    }
}
/**
 * Spawn kubectl port-forward processes for all 4 peripherals in parallel.
 *
 * Each peripheral uses its own independent flock + PID file
 * (`~/.olam/state/port-forward-<name>.{lock,pid}`), so locking one
 * peripheral does NOT block another.
 *
 * @param context - kubectl context name (--context flag); defaults to 'olam'
 * @param namespace - target namespace; defaults to 'olam'
 * @param deps - injectable dependencies for testing
 */
export async function spawnAllPeripheralPortForwards(context = 'olam', namespace = 'olam', deps = {}) {
    await Promise.all(PERIPHERALS.map((p) => spawnPeripheralPortForward(p, context, namespace, deps)));
}
/**
 * Send SIGTERM to all running peripheral port-forward processes and
 * remove their PID and lock files.
 *
 * No-op for any peripheral whose PID file is absent (process already stopped).
 *
 * @param deps - injectable dependencies for testing (stateDir override)
 */
export function stopAllPeripheralPortForwards(deps = {}) {
    const stateDir = deps.stateDir ?? OLAM_STATE_DIR;
    for (const p of PERIPHERALS) {
        const pidPath = peripheralPidPath(p.name, stateDir);
        const lockPath = peripheralLockPath(p.name, stateDir);
        const pid = readPidFile(pidPath);
        if (pid !== null) {
            try {
                process.kill(pid, 'SIGTERM');
            }
            catch { /* ESRCH: already dead; fine */ }
        }
        try {
            fs.unlinkSync(pidPath);
        }
        catch { /* already gone */ }
        try {
            fs.unlinkSync(lockPath);
        }
        catch { /* already gone */ }
    }
}
/**
 * TCP-probe each peripheral and return a snapshot of their liveness.
 *
 * Uses the same realTcpProbe utility as probePortForwardLiveness (D24).
 * Probes run in parallel for speed.
 *
 * @param deps - injectable dependencies for testing
 */
export async function getPeripheralLivenessSnapshot(deps = {}) {
    const tcpProbeImpl = deps.tcpProbeImpl ?? realTcpProbe;
    return Promise.all(PERIPHERALS.map(async (p) => {
        const alive = await tcpProbeImpl('127.0.0.1', p.port, TCP_PROBE_TIMEOUT_MS);
        return { name: p.name, alive };
    }));
}
// Re-export so tests can import from this module without needing os/path.
export { os, path };
//# sourceMappingURL=port-forward.js.map