import { spawn } from "node:child_process";
import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { createConnection, createServer } from "node:net";
import { homedir } from "node:os";
import { join } from "node:path";
/**
 * The telemetry daemon: two long-lived processes — Jaeger (traces end-to-end)
 * and otelcol (logs-only pipeline → file sink). Supervised as a unit; stop
 * kills both, status reports both, restart respawns both.
 *
 * Lifecycle is managed via three files under `~/.indusk/` (or `$INDUSK_HOME/`
 * during tests):
 *
 *   telemetry.pid   — bare Jaeger PID (the "primary" process) for liveness
 *                     probing via `kill(pid, 0)`. otelcol PID also tracked,
 *                     but in the JSON meta file — one primary PID is the
 *                     admin-UI convention we inherit here.
 *   telemetry.json  — richer metadata: both PIDs, both ports, startedAt,
 *                     binary paths, platform
 *   telemetry.log   — interleaved stdout+stderr from both detached children
 *
 * Both processes survive terminal close via `detached: true` + `unref()`
 * + `stdio: ["ignore", logFd, logFd]`. The parent process is free to exit
 * immediately after `daemonStart` resolves — the PID/meta files are the
 * durable handoff to subsequent `telemetry status`/`telemetry stop` invocations.
 *
 * PID-reuse hardening: `verifyIdentity(pid, port)` composes `isAlive(pid)`
 * with `isPortListening(port)` to guard against reporting a recycled PID as
 * "running" (admin-UI Phase 7 lesson — applied here).
 */
function induskHome() {
    return process.env.INDUSK_HOME ?? join(homedir(), ".indusk");
}
function pidFilePath() {
    return join(induskHome(), "telemetry.pid");
}
function metaFilePath() {
    return join(induskHome(), "telemetry.json");
}
function logFilePath() {
    return join(induskHome(), "telemetry.log");
}
function ensureHome() {
    const h = induskHome();
    if (!existsSync(h))
        mkdirSync(h, { recursive: true });
}
// ---- platform + binary resolution ------------------------------------------
function currentPlatformTag() {
    const os = process.platform;
    const arch = process.arch === "x64" ? "x64" : process.arch;
    return `${os}-${arch}`;
}
/**
 * Resolve the path to a bundled binary via npm's optional-deps package
 * resolution. Throws a user-facing error if the platform package isn't
 * installed (unsupported platform, or `npm install` hasn't run).
 */
export function resolveBinary(name) {
    const require = createRequire(import.meta.url);
    const platformTag = currentPlatformTag();
    const pkgName = `@infinitedusky/telemetry-binaries-${platformTag}`;
    try {
        return require.resolve(`${pkgName}/bin/${name}`);
    }
    catch (err) {
        throw new Error(`Could not resolve ${name} binary for platform ${platformTag}. ` +
            `The platform package ${pkgName} is not installed — either your ` +
            `platform is unsupported (currently supported: darwin-arm64/x64, ` +
            `linux-arm64/x64) or the install didn't land the optional dep. ` +
            `Run \`npm install\` / \`pnpm install\` and try again. ` +
            `Underlying: ${err.message}`);
    }
}
// ---- port helpers ----------------------------------------------------------
export function isPortListening(port) {
    return new Promise((resolve) => {
        const socket = createConnection({ port, host: "127.0.0.1" });
        const done = (result) => {
            socket.destroy();
            resolve(result);
        };
        socket.once("connect", () => done(true));
        socket.once("error", () => done(false));
        socket.setTimeout(500, () => done(false));
    });
}
export async function findFreePort(requested) {
    if (requested === 0)
        return pickAnyFreePort();
    if (await isPortFree(requested))
        return requested;
    return pickAnyFreePort();
}
function pickAnyFreePort() {
    return new Promise((resolve, reject) => {
        const s = createServer();
        s.once("error", reject);
        s.listen(0, () => {
            const addr = s.address();
            if (typeof addr === "object" && addr !== null) {
                const port = addr.port;
                s.close(() => resolve(port));
            }
            else {
                reject(new Error("Could not determine free port"));
            }
        });
    });
}
function isPortFree(port) {
    return new Promise((resolve) => {
        const s = createServer();
        s.once("error", () => resolve(false));
        s.once("listening", () => s.close(() => resolve(true)));
        // Bind without host so we test the same wildcard scope Jaeger/otelcol use.
        // Binding to 127.0.0.1 falsely reports "free" when another process holds
        // the port via an IPv6 wildcard (`::`) bind without IPV6_V6ONLY — which
        // is the default for `net.createServer` and what Jaeger does internally.
        s.listen(port);
    });
}
// ---- identity + lifecycle --------------------------------------------------
function isAlive(pid) {
    try {
        process.kill(pid, 0);
        return true;
    }
    catch {
        return false;
    }
}
async function verifyIdentity(pid, port) {
    if (!isAlive(pid))
        return false;
    return isPortListening(port);
}
function cleanupFiles() {
    for (const p of [pidFilePath(), metaFilePath()]) {
        if (existsSync(p))
            rmSync(p, { force: true });
    }
}
function sleep(ms) {
    return new Promise((r) => setTimeout(r, ms));
}
async function waitForPortListening(port, timeoutMs) {
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
        if (await isPortListening(port))
            return true;
        await sleep(100);
    }
    return false;
}
// Poll until a port is rebind-able (or timeout). After `daemonStop()` the OS
// can hold the just-released socket in TIME_WAIT / cleanup for hundreds of ms
// to a couple of seconds; without this, `daemonRestart` would race and
// `findFreePort(4318)` would fall through to `pickAnyFreePort()` — drifting
// the OTLP port off the user's pinned value and breaking every downstream app
// pointing at the old port.
async function waitForPortFree(port, timeoutMs) {
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
        if (await isPortFree(port))
            return true;
        await sleep(100);
    }
    return false;
}
// ---- config file rendering -------------------------------------------------
/**
 * Render an inline Jaeger config with the auto-picked ports. We render a
 * fresh config per spawn rather than rely on the shipped jaeger-config.yaml
 * so ports can be substituted. Shape matches
 * `packages/telemetry-binaries-shared/jaeger-config.yaml` including the
 * self-metrics-off setting (`service.telemetry.metrics.level: none`).
 */
