/**
 * BurnDetector — emits a structured signal when a single attribution_key
 * crosses configured spend thresholds.
 *
 * Phase 3 of docs/specs/token-burn-detection-and-self-heal.md.
 *
 * Signal-only (per umbrella spec §"Signal-vs-Authority Decomposition"): the
 * detector emits to the existing DegradationReporter. The Phase 4 runbook
 * (Tier-2 Remediator surface) is the only authority that decides whether
 * to alert / throttle / both. The BurnDetector cannot decide anything.
 *
 * Triggers (OR'd):
 *   1. Absolute share — a single attribution_key consumed > absoluteShareThreshold
 *      of total 24h spend.
 *   2. Baseline divergence — the key's last-1h rate is > rollingBaselineMultiplier ×
 *      its trailing-7-day median rate, AND the 1h rate exceeds rollingBaselineFloor
 *      tokens/h.
 *
 * Cold-start (per umbrella spec §"Threshold logic"): the baseline-divergence
 * trigger only fires after a key has been observed for 7 days. Until then,
 * absolute-share is the only signal — that's the trigger that would have
 * caught the 2026-05-15 incident.
 *
 * Polling cadence: 60s, decoupled from the ledger writer. The detector does
 * its own clock (injected `now`) so tests don't need to fake timers globally.
 */
