import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from 'node:fs';
import { join } from 'node:path';
// Pidfile sits directly inside hippoRoot. `hippoRoot` is the `.hippo`
// directory itself (the same convention used by api.ts / store.ts /
// openHippoDb), so this resolves to `${hippoRoot}/server.pid`.
const PIDFILE = 'server.pid';
/**
 * How long detectServer waits for the `/health` liveness probe before
 * treating the pidfile as stale. Short by design: the probe only fires on
 * the rare path where a pidfile exists and its pid is live, and the target
 * is always loopback, so a healthy server answers well within this bound.
 */
const HEALTH_PROBE_TIMEOUT_MS = 300;
/**
 * Hard cap on the /health response body detectServer will buffer. A real
 * hippo /health payload is well under 1 KB; a larger body means the process
 * answering on the recorded port is not hippo, so the pidfile is stale.
 */
const HEALTH_BODY_MAX_BYTES = 64 * 1024;
/**
 * Loopback hosts the recorded pidfile url is allowed to point at. serve()
 * only binds these (it mirrors LOOPBACK_HOSTS in server.ts); a pidfile url
 * with any other host is malformed or forged and must not be probed.
 */
const PIDFILE_LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
/**
 * Read .hippo/server.pid and return the embedded ServerInfo if a live hippo
 * server is genuinely answering on the recorded url. Returns null on missing,
 * malformed, or stale pidfiles, and best-effort unlinks the file in the
 * stale/malformed cases.
 *
 * Liveness is proven in two steps. `process.kill(pid, 0)` rules out dead
 * pids. But a pid can be reused by an unrelated process, so a GET /health
 * then confirms the process that answers is *this* hippo server: its
 * `started_at` must equal the pidfile's. A mismatch, non-200, malformed
 * body, or timeout all mean the pidfile is stale.
 *
 * The /health probe runs only when a pidfile exists and the pid is live, so
 * the common no-server path stays a single fast file existence check.
 */
export async function detectServer(hippoRoot) {
    const path = join(hippoRoot, PIDFILE);
    if (!existsSync(path))
        return null;
    let info;
    try {
        info = JSON.parse(readFileSync(path, 'utf8'));
    }
    catch {
        try {
            unlinkSync(path);
        }
        catch { }
        return null;
    }
    // Probe the process. Sending signal 0 throws if the pid is dead or owned
    // by another user we cannot signal. Either way, treat as stale.
    // Node's process.kill(pid, 0) is implemented on Windows via OpenProcess +
    // GetExitCodeProcess, so this works cross-platform for the dead-pid case.
    try {
        process.kill(info.pid, 0);
    }
    catch {
        try {
            unlinkSync(path);
        }
        catch { }
        return null;
    }
    // The pid is live, but it may have been reused by an unrelated process, and
    // the recorded url is read from a file anyone could forge. serve() only ever
    // binds a loopback host, so a url that is not http on a loopback host and the
    // recorded port is malformed or forged. Reject it before probing: the probe,
    // and the routed request that may follow, can carry HIPPO_API_KEY.
    let probeUrl;
    try {
        probeUrl = new URL(info.url);
    }
    catch {
        try {
            unlinkSync(path);
        }
        catch { }
        return null;
    }
    if (probeUrl.protocol !== 'http:' ||
        !PIDFILE_LOOPBACK_HOSTS.has(probeUrl.hostname) ||
        probeUrl.port !== String(info.port)) {
        try {
            unlinkSync(path);
        }
        catch { }
        return null;
    }
    // Confirm the process answering on info.url is this hippo server by matching
    // the /health `started_at` against the pidfile. A connection refusal, a
    // non-200, or a malformed body unlink the pidfile as stale. A probe timeout
    // is deliberately left ambiguous: a live but momentarily-busy server (e.g.
    // blocked on a synchronous query) can miss the 300ms window, so a timeout
    // returns null WITHOUT unlinking. The pidfile survives for the next probe.
    try {
        const res = await fetch(`${info.url}/health`, {
            signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS),
        });
        if (!res.ok || !res.body) {
            try {
                unlinkSync(path);
            }
            catch { }
            return null;
        }
        // Read the body under a hard byte cap. The process answering on info.url
        // may not be hippo (pid reuse is the case this probe guards against), so
        // its response is untrusted: never hand an unbounded stream to a parser.
        const reader = res.body.getReader();
        const decoder = new TextDecoder();
        let raw = '';
        let received = 0;
        for (;;) {
            const { done, value } = await reader.read();
            if (done)
                break;
            received += value.byteLength;
            if (received > HEALTH_BODY_MAX_BYTES) {
                await reader.cancel();
                try {
                    unlinkSync(path);
                }
                catch { }
                return null;
            }
            raw += decoder.decode(value, { stream: true });
        }
        raw += decoder.decode();
        const body = JSON.parse(raw);
        if (body.started_at !== info.started_at) {
            try {
                unlinkSync(path);
            }
            catch { }
            return null;
        }
    }
    catch (err) {
        // A timeout is ambiguous (the server may be alive but busy), so keep the
        // pidfile. Any other failure (connection refused, malformed body) is
        // definitive: unlink it as stale.
        if (err?.name !== 'TimeoutError') {
            try {
                unlinkSync(path);
            }
            catch { }
        }
        return null;
    }
    return info;
}
/**
 * Atomically write the pidfile. Writes to a process-scoped temp file then
 * renames into place, which is atomic on POSIX and on NTFS via MoveFileEx.
 *
 * `startedAt` is supplied by the caller (`serve()`) rather than generated
 * here, so the pidfile and the server's GET /health response carry the same
 * timestamp — detectServer's liveness probe compares the two for equality.
 */
