import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { processAlive } from "./git.js";
import { loadConfig, projectStateDir, registerProject, runsDir } from "./state.js";
import { DEFAULT_BOARD_PORT } from "./types.js";
import { ensureDir, readJson, writeJson } from "./util.js";
import { shortenHome } from "./util.js";
import { startBoardDaemon } from "./board/daemon.js";
import { RudderBus } from "./bus.js";
import { onRunTransition, scheduleTick } from "./scheduler.js";
import { renderLiveRudderMd } from "./surfaces.js";
const SCHEDULE_TICK_MS = 1000;
export function daemonPidPath(repoRoot) {
    return path.join(projectStateDir(repoRoot), "daemon.pid");
}
async function readPidFile(repoRoot) {
    const data = await readJson(daemonPidPath(repoRoot));
    if (data && typeof data.pid === "number" && typeof data.port === "number") {
        return data;
    }
    return null;
}
/**
 * Phase 2 lifecycle: a single-instance guard. If a live daemon already holds
 * the pid file, returns its port without starting a second server. Otherwise
 * starts the board daemon in-process and writes the pid file.
 */
export async function ensureBoardRunning(repoRoot, opts) {
    await registerProject(repoRoot).catch(() => undefined);
    const existing = await readPidFile(repoRoot);
    if (existing && existing.pid !== process.pid && processAlive(existing.pid)) {
        const url = `http://127.0.0.1:${existing.port}`;
        return { port: existing.port, url, started: false };
    }
    const config = await loadConfig();
    const port = opts?.port ?? config.board?.port ?? DEFAULT_BOARD_PORT;
    // One bus shared between the scheduler (producer) and the board SSE
    // broadcaster (consumer). The daemon is the single authoritative scheduler.
    const bus = new RudderBus();
    const handle = await startBoardDaemon({ port, repoRoot, open: opts?.open, bus });
    // Projector-only mode: when scheduler === false the board still serves
    // HTTP + SSE + fs.watch (so it reflects whatever is written to graph.json),
    // but the LAUNCHING scheduler tick is NOT started. The TUI starts the daemon
    // this way so the TUI remains the sole scheduler and there is no double-launch
    // (the TUI mirrors its plan into graph.json; the daemon only projects it). The
    // standalone `rudder board`/`serve` paths keep the full scheduler (default).
    const runScheduler = opts?.scheduler ?? true;
    const scheduler = runScheduler ? startScheduler(repoRoot, bus) : { stop: () => { } };
    await ensureDir(projectStateDir(repoRoot));
    await writeJson(daemonPidPath(repoRoot), {
        pid: process.pid,
        port: handle.port,
        startedAt: new Date().toISOString(),
    });
    const cleanup = async () => {
        scheduler.stop();
        await handle.close().catch(() => undefined);
        await fsp.unlink(daemonPidPath(repoRoot)).catch(() => undefined);
    };
    for (const signal of ["SIGINT", "SIGTERM"]) {
        process.once(signal, () => {
            void cleanup().finally(() => process.exit(signal === "SIGINT" ? 130 : 143));
        });
    }
    process.once("exit", () => {
        // Best-effort synchronous-ish unlink on normal exit.
        void fsp.unlink(daemonPidPath(repoRoot)).catch(() => undefined);
    });
    console.log(`rudder board listening on ${handle.url}`);
    console.log(`  repo: ${shortenHome(repoRoot)}`);
    return { port: handle.port, url: handle.url, started: true, handle };
}
/**
 * Run the dependency-aware scheduler inside the daemon. The daemon is the single
 * authoritative scheduler: a coarse 1s tick covers steady-state launches, and a
 * fs.watch on .rudder/runs edge-triggers onRunTransition the moment a run.json
 * changes (completion/failure/merge). Each run id is debounced so a burst of
 * writes coalesces into one transition. Returns a stop() to tear everything down.
 */
function startScheduler(repoRoot, bus) {
    let stopped = false;
    // Render the live RUDDER.md once on startup and tick immediately so a daemon
    // restart catches up missed transitions from disk.
    void renderLiveRudderMd(repoRoot).catch(() => undefined);
    void scheduleTick(repoRoot, bus).catch(() => undefined);
    const interval = setInterval(() => {
        if (stopped) {
            return;
        }
        void scheduleTick(repoRoot, bus).catch(() => undefined);
    }, SCHEDULE_TICK_MS);
    interval.unref?.();
    // Watch .rudder/runs for run.json changes; map the path back to a run id and
    // edge-trigger a transition. Per-run debounce so a write burst is one call.
    const runs = runsDir(repoRoot);
    try {
        fs.mkdirSync(runs, { recursive: true });
    }
    catch {
        // ignore
    }
    const runTimers = new Map();
    const trigger = (runId) => {
        const existing = runTimers.get(runId);
        if (existing) {
            clearTimeout(existing);
        }
        const timer = setTimeout(() => {
            runTimers.delete(runId);
            if (!stopped) {
                void onRunTransition(repoRoot, runId, bus).catch(() => undefined);
            }
        }, 100);
        timer.unref?.();
        runTimers.set(runId, timer);
    };
    let watcher;
    try {
        watcher = fs.watch(runs, { recursive: true }, (_event, filename) => {
            if (!filename) {
                return;
            }
            const normalized = String(filename).replace(/\\/g, "/");
            // Match runs/<id>/run.json or <id>/run.json.
            const match = normalized.match(/(?:^|\/)([^/]+)\/run\.json$/);
            if (match?.[1]) {
                trigger(match[1]);
            }
        });
        watcher.on("error", () => undefined);
    }
    catch {
        watcher = undefined;
    }
    return {
        stop: () => {
            stopped = true;
            clearInterval(interval);
            for (const [, timer] of runTimers) {
                clearTimeout(timer);
            }
            runTimers.clear();
            watcher?.close();
        },
    };
}
//# sourceMappingURL=daemon.js.map