import { PRE_ATTRIBUTION_KEY } from './AttributionResolver.js';
export const DEFAULT_BURN_DETECTION_CONFIG = {
    enabled: true,
    absoluteShareThreshold: 0.25,
    absoluteShareActivityFloorTokens: 0,
    rollingBaselineMultiplier: 2,
    rollingBaselineFloor: 10_000_000,
    perKeyAlertCooldownMs: 3_600_000,
    pollIntervalMs: 60_000,
    coldStartMs: 7 * 24 * 60 * 60 * 1000,
};
export class BurnDetector {
    ledger;
    reporter;
    config;
    now;
    ultraCapMonitor;
    /** First time a given attribution_key was seen. Used for cold-start cutoff. */
    firstSeen = new Map();
    /** Last alert emit time per key — gates per-key alert cooldown. */
    lastAlertAt = new Map();
    timer = null;
    constructor(deps) {
        this.ledger = deps.ledger;
        this.reporter = deps.reporter;
        this.config = { ...DEFAULT_BURN_DETECTION_CONFIG, ...(deps.config ?? {}) };
        this.now = deps.now ?? (() => Date.now());
        this.ultraCapMonitor = deps.ultraCapMonitor;
    }
    start() {
        if (!this.config.enabled || this.timer)
            return;
        this.timer = setInterval(() => {
            try {
                this.tick();
            }
            catch (err) {
                console.warn(`[burn-detector] tick error (non-fatal): ${err.message}`);
            }
        }, this.config.pollIntervalMs);
        // Prevent the interval from keeping the process alive on its own — the
        // server's lifecycle owns shutdown.
        if (this.timer && typeof this.timer.unref === 'function') {
            this.timer.unref();
        }
    }
    stop() {
        if (this.timer) {
            clearInterval(this.timer);
            this.timer = null;
        }
    }
    /**
     * One detector cycle. Returns the signals emitted on this tick — useful
     * for tests, and the production loop ignores the return.
     */
    tick() {
        if (!this.config.enabled)
            return [];
        // §8 ultra-cap monitor rides this cadence — BEFORE the empty-ledger
        // early-returns below, so a quiet attribution ledger can't starve the
        // per-session cap check. Its tick() is self-guarding (never throws).
        this.ultraCapMonitor?.tick();
        const now = this.now();
        const since24h = now - 24 * 60 * 60 * 1000;
        const since1h = now - 60 * 60 * 1000;
        const since7d = now - 7 * 24 * 60 * 60 * 1000;
        const keys24h = this.ledger.byAttributionKey({ sinceMs: since24h });
        if (keys24h.length === 0)
            return [];
        const total24h = keys24h.reduce((sum, k) => sum + k.totalTokens, 0);
        if (total24h <= 0)
            return [];
        // Update first-seen map.
        for (const k of keys24h) {
            if (!this.firstSeen.has(k.attributionKey)) {
                this.firstSeen.set(k.attributionKey, k.firstTs);
            }
        }
        const signals = [];
        const keys1h = this.ledger.byAttributionKey({ sinceMs: since1h });
        const keys1hMap = new Map(keys1h.map((k) => [k.attributionKey, k]));
        for (const key24h of keys24h) {
            const share = key24h.totalTokens / total24h;
            const key1h = keys1hMap.get(key24h.attributionKey);
            const tokens1h = key1h ? key1h.totalTokens : 0;
            const projectedDaily = tokens1h * 24;
            // Skip exempt runbook self-attribution prefix (defence in depth — Phase 4
            // runbook also exempts itself at the gate level).
            if (key24h.attributionKey.startsWith('burn-throttle-runbook::'))
                continue;
            // Cooldown.
            const lastAlert = this.lastAlertAt.get(key24h.attributionKey) ?? 0;
            if (now - lastAlert < this.config.perKeyAlertCooldownMs)
                continue;
            let trigger = null;
            let baselineMedian7d;
            // Trigger 1 — absolute share.
            //
            // The PRE_ATTRIBUTION_KEY sentinel ("resolver never ran") is EXEMPT from
            // this trigger. A bucket at 100% share under the sentinel means "we
            // don't attribute these events yet" — a coverage gap — not "one
            // component is burning." Before attribution was wired, every event sat
            // under this sentinel, so absolute-share fired forever (the false
            // positive this change closes). Per the umbrella spec §"Threshold
            // logic", the sentinel is a coverage signal, never an absolute-share
            // burn trigger. The genuinely-residual `unknown::<sessionId>` key
            // (resolver ran, found no match) is NOT exempt and still alerts —
            // that's the spec's "alert on unattributable spend." Baseline-divergence
            // (trigger 2) is intentionally still allowed on the sentinel: a sudden
            // spike of NEW unattributed spend vs its own 7-day history is worth
            // surfacing even though its absolute share is not.
            //
            // ACTIVITY GATE: absolute-share also requires the key to be actively
            // spending right now (last-1h tokens above absoluteShareActivityFloorTokens).
            // A burn alert means "something is spending heavily NOW" — a key whose
            // 24h share is high but whose current rate is ~zero is a FINISHED burst
            // still inside the trailing window, not a live burn. Without this gate one
            // heavy session re-tripped the 25% alarm every cooldown for a full 24h
            // with a self-contradictory "consumed 67% of 24h spend … Projected 0
            // tokens" message (the 2026-06-03 noise incident). The baseline-divergence
            // trigger already gates on rollingBaselineFloor; this brings the
            // absolute-share trigger to parity.
            const isPreAttributionSentinel = key24h.attributionKey === PRE_ATTRIBUTION_KEY;
            const isActivelySpending = tokens1h > this.config.absoluteShareActivityFloorTokens;
            if (!isPreAttributionSentinel && isActivelySpending && share > this.config.absoluteShareThreshold) {
                trigger = 'absolute-share';
            }
            // Trigger 2 — baseline divergence (only after cold-start window).
            if (!trigger) {
                const firstSeen = this.firstSeen.get(key24h.attributionKey) ?? key24h.firstTs;
                if (now - firstSeen >= this.config.coldStartMs && tokens1h >= this.config.rollingBaselineFloor) {
                    // 7-day median rate (tokens/h). Use the simple per-key 7d total / 168
                    // as a proxy when the table doesn't store hourly buckets — Phase 4 may
                    // refine to a true median.
                    const keys7d = this.ledger.byAttributionKey({ sinceMs: since7d });
                    const key7d = keys7d.find((k) => k.attributionKey === key24h.attributionKey);
                    if (key7d) {
                        baselineMedian7d = key7d.totalTokens / (7 * 24);
                        if (tokens1h > this.config.rollingBaselineMultiplier * baselineMedian7d) {
                            trigger = 'baseline-divergence';
                        }
                    }
                }
            }
            if (!trigger)
                continue;
            const signal = {
                attributionKey: key24h.attributionKey,
                trigger,
                emittedAt: new Date(now).toISOString(),
                observed: {
                    tokens24h: key24h.totalTokens,
                    share24h: share,
                    tokensLast1h: tokens1h,
                    projectedDaily,
                },
                baselineMedian7d,
            };
            signals.push(signal);
            this.lastAlertAt.set(key24h.attributionKey, now);
            // Emit to DegradationReporter. The runbook in Phase 4 will subscribe via
            // the existing Remediator dispatch and decide alert vs throttle.
            this.reporter.report({
                feature: 'token-burn-detection',
                primary: `attribution_key ${key24h.attributionKey} sustained spend within thresholds`,
                fallback: `signal-only: detector flagged the key (Phase 4 runbook will decide alert vs throttle)`,
                reason: trigger === 'absolute-share'
                    ? `${key24h.attributionKey} consumed ${(share * 100).toFixed(1)}% of 24h spend (threshold ${(this.config.absoluteShareThreshold * 100).toFixed(0)}%)`
                    : `${key24h.attributionKey} last-1h rate ${tokens1h.toLocaleString()} tok/h, baseline ${baselineMedian7d?.toLocaleString() ?? '?'} tok/h (multiplier ${this.config.rollingBaselineMultiplier}x)`,
                impact: `Projected ${projectedDaily.toLocaleString()} tokens in next 24h at the current rate.`,
            });
        }
        return signals;
    }
}
//# sourceMappingURL=BurnDetector.js.map