export function writePidfile(hippoRoot, opts) {
    const path = join(hippoRoot, PIDFILE);
    const tmp = `${path}.tmp.${process.pid}`;
    const info = {
        schema: 1,
        pid: process.pid,
        port: opts.port,
        url: opts.url,
        started_at: opts.startedAt,
    };
    writeFileSync(tmp, JSON.stringify(info));
    renameSync(tmp, path);
}
/**
 * Best-effort pidfile removal. Silent on ENOENT or any other error so the
 * caller can use this in shutdown paths without fear of throwing.
 *
 * Identity-blind: deletes whatever pidfile is on disk. A shutdown path that
 * must not clobber a newer server's pidfile should use removePidfileIfOwned.
 */
export function removePidfile(hippoRoot) {
    const path = join(hippoRoot, PIDFILE);
    try {
        unlinkSync(path);
    }
    catch { }
}
/**
 * Remove the pidfile only if it still describes the caller's own server.
 * Reads and parses the pidfile and unlinks it ONLY when both `pid` and
 * `started_at` match `owner`. A pidfile rewritten by a newer server is left
 * intact, so a shutting-down older server cannot orphan the newer one by
 * deleting its pidfile out from under it.
 *
 * Best-effort and never throws: a missing, unreadable, malformed, or
 * non-object pidfile (including a literal JSON `null`) is "not provably mine"
 * and left alone — detectServer unlinks a malformed pidfile on its next
 * probe, so it does not leak. Returns true iff a matching pidfile was removed.
 *
 * Residual TOCTOU: the pidfile could be rewritten between the read and the
 * unlink. The window is microseconds and the shape is the same read-check-
 * unlink detectServer already uses; this narrows the clobber from an
 * unbounded window (any time between writePidfile and stop) to a negligible
 * one.
 */
export function removePidfileIfOwned(hippoRoot, owner) {
    const path = join(hippoRoot, PIDFILE);
    try {
        const info = JSON.parse(readFileSync(path, 'utf8'));
        // A literal JSON `null` parses cleanly but throws on property access;
        // that lands in the catch below, which is the intended "not mine" path.
        if (info.pid !== owner.pid || info.started_at !== owner.startedAt) {
            return false; // a different server owns the pidfile now
        }
        unlinkSync(path);
        return true;
    }
    catch {
        // Missing, unreadable, malformed, or non-object pidfile, or an unlink
        // failure: none is a provable own-removal, so report not-removed.
        return false;
    }
}
//# sourceMappingURL=server-detect.js.map