/**
 * cutoverReadiness.ts — the cutover-READINESS checker (coordination-mandate spec §7
 * G2.4, scoped by decision 1A): everything UP TO the door, never the door.
 *
 * The Phase-4 cutover flip is Justin's one manual click — NOT an autonomously
 * fireable action (decision 1A/3B). What CAN and SHOULD be autonomous is knowing,
 * from REAL durable state, whether the flip is safe: this module composes the two
 * objective conditions the spec names —
 *
 *   - `integrity-gate-pass`     ← the persisted Phase-4 import IntegrityReport
 *                                 (`runIntegrityGate(...).passed`, written server-side
 *                                 by the import tooling — never asserted by an agent)
 *   - `parity-zero-divergence`  ← `DurableParityMonitor.gate(now).cleared` over the
 *                                 durable zero-divergence window, PLUS a readiness-layer
 *                                 freshness bound (a streak that stopped being fed is
 *                                 visible staleness, not silent readiness)
 *
 * — and exposes them as one read-only status. The T7 discipline (conditions resolve
 * from real state, never an agent's assertion) is structural here: the ONLY write
 * paths are (a) `runParityPass()`, which the agent may TRIGGER but whose result is
 * computed server-side from a live fetch+compare, and (b) `recordIntegrityReport()`,
 * called by the server-side import tooling. There is no "set the condition" input.
 */
import fs from 'node:fs';
import path from 'node:path';
const DEFAULT_MAX_PASS_STALENESS_MS = 6 * 60 * 60 * 1000;
/** 12m — above HttpParitySource's 600s total budget + a working pass's compare,
 *  so the lock-boundary max-hold is a true backstop, not a false-abort risk. */
