/**
 * Native per-monitor state-diff store.
 *
 * This is the one genuinely new primitive monitors add over routines: a
 * last-observed-*value* store. Routines persist per-*run* metadata but have no
 * last-seen value, so hand-built watchers (the RUSH-1107 SSL watcher) re-invented
 * state-diffing through a markdown memory file every time. This store kills that.
 *
 * Layout (sibling of the runs layout, atomic writes like writeRunMeta):
 *   ~/.agents/.history/monitors/<name>/state.json      # last-seen hash/value + fire bookkeeping
 *   ~/.agents/.history/monitors/<name>/fires/<id>/…    # fire history
 */
import * as fs from 'fs';
import * as path from 'path';
import { createHash } from 'crypto';
import { spawnSync } from 'child_process';
import { getMonitorsHistoryDir, ensureAgentsDir } from '../state.js';
import { safeJoin } from '../paths.js';
import { readRunMeta } from '../scheduling/routines.js';
const MAX_STORED_VALUE = 4096;
/** Per-monitor history root, with the (untrusted) name contained to one segment. */
export function getMonitorHistoryDir(name) {
    return safeJoin(getMonitorsHistoryDir(), name);
}
function getStatePath(name) {
    return path.join(getMonitorHistoryDir(name), 'state.json');
}
function getLivenessPath(name) {
    return path.join(getMonitorHistoryDir(name), 'liveness.json');
}
/** Directory holding a monitor's fire history. */
export function getMonitorFiresDir(name) {
    return path.join(getMonitorHistoryDir(name), 'fires');
}
/** Read a monitor's last-seen state, or null if it has never been observed. */
export function readState(name) {
    const statePath = getStatePath(name);
    if (!fs.existsSync(statePath))
        return null;
    try {
        return JSON.parse(fs.readFileSync(statePath, 'utf-8'));
    }
    catch {
        return null;
    }
}
/** Persist a monitor's state atomically (temp file + rename, like writeRunMeta). */
export function writeStateRaw(state) {
    ensureAgentsDir();
    const dir = getMonitorHistoryDir(state.monitorName);
    fs.mkdirSync(dir, { recursive: true });
    const statePath = path.join(dir, 'state.json');
    const tmp = `${statePath}.tmp-${process.pid}`;
    fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf-8');
    fs.renameSync(tmp, statePath);
}
/**
 * Record a new observation as the monitor's last-seen state, preserving fire
 * bookkeeping. Truncates the stored raw value so a firehose can't bloat disk.
 */
export function writeState(name, value, dedupeKey, extra = {}) {
    const prev = readState(name);
    const state = {
        monitorName: name,
        lastHash: hashSignature(value, dedupeKey),
        lastValue: value.length > MAX_STORED_VALUE ? value.slice(0, MAX_STORED_VALUE) : value,
        lastSeenAt: new Date().toISOString(),
        ...(prev?.lastFiredAt ? { lastFiredAt: prev.lastFiredAt } : {}),
        ...(prev?.fireTimes ? { fireTimes: prev.fireTimes } : {}),
        ...extra,
    };
    writeStateRaw(state);
    return state;
}
/**
 * The de-dupe signature for an observation. When `dedupeKey` is set, the
 * signature is the first regex match of dedupeKey against the observation (so
 * "the same event" is same matched token); otherwise it is the full observation.
 * An unmatched dedupeKey falls back to the full observation.
 */
export function dedupeSignature(observation, dedupeKey) {
    if (!dedupeKey)
        return observation;
    try {
        const m = new RegExp(dedupeKey).exec(observation);
        if (m)
            return m[1] ?? m[0];
    }
    catch {
        /* invalid regex — fall back to full observation */
    }
    return observation;
}
function hashSignature(observation, dedupeKey) {
    return createHash('sha256').update(dedupeSignature(observation, dedupeKey)).digest('hex');
}
/**
 * True when `observation`'s de-dupe signature differs from the monitor's
 * last-seen signature (or the monitor has never been observed). Pure read — the
 * caller persists the new value via writeState only on a real fire.
 */
export function hasChanged(name, observation, dedupeKey) {
    const prev = readState(name);
    if (!prev)
        return true;
    return prev.lastHash !== hashSignature(observation, dedupeKey);
}
/** Read a monitor's liveness heartbeat, or null if the engine has never polled it. */
export function readLiveness(name) {
    const livenessPath = getLivenessPath(name);
    if (!fs.existsSync(livenessPath))
        return null;
    try {
        return JSON.parse(fs.readFileSync(livenessPath, 'utf-8'));
    }
    catch {
        return null;
    }
}
/**
 * Record one poll attempt as the monitor's liveness heartbeat — the single call
 * the engine makes on every evaluation, regardless of fire/match. A successful
 * observation clears `lastError`/`consecutiveErrors` and any drought flag; a
 * failed one (source threw or produced nothing) records the error and increments
 * the consecutive-failure counter the drought escalation reads. Written
 * atomically (temp + rename) like writeStateRaw, and never touches state.json, so
 * change-detection is untouched.
 */
export function recordCheck(name, checkedAt, error) {
    const prev = readLiveness(name);
    const consecutiveErrors = error ? (prev?.consecutiveErrors ?? 0) + 1 : 0;
    const liveness = {
        monitorName: name,
        lastCheckedAt: checkedAt,
        checkCount: (prev?.checkCount ?? 0) + 1,
        consecutiveErrors,
        ...(error ? { lastError: error } : {}),
        // A drought flag only survives while the drought does — cleared on recovery.
        ...(error && prev?.droughtNotifiedAt ? { droughtNotifiedAt: prev.droughtNotifiedAt } : {}),
    };
    ensureAgentsDir();
    const dir = getMonitorHistoryDir(name);
    fs.mkdirSync(dir, { recursive: true });
    const livenessPath = path.join(dir, 'liveness.json');
    const tmp = `${livenessPath}.tmp-${process.pid}`;
    fs.writeFileSync(tmp, JSON.stringify(liveness, null, 2), 'utf-8');
    fs.renameSync(tmp, livenessPath);
    return liveness;
}
/** Stamp the drought-notified marker so the engine notifies at most once per drought. */
export function markDroughtNotified(name, at) {
    const prev = readLiveness(name);
    if (!prev)
        return;
    const livenessPath = getLivenessPath(name);
    const next = { ...prev, droughtNotifiedAt: at };
    const tmp = `${livenessPath}.tmp-${process.pid}`;
    fs.writeFileSync(tmp, JSON.stringify(next, null, 2), 'utf-8');
    fs.renameSync(tmp, livenessPath);
}
/**
 * Append a fire timestamp and return the pruned window (fires within `windowMs`).
 * The engine uses the returned length to decide whether the rate limit tripped.
 */
export function recordFireTime(name, now, windowMs) {
    const prev = readState(name);
    const times = [...(prev?.fireTimes ?? []), now].filter((t) => now - t <= windowMs);
    return times;
}
/** Write a fire record to fires/<id>/event.json and return the fire id. */
export function writeFireRecord(event, meta = {}) {
    ensureAgentsDir();
    const fireId = event.firedAt.replace(/[:.]/g, '-');
    const fireDir = safeJoin(getMonitorFiresDir(event.monitorName), fireId);
    fs.mkdirSync(fireDir, { recursive: true });
    fs.writeFileSync(path.join(fireDir, 'event.json'), JSON.stringify({ ...event, ...meta }, null, 2), 'utf-8');
    return fireId;
}
/** List a monitor's fire history, chronologically ascending. */
export function listFires(name) {
    const dir = getMonitorFiresDir(name);
    if (!fs.existsSync(dir))
        return [];
    const ids = fs
        .readdirSync(dir, { withFileTypes: true })
        .filter((e) => e.isDirectory())
        .map((e) => e.name)
        .sort();
    const fires = [];
    for (const id of ids) {
        const eventPath = path.join(dir, id, 'event.json');
        if (!fs.existsSync(eventPath))
            continue;
        try {
            fires.push(JSON.parse(fs.readFileSync(eventPath, 'utf-8')));
        }
        catch {
            /* skip corrupt record */
        }
    }
    return fires;
}
/** Cap a display-time postcondition so `monitors runs` cannot hang on a stuck command. */
const POSTCONDITION_TIMEOUT_MS = 15_000;
/**
 * Run a fire's postcondition command. Exit 0 means the intended effect happened;
 * anything else (nonzero, timeout, spawn error, empty command) is "no effect".
 * Real `/bin/sh -c` (or `cmd /c`) — the same seam command sources use.
 */