function renderJaegerConfig(ports) {
    return `service:
  extensions: [jaeger_storage, jaeger_query, jaeger_mcp, healthcheckv2]
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [jaeger_storage_exporter]
  telemetry:
    resource:
      service.name: jaeger
    metrics:
      level: none

extensions:
  healthcheckv2:
    use_v2: true
    http:
      endpoint: 0.0.0.0:${ports.health}
  jaeger_storage:
    backends:
      some_storage:
        memory:
          max_traces: 100000
  jaeger_query:
    storage:
      traces: some_storage
    http:
      endpoint: 0.0.0.0:${ports.ui}
    grpc:
      endpoint: 0.0.0.0:${ports.uiGrpc}
  jaeger_mcp:
    http:
      endpoint: 0.0.0.0:${ports.mcp}

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:${ports.otlpGrpc}
      http:
        endpoint: 0.0.0.0:${ports.otlpHttp}
        # CORS for browser-origin OTLP POSTs. Without this, every
        # /v1/traces request from a webapp fails preflight and no
        # browser-side spans land in Jaeger. Wildcard is safe here because
        # InDusk telemetry is a developer-machine local tool (Jaeger binds
        # to 0.0.0.0 already; the network boundary is the dev's loopback,
        # not the internet). Projects with stricter needs can override via
        # a future user-configurable hook.
        cors:
          allowed_origins:
            - "*"
          allowed_headers: ["*"]

processors:
  batch: {}

exporters:
  jaeger_storage_exporter:
    trace_storage: some_storage
`;
}
/**
 * Render an inline otelcol config (logs-only pipeline). Mirrors
 * `packages/telemetry-binaries-shared/collector-config.yaml` with the log
 * sink path templated.
 */