const DEFAULT_MAX_LIVE_FETCH_MS = 12 * 60 * 1000;
export class CutoverReadiness {
    d;
    maxStaleMs;
    maxLiveFetchMs;
    /**
     * Single-flight guard over the LIVE SOURCE FETCH — shared by the parity pass
     * and the import dry-run because both page the full live source. Without it,
     * repeated triggers pile CONCURRENT full fetches onto the source: the route's
     * response window can elapse while the handler keeps running (the always-logged
     * outcome contract), so a caller that retries on a 408 spawns a new fetch next
     * to the live one (observed live 2026-06-05 12:16Z — four concurrent passes
     * against an already-degraded Portal). One live fetch at a time; a concurrent
     * trigger is refused immediately (409 at the route) and records nothing.
     */
    liveFetchInFlight = null;
    constructor(deps) {
        if (deps.importDryRunReportPath && path.resolve(deps.importDryRunReportPath) === path.resolve(deps.integrityReportPath)) {
            // Structural readiness-honesty guard: a rehearsal report aimed at the
            // canonical integrity path WOULD green `ready` — refuse the wiring outright.
            throw new Error('importDryRunReportPath must differ from integrityReportPath — a dry-run must never green the integrity gate');
        }
        this.d = deps;
        this.maxStaleMs = deps.maxPassStalenessMs ?? DEFAULT_MAX_PASS_STALENESS_MS;
        this.maxLiveFetchMs = deps.maxLiveFetchMs ?? DEFAULT_MAX_LIVE_FETCH_MS;
    }
    nowMs() {
        return this.d.now ? this.d.now() : Date.now();
    }
    /**
     * Race an injected live-fetch promise against a wall-clock max-hold so the
     * single-flight lock can NEVER be held indefinitely by a fetch that fails to
     * settle (#948). On timeout this rejects (the caller's `finally` then releases
     * the lock and the next sweep retries); the orphaned work is abandoned — a
     * re-attempt is cheap and idempotent, an unreleasable lock is not. Uses real
     * wall-clock time (NOT `nowMs()`), since the bug is a promise that never
     * settles in real time regardless of an injected clock. */
    async withMaxHold(work, op) {
        let timer;
        const guard = new Promise((_resolve, reject) => {
            timer = setTimeout(() => {
                reject(new Error(`live ${op} fetch exceeded the max-hold budget (${this.maxLiveFetchMs}ms) — releasing the single-flight lock so a later sweep can retry; the underlying fetch did not settle within budget (see #948)`));
            }, this.maxLiveFetchMs);
            if (typeof timer.unref === 'function')
                timer.unref();
        });
        try {
            return await Promise.race([work, guard]);
        }
        finally {
            if (timer)
                clearTimeout(timer);
        }
    }
    /** Persist an import IntegrityReport (server-side import tooling only). */
    recordIntegrityReport(report, generatedAt) {
        const envelope = {
            generatedAt: generatedAt ?? new Date(this.nowMs()).toISOString(),
            report,
        };
        fs.mkdirSync(path.dirname(this.d.integrityReportPath), { recursive: true });
        fs.writeFileSync(this.d.integrityReportPath, JSON.stringify(envelope, null, 2));
    }
    integrityStatus() {
        try {
            const raw = JSON.parse(fs.readFileSync(this.d.integrityReportPath, 'utf8'));
            const r = raw?.report;
            if (!r || typeof r.passed !== 'boolean') {
                return { ran: false, passed: false, generatedAt: null, summary: null };
            }
            return {
                ran: true,
                passed: r.passed,
                generatedAt: typeof raw.generatedAt === 'string' ? raw.generatedAt : null,
                summary: {
                    fingerprintCollisions: r.fingerprintCollisions?.length ?? 0,
                    schemaDivergences: r.schemaDivergences?.length ?? 0,
                    checksumMismatches: r.checksumMismatches?.length ?? 0,
                    danglingRefs: r.danglingRefs?.length ?? 0,
                },
            };
        }
        catch { /* @silent-fallback-ok — no report on disk = the import never ran = NOT ready (deny-safe) */
            return { ran: false, passed: false, generatedAt: null, summary: null };
        }
    }
    parityStatus() {
        const nowIso = new Date(this.nowMs()).toISOString();
        const gate = this.d.parityMonitor.gate(nowIso);
        const passes = this.d.parityMonitor.passes;
        const lastPassAt = passes.length > 0 ? passes[passes.length - 1].at : null;
        const stale = lastPassAt === null
            ? true
            : this.nowMs() - Date.parse(lastPassAt) > this.maxStaleMs;
        return { ...gate, lastPassAt, stale };
    }
    /**
     * TRIGGER a server-side parity pass: live fetch + compare via the injected check,
     * record the result (clean OR divergent) into the durable window. The caller
     * contributes nothing to the result — only the request to run it (T7). A FAILED
     * check records nothing: a fetch error is absence of evidence, not evidence of
     * divergence — and it cannot extend the clean window either.
     */
    async runParityPass() {
        if (!this.d.runParityCheck) {
            return { ok: false, reason: 'no parity source configured (feedbackMigration.paritySource) — cannot run a live check' };
        }
        if (this.liveFetchInFlight) {
            return { ok: false, reason: `live source fetch already in flight (${this.liveFetchInFlight.op} started ${this.liveFetchInFlight.startedAt}) — one at a time; retry after it completes` };
        }
        this.liveFetchInFlight = { op: 'parity-pass', startedAt: new Date(this.nowMs()).toISOString() };
        let result;
        try {
            result = await this.withMaxHold(this.d.runParityCheck(), 'parity-pass');
        }
        catch (err) {
            return { ok: false, reason: `parity check failed: ${err instanceof Error ? err.message : String(err)}` };
        }
        finally {
            this.liveFetchInFlight = null;
        }
        const at = new Date(this.nowMs()).toISOString();
        this.d.parityMonitor.recordResult(result, at);
        const divergences = result.fingerprintDivergences.length + result.outcomeDivergences.length;
        return {
            ok: true,
            pass: { at, clustersCompared: result.clustersCompared, divergences, divergent: divergences > 0 },
            gate: this.d.parityMonitor.gate(at),
        };
    }
    /**
     * TRIGGER a server-side import rehearsal (T7: the agent may ask; the server
     * computes — live source fetch, in-memory AS-IS import, integrity gate over the
     * readback). The envelope persists to the SEPARATE dry-run path; the canonical
     * integrity report is structurally out of reach of this method.
     */
    async runImportDryRunPass() {
        if (!this.d.runImportDryRun || !this.d.importDryRunReportPath) {
            return { ok: false, reason: 'no import source configured (feedbackMigration.paritySource) — cannot run an import dry-run' };
        }
        if (this.liveFetchInFlight) {
            return { ok: false, reason: `live source fetch already in flight (${this.liveFetchInFlight.op} started ${this.liveFetchInFlight.startedAt}) — one at a time; retry after it completes` };
        }
        this.liveFetchInFlight = { op: 'import-dry-run', startedAt: new Date(this.nowMs()).toISOString() };
        let result;
        try {
            result = await this.withMaxHold(this.d.runImportDryRun(), 'import-dry-run');
        }
        catch (err) {
            return { ok: false, reason: `import dry-run failed: ${err instanceof Error ? err.message : String(err)}` };
        }
        finally {
            this.liveFetchInFlight = null;
        }
        const generatedAt = new Date(this.nowMs()).toISOString();
        const envelope = { generatedAt, mode: 'dry-run', result };
        fs.mkdirSync(path.dirname(this.d.importDryRunReportPath), { recursive: true });
        fs.writeFileSync(this.d.importDryRunReportPath, JSON.stringify(envelope, null, 2));
        return { ok: true, result, generatedAt };
    }
    /**
     * TRIGGER the REAL pre-click integrity pass (T7: the agent may ask; the server
     * computes — live source fetch → AS-IS import into a PERSISTED shadow → integrity
     * gate over the readback). UNLIKE the dry-run, the resulting report is recorded to
     * the CANONICAL integrity path via recordIntegrityReport: a PASSING report greens
     * `ready`, a FAILING report flips it closed (the door always reflects the LATEST
     * real verdict — never a stale green). A pre-import abort (fingerprint collision →
     * null report) records nothing and leaves prior state; the abort is returned for the
     * operator to resolve. A FAILED fetch records nothing (absence of evidence, not a
     * verdict). Shares the single-flight live-fetch guard + the max-hold backstop.
     */
    async runIntegrityPass() {
        if (!this.d.runIntegrityImport) {
            return { ok: false, reason: 'no import source configured (feedbackMigration.paritySource) — cannot run a real integrity pass' };
        }
        if (this.liveFetchInFlight) {
            return { ok: false, reason: `live source fetch already in flight (${this.liveFetchInFlight.op} started ${this.liveFetchInFlight.startedAt}) — one at a time; retry after it completes` };
        }
        this.liveFetchInFlight = { op: 'integrity-pass', startedAt: new Date(this.nowMs()).toISOString() };
        let result;
        try {
            result = await this.withMaxHold(this.d.runIntegrityImport(), 'integrity-pass');
        }
        catch (err) {
            return { ok: false, reason: `integrity pass failed: ${err instanceof Error ? err.message : String(err)}` };
        }
        finally {
            this.liveFetchInFlight = null;
        }
        const generatedAt = new Date(this.nowMs()).toISOString();
        let recorded = false;
        if (result.report) {
            this.recordIntegrityReport(result.report, generatedAt);
            recorded = true;
        }
        return { ok: true, result, generatedAt, recorded };
    }
    importDryRunStatus() {
        const empty = { ran: false, passed: false, generatedAt: null, imported: null, abortedPreImport: null };
        if (!this.d.importDryRunReportPath)
            return empty;
        try {
            const raw = JSON.parse(fs.readFileSync(this.d.importDryRunReportPath, 'utf8'));
            const r = raw?.result;
            if (!r || typeof r.passed !== 'boolean')
                return empty;
            return {
                ran: true,
                passed: r.passed,
                generatedAt: typeof raw.generatedAt === 'string' ? raw.generatedAt : null,
                imported: r.imported ?? null,
                abortedPreImport: r.abortedPreImport ? r.abortedPreImport.reason : null,
            };
        }
        catch { /* @silent-fallback-ok — no dry-run envelope on disk = the rehearsal never ran (informational-only surface) */
            return empty;
        }
    }
    /** The everything-up-to-the-door readiness signal. Read-only; never fires anything. */
    status() {
        const integrity = this.integrityStatus();
        const parity = this.parityStatus();
        return {
            // `ready` composes integrity + parity ONLY — the dry-run is visibility, not a gate input.
            ready: integrity.passed && parity.cleared && !parity.stale,
            door: 'manual-operator-click',
            integrity,
            parity,
            importDryRun: this.importDryRunStatus(),
        };
    }
}
//# sourceMappingURL=cutoverReadiness.js.map