export function evaluatePostcondition(command) {
    const trimmed = command.trim();
    if (!trimmed)
        return { ok: false, error: 'postcondition not met: empty command' };
    const [bin, args] = process.platform === 'win32'
        ? ['cmd', ['/c', trimmed]]
        : ['/bin/sh', ['-c', trimmed]];
    const result = spawnSync(bin, args, {
        encoding: 'utf-8',
        timeout: POSTCONDITION_TIMEOUT_MS,
        maxBuffer: 8 * 1024 * 1024,
        env: { ...process.env, CLICOLOR: '0', NO_COLOR: '1', FORCE_COLOR: '0' },
    });
    if (result.status === 0)
        return { ok: true };
    if (result.error) {
        const err = result.error;
        if (err.code === 'ETIMEDOUT') {
            return { ok: false, error: `postcondition not met: timed out after ${POSTCONDITION_TIMEOUT_MS / 1000}s` };
        }
        return { ok: false, error: `postcondition not met: ${err.message}` };
    }
    const detail = (result.stderr || result.stdout || '').trim().replace(/\s+/g, ' ').slice(0, 200);
    const exit = result.status ?? 'unknown';
    return { ok: false, error: `postcondition not met${detail ? `: ${detail}` : ` (exit ${exit})`}` };
}
/** Persist the postcondition result onto the existing fire record. Frozen `ok` is left as the fire-time snapshot. */
function persistPostcondition(fire, result) {
    writeFireRecord(fire, {
        postconditionOk: result.ok,
        ...(result.error ? { postconditionError: result.error } : {}),
    });
}
/**
 * Reconcile a fire's frozen `ok` against its dispatched run's REAL, current
 * status — the render-time fix for RUSH-2690 — and, when the run has settled
 * `completed`, against a declared postcondition (PHNX-2842).
 *
 * `writeFireRecord` (this module) persists `ok` once, at fire time, from
 * `dispatchAction`'s synchronous return. For a `run`/`routine` action that
 * return is a snapshot: `executeJobDetached` writes `status: 'running'` before
 * spawning and hands that back immediately, so `dispatchAction`'s negative
 * check (`skipped`/`blocked`/`failed`) never sees the async outcome that lands
 * later via `settle()` — a run that goes on to fail, time out, or otherwise
 * never produce output still reads `ok: true` in `agents monitors runs`
 * forever, while `agents monitors logs` (which reads the run record fresh)
 * shows the real status. Re-reading the run here, at DISPLAY time, closes that
 * gap without touching the write path or the frozen historical record on disk.
 *
 * A fire with no `runId` (a `notify`/`webhook-out` action, or a `run`/`routine`
 * dispatch that never got a runId at all) has nothing to reconcile against —
 * its frozen `ok` is the only signal and is returned as-is.
 *
 * `completed` is not success by itself. An agent that exits 0 without doing
 * the job (merge-on-green that never merged) used to record `ok` because
 * `OK_RUN_STATUSES` treated `completed` as healthy. When the fire carries a
 * `postcondition` command, this function runs it once the run has settled and
 * returns `ok: false, effect: 'none'` when it fails — distinguishing "ran but
 * no effect" from a working fire. The result is persisted on the fire record
 * so later listings do not re-exec the command.
 */
export function resolveFireOutcome(jobName, fire) {
    if (!fire.runId)
        return { ok: fire.ok !== false };
    const run = readRunMeta(jobName, fire.runId);
    if (!run)
        return { ok: fire.ok !== false };
    if (run.status === 'running')
        return { ok: true, runStatus: run.status };
    if (run.status !== 'completed')
        return { ok: false, runStatus: run.status };
    if (!fire.postcondition)
        return { ok: true, runStatus: 'completed' };
    if (fire.postconditionOk === true) {
        return { ok: true, runStatus: 'completed', effect: 'met' };
    }
    if (fire.postconditionOk === false) {
        return {
            ok: false,
            runStatus: 'completed',
            effect: 'none',
            ...(fire.postconditionError ? { error: fire.postconditionError } : {}),
        };
    }
    const result = evaluatePostcondition(fire.postcondition);
    persistPostcondition(fire, result);
    fire.postconditionOk = result.ok;
    if (result.error)
        fire.postconditionError = result.error;
    return {
        ok: result.ok,
        runStatus: 'completed',
        effect: result.ok ? 'met' : 'none',
        ...(result.error ? { error: result.error } : {}),
    };
}