function renderCollectorConfig(ports, logsPath) {
    return `receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:${ports.otlpHttp}

processors:
  batch:
    send_batch_size: 512
    timeout: 2s
  memory_limiter:
    check_interval: 1s
    limit_mib: 256
    spike_limit_mib: 64

exporters:
  file/logs:
    path: ${logsPath}
    rotation:
      max_megabytes: 10
      max_backups: 5
    format: json

extensions:
  health_check:
    endpoint: 0.0.0.0:${ports.health}

service:
  extensions: [health_check]
  pipelines:
    logs:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [file/logs]
  telemetry:
    metrics:
      level: none
    logs:
      level: warn
`;
}
// ---- daemonStart / daemonStop / daemonStatus / daemonRestart ---------------
export async function daemonStart(opts = {}) {
    ensureHome();
    // Fail fast if already running (up-stream uiStart-style guard)
    const existing = await daemonStatus();
    if (existing.running) {
        throw new Error(`Telemetry daemon is already running (Jaeger PID ${existing.jaegerPid}, otelcol PID ${existing.otelcolPid}, OTLP :${existing.otlpPort}, UI :${existing.uiPort}). Run 'indusk telemetry stop' first.`);
    }
    const jaegerBin = resolveBinary("jaeger");
    const otelcolBin = resolveBinary("otelcol");
    // Pick 8 ports simultaneously to avoid sequential-pickFreePort collision
    // (see telemetry-ui-reachable.test.ts lesson from Phase 2).
    const [otlpHttp, otlpGrpc, ui, uiGrpc, jaegerHealth, logsOtlp, otelcolHealth, mcp] = await Promise.all([
        findFreePort(opts.otlpPort ?? 4318),
        findFreePort(0),
        findFreePort(opts.uiPort ?? 16686),
        findFreePort(0),
        findFreePort(0),
        findFreePort(0),
        findFreePort(0),
        findFreePort(0),
    ]);
    // Write configs to INDUSK_HOME/telemetry-{jaeger,collector}.yaml so they
    // survive parent exit and are inspectable post-mortem.
    const jaegerConfig = join(induskHome(), "telemetry-jaeger.yaml");
    const collectorConfig = join(induskHome(), "telemetry-collector.yaml");
    const logsPath = join(induskHome(), "telemetry", "logs.jsonl");
    mkdirSync(join(induskHome(), "telemetry"), { recursive: true });
    writeFileSync(jaegerConfig, renderJaegerConfig({
        otlpHttp,
        otlpGrpc,
        ui,
        uiGrpc,
        health: jaegerHealth,
        mcp,
    }));
    writeFileSync(collectorConfig, renderCollectorConfig({ otlpHttp: logsOtlp, health: otelcolHealth }, logsPath));
    const logFd = openSync(logFilePath(), "a");
    // Spawn Jaeger first
    const jaegerChild = spawn(jaegerBin, [`--config=file:${jaegerConfig}`], {
        detached: true,
        stdio: ["ignore", logFd, logFd],
    });
    jaegerChild.unref();
    if (typeof jaegerChild.pid !== "number") {
        throw new Error("Jaeger spawn did not produce a PID");
    }
    // Wait for Jaeger health port so otelcol has a live upstream when it starts
    const jaegerReady = await waitForPortListening(jaegerHealth, 15_000);
    if (!jaegerReady) {
        try {
            process.kill(jaegerChild.pid, "SIGTERM");
        }
        catch {
            // already dead
        }
        throw new Error(`Jaeger did not become ready on health port :${jaegerHealth} within 15s. See ${logFilePath()} for details.`);
    }
    // Spawn otelcol (logs-only pipeline)
    const otelcolChild = spawn(otelcolBin, [`--config=${collectorConfig}`], {
        detached: true,
        stdio: ["ignore", logFd, logFd],
    });
    otelcolChild.unref();
    if (typeof otelcolChild.pid !== "number") {
        try {
            process.kill(jaegerChild.pid, "SIGTERM");
        }
        catch {
            // already dead
        }
        throw new Error("otelcol spawn did not produce a PID");
    }
    const otelcolReady = await waitForPortListening(otelcolHealth, 15_000);
    if (!otelcolReady) {
        try {
            process.kill(otelcolChild.pid, "SIGTERM");
            process.kill(jaegerChild.pid, "SIGTERM");
        }
        catch {
            // best-effort
        }
        throw new Error(`otelcol did not become ready on health port :${otelcolHealth} within 15s. See ${logFilePath()} for details.`);
    }
    const meta = {
        jaegerPid: jaegerChild.pid,
        otelcolPid: otelcolChild.pid,
        otlpPort: otlpHttp,
        uiPort: ui,
        mcpPort: mcp,
        jaegerHealthPort: jaegerHealth,
        otelcolHealthPort: otelcolHealth,
        logsOtlpPort: logsOtlp,
        startedAt: new Date().toISOString(),
        jaegerBinary: jaegerBin,
        otelcolBinary: otelcolBin,
        platform: currentPlatformTag(),
        logsPath,
    };
    writeFileSync(pidFilePath(), String(meta.jaegerPid));
    writeFileSync(metaFilePath(), JSON.stringify(meta, null, 2));
    return meta;
}
export async function daemonStatus() {
    const pidFile = pidFilePath();
    const metaFile = metaFilePath();
    if (!existsSync(pidFile) || !existsSync(metaFile))
        return { running: false };
    let meta;
    try {
        meta = JSON.parse(readFileSync(metaFile, "utf-8"));
    }
    catch {
        return { running: false };
    }
    // Identity gate: BOTH processes alive AND both listening on their ports
    const jaegerOk = await verifyIdentity(meta.jaegerPid, meta.uiPort);
    const otelcolOk = await verifyIdentity(meta.otelcolPid, meta.otelcolHealthPort);
    if (!jaegerOk || !otelcolOk) {
        cleanupFiles();
        return { running: false };
    }
    return {
        running: true,
        jaegerPid: meta.jaegerPid,
        otelcolPid: meta.otelcolPid,
        otlpPort: meta.otlpPort,
        uiPort: meta.uiPort,
        mcpPort: meta.mcpPort,
        startedAt: meta.startedAt,
    };
}
export async function daemonStop() {
    const metaFile = metaFilePath();
    if (!existsSync(metaFile))
        return { stopped: false };
    let meta;
    try {
        meta = JSON.parse(readFileSync(metaFile, "utf-8"));
    }
    catch {
        cleanupFiles();
        return { stopped: false };
    }
    // Identity gate for stop: if the recorded PIDs aren't ours anymore
    // (PID-reuse), clean up files but don't SIGTERM a stranger.
    const jaegerOk = await verifyIdentity(meta.jaegerPid, meta.uiPort);
    const otelcolOk = await verifyIdentity(meta.otelcolPid, meta.otelcolHealthPort);
    if (!jaegerOk && !otelcolOk) {
        cleanupFiles();
        return {
            stopped: true,
            signaledJaegerPid: meta.jaegerPid,
            signaledOtelcolPid: meta.otelcolPid,
        };
    }
    // Signal both; poll for exit up to 3s; SIGKILL fallback
    const targets = [];
    if (jaegerOk)
        targets.push(meta.jaegerPid);
    if (otelcolOk)
        targets.push(meta.otelcolPid);
    for (const pid of targets) {
        try {
            process.kill(pid, "SIGTERM");
        }
        catch {
            // already gone
        }
    }
    for (let i = 0; i < 30; i++) {
        await sleep(100);
        if (targets.every((pid) => !isAlive(pid))) {
            cleanupFiles();
            return {
                stopped: true,
                signaledJaegerPid: meta.jaegerPid,
                signaledOtelcolPid: meta.otelcolPid,
            };
        }
    }
    let usedSigkill = false;
    for (const pid of targets) {
        if (isAlive(pid)) {
            try {
                process.kill(pid, "SIGKILL");
                usedSigkill = true;
            }
            catch {
                // best-effort
            }
        }
    }
    cleanupFiles();
    return {
        stopped: true,
        signaledJaegerPid: meta.jaegerPid,
        signaledOtelcolPid: meta.otelcolPid,
        usedSigkill,
    };
}
export async function daemonRestart(opts = {}) {
    // Restart means "same daemon, fresh processes" — inherit the previously
    // bound ports before stopping so the new spawn rebinds the same addresses
    // (and doesn't fall back to default 4318/16686, which collide with the
    // machine-global daemon when the test/dev daemon was started with port 0).
    // Caller-supplied opts win when explicitly set.
    let inheritedOpts = opts;
    const metaFile = metaFilePath();
    if (existsSync(metaFile)) {
        try {
            const prev = JSON.parse(readFileSync(metaFile, "utf-8"));
            inheritedOpts = {
                otlpPort: opts.otlpPort ?? prev.otlpPort,
                uiPort: opts.uiPort ?? prev.uiPort,
            };
        }
        catch {
            // Malformed meta — fall through to caller opts (or defaults).
        }
    }
    await daemonStop();
    // Poll the inherited ports until the OS has actually released them. A fixed
    // sleep (used to be 200 ms) is unreliable: on machines where TIME_WAIT
    // outlives the sleep, `daemonStart` calls `findFreePort(inheritedOpts.otlpPort)`,
    // sees the port still occupied, and falls through to `pickAnyFreePort()` —
    // drifting the OTLP port off the user's pinned value. 5 s ceiling is well
    // above observed worst-case release time on macOS/Linux loopback.
    const portsToWait = [inheritedOpts.otlpPort, inheritedOpts.uiPort].filter((p) => typeof p === "number" && p > 0);
    await Promise.all(portsToWait.map((p) => waitForPortFree(p, 5_000)));
    return daemonStart(inheritedOpts);
}
