/**
 * Overdue routine detection.
 *
 * When the daemon was not running (laptop off, reboot, daemon crash) at the
 * time a job was supposed to fire, the missed schedule is lost — croner only
 * schedules forward from "now." This module compares each enabled job's
 * most-recent expected fire time (from its cron expression) with the start
 * time of its most-recent recorded run; jobs whose latest run is older than
 * their most-recent expected fire are flagged as overdue.
 *
 * Surfaced two ways: a desktop notification on daemon startup, and a
 * `agents routines catchup` command that runs them on demand.
 */
import * as fs from 'fs';
import { Cron } from 'croner';
import { alignedSlotForFire, listJobs, getLatestRun, resolveJobFilePath, isPastEndAt, isOneShotRoutine, jobRunsOnThisDevice } from './scheduling/routines.js';
import { notifyDesktop } from './menubar/notify-desktop.js';
// Tolerance between "expected fire" and "recorded run start" — accounts for
// the small gap between the cron tick and when the runner writes meta.json.
const GRACE_MS = 60_000;
/** Compute the most recent fire of `pattern` at or before `now`. Delegates to
 *  {@link alignedSlotForFire} so overdue detection (`missedRunId`) and the live
 *  forward-timer dispatch (`slotRunId`) key on the SAME occurrence identity — a
 *  missed fire and its live twin for one UTC slot then collide by construction
 *  (SING-15). */
function previousExpectedFire(cron, now) {
    return alignedSlotForFire(cron, now);
}
/**
 * When a routine started existing, and therefore the earliest fire it can
 * sensibly be judged against.
 *
 * `createdAt` is stamped by `writeJob`. Routines written before that field
 * existed have none, so the file's own mtime stands in — it is the closest
 * honest answer available on disk, and it only ever moves the floor later,
 * never earlier, so it cannot manufacture a false "overdue".
 *
 * Returns null when neither is available, which leaves the routine unfloored
 * (previous behaviour) rather than silently skipping it.
 */
export function routineEffectiveStart(job, now = new Date()) {
    if (job.createdAt) {
        const stamped = new Date(job.createdAt);
        // Clamp a future stamp (clock skew, a hand-edited year) to now. Left
        // unclamped it sits after every possible expected fire, so the routine can
        // never be flagged overdue until wall-clock time catches up.
        if (!isNaN(stamped.getTime())) {
            return stamped.getTime() > now.getTime() ? now : stamped;
        }
    }
    const path = resolveJobFilePath(job.name);
    if (!path)
        return null;
    try {
        return new Date(fs.statSync(path).mtimeMs);
    }
    catch {
        return null;
    }
}
/** Return every enabled, recurring job whose most recent expected fire was
 *  missed. One-shot jobs are excluded — they fire at most once. */
export function detectOverdueJobs(now = new Date()) {
    const overdue = [];
    for (const job of listJobs()) {
        if (!job.enabled)
            continue;
        // One-shot: fires at most once, so a missed slot is not a backlog to replay.
        // Use the same predicate the scheduler does — the raw `runOnce` flag alone
        // missed a one-shot-LIKE schedule (a fixed minute/hour/day/month) that never
        // carried the flag.
        if (isOneShotRoutine(job))
            continue;
        // Past its configured end: catch-up must not resurrect a routine the author
        // already retired. The scheduler only auto-disables lazily, inside a live
        // cron tick, so a routine whose endAt elapsed while the daemon was down is
        // still enabled on disk when the catch-up pass runs.
        if (isPastEndAt(job, now))
            continue;
        // Trigger-only jobs (no cron schedule) never have an expected fire time.
        if (!job.schedule)
            continue;
        // A job pinned to another device is that device's to run, notify, and
        // catch up — flagging it here would make every machine in the fleet nag
        // (and `catchup` fire) for a job that must not run locally.
        if (!jobRunsOnThisDevice(job))
            continue;
        let expected = null;
        try {
            const cronOptions = { paused: true };
            if (job.timezone)
                cronOptions.timezone = job.timezone;
            const cron = new Cron(job.schedule, cronOptions);
            expected = previousExpectedFire(cron, now);
            cron.stop();
        }
        catch {
            // Invalid cron expression — skip rather than crash the daemon.
            continue;
        }
        if (!expected)
            continue;
        // A fire that predates the routine never could have happened, so it is not
        // a miss. Without this, any newly created routine on a daily/weekly cron is
        // instantly "overdue" for the previous occurrence — and with auto-catchup
        // that means `agents routines add` runs the routine once, immediately.
        const start = routineEffectiveStart(job, now);
        if (start && expected.getTime() < start.getTime())
            continue;
        const latest = getLatestRun(job.name);
        const lastRanAt = latest ? new Date(latest.startedAt) : null;
        const isOverdue = !lastRanAt || lastRanAt.getTime() < expected.getTime() - GRACE_MS;
        if (isOverdue) {
            overdue.push({ name: job.name, expectedAt: expected, lastRanAt });
        }
    }
    return overdue;
}
/**
 * Fire a branded desktop notification listing the overdue jobs. Routed through
 * the MenubarHelper companion (notify-desktop.ts) so it carries the agents-cli
 * mark; clicking opens the runs folder (~/.agents/.history/runs). Best-effort —
 * a missing notifier or absent display is swallowed and never crashes the daemon.
 */
export function notifyOverdue(jobs) {
    if (jobs.length === 0)
        return;
    const title = jobs.length === 1 ? 'Routine overdue' : `${jobs.length} routines overdue`;
    const subtitle = jobs.length === 1 ? jobs[0].name : undefined;
    const body = jobs.length === 1
        ? `Missed ${jobs[0].expectedAt.toLocaleString()}. Run: agents routines catchup`
        : `${jobs.map((j) => j.name).join(', ')} — agents routines catchup`;
    notifyDesktop({ title, subtitle, body, action: 'routines:list' });
}
