/**
 * TunnelLifecycle — single-owner state machine for the tunnel layer.
 *
 * Per spec specs/dev-infrastructure/tunnel-failure-resilience.md Part 2.
 *
 * This module owns the detect → attempt → fall-back → notify → self-heal
 * lifecycle. The existing server.ts startup-retry ladder, background-
 * retry ladder, and Lifeline failure message are retired in favor of
 * routing all retry through here.
 *
 * Design properties:
 *   - Single-writer transitions: every state change goes through
 *     `transition(expectedFrom, to)`, a compare-and-swap that REJECTS
 *     any transition whose expectedFrom doesn't match the current
 *     state. This prevents the error+exit double-handler race that the
 *     concurrency reviewer surfaced.
 *   - Monotonic epoch: each guarded transition carries a `transition.id`
 *     for downstream notification dedup. Notifications are emitted ONLY
 *     from inside the guarded mutation, tagged with the epoch.
 *   - Episode model: each contiguous failure→recovery cycle is an
 *     `episode` with its own id and one-time consent nonce. All consent
 *     state binds to (episodeId, provider, ownerId, chatId, messageId,
 *     issuedAt).
 *
 * This module is provider-agnostic and notification-channel-agnostic.
 * Providers are injected as a pool; the notifier is injected as a sink.
 * Both responsibilities live OUTSIDE this module per the spec's
 * separation of concerns.
 */
import crypto from 'node:crypto';
import { EventEmitter } from 'node:events';
// ── Helpers ──────────────────────────────────────────────────────────
/** Classify a provider failure based on the Error's `message`. */
export function classifyFailure(err) {
    const msg = (err instanceof Error ? err.message : String(err ?? '')).toLowerCase();
    if (msg.startsWith('rate-limited') || msg.includes('rate limit') || msg.includes('429') || msg.includes('1015')) {
        return 'rate-limited';
    }
    if (msg.startsWith('binary-missing') || msg.includes('not installed') || msg.includes('enoent')) {
        return 'binary-missing';
    }
    if (msg.startsWith('network') || msg.includes('dns') || msg.includes('econnrefused') || msg.includes('eai_again')) {
        return 'network';
    }
    if (msg.startsWith('timeout') || msg.includes('timed out')) {
        return 'timeout';
    }
    if (msg.startsWith('reachability-failed')) {
        return 'reachability-failed';
    }
    if (msg.includes('process-exit') || msg.includes('exited')) {
        return 'process-exit';
    }
    return 'unknown';
}
/** Generate a fresh 128-bit CSPRNG nonce as hex (32 chars). */
export function generateNonce() {
    return crypto.randomBytes(16).toString('hex');
}
/** Generate a fresh episode id (16-byte hex, no PII). */
export function generateEpisodeId() {
    return crypto.randomBytes(8).toString('hex');
}
// ── Single-writer state machine ─────────────────────────────────────
/** Valid transitions per the spec's state machine. */
const VALID_TRANSITIONS = new Map([
    ['idle', new Set(['starting'])],
    ['starting', new Set(['active', 'retrying', 'awaiting-consent', 'exhausted', 'idle'])],
    ['active', new Set(['retrying', 'idle'])],
    ['retrying', new Set(['active', 'starting', 'awaiting-consent', 'exhausted', 'idle'])],
    ['awaiting-consent', new Set(['relay-active', 'starting', 'active', 'exhausted', 'idle'])],
    ['relay-active', new Set(['self-healing', 'idle', 'exhausted'])],
    ['self-healing', new Set(['active', 'relay-active', 'exhausted', 'idle'])],
    ['exhausted', new Set(['self-healing', 'starting', 'idle'])],
]);
export function isValidTransition(from, to) {
    if (from === to)
        return false;
    const allowed = VALID_TRANSITIONS.get(from);
    return !!allowed && allowed.has(to);
}
export class TunnelLifecycle extends EventEmitter {
    _state = 'idle';
    _epoch = 0;
    _episode = null;
    _lastFailureReason = null;
    _activeProvider = null;
    _consentCooldown = {
        consecutiveRefusals: 0,
        lastExtendedAt: 0,
        activeUntil: 0,
    };
    _rotationPending = false;
    get state() { return this._state; }
    get epoch() { return this._epoch; }
    get episode() { return this._episode; }
    get activeProvider() { return this._activeProvider; }
    get rotationPending() { return this._rotationPending; }
    get consentCooldown() { return { ...this._consentCooldown }; }
    get lastFailureReason() { return this._lastFailureReason; }
    /**
     * Compare-and-swap transition. Returns true if the transition was
     * accepted; false if the current state didn't match `expectedFrom`
     * (caller MUST treat false as a race — the lifecycle moved
     * underneath them — and re-read state).
     *
     * `to` must be reachable from `expectedFrom` per VALID_TRANSITIONS;
     * an invalid pair throws (programming error, not a runtime race).
     */
    transition(expectedFrom, to, ctx) {
        if (this._state !== expectedFrom) {
            return false; // CAS lost
        }
        if (!isValidTransition(expectedFrom, to)) {
            throw new Error(`invalid transition ${expectedFrom} → ${to}`);
        }
        // Apply mutation atomically (Node is single-threaded; this synchronous
        // block IS the critical section).
        this._state = to;
        this._epoch += 1;
        if (ctx?.episode !== undefined)
            this._episode = ctx.episode;
        if (ctx?.activeProvider !== undefined)
            this._activeProvider = ctx.activeProvider;
        if (ctx?.lastFailureReason !== undefined)
            this._lastFailureReason = ctx.lastFailureReason;
        if (ctx?.rotationPending !== undefined)
            this._rotationPending = ctx.rotationPending;
        const event = {
            epoch: this._epoch,
            from: expectedFrom,
            to,
            episode: this._episode,
            lastFailureReason: this._lastFailureReason,
            at: Date.now(),
        };
        this.emit('transition', event);
        return true;
    }
    /** Begin a new failure-recovery episode. Called when entering `starting` from `idle` or `active`. */
    startEpisode() {
        const ep = {
            episodeId: generateEpisodeId(),
            startedAt: new Date().toISOString(),
            tier1Attempts: 0,
            lastFailureReason: null,
            attemptedProviders: [],
        };
        this._episode = ep;
        return ep;
    }
    /** Close out the current episode (called when returning to `active` or `idle`). */
    endEpisode() {
        this._episode = null;
    }
    /** Record a Tier-1 provider failure attempt against the current episode. */
    recordAttempt(provider, reason) {
        if (!this._episode)
            return;
        this._episode.tier1Attempts += 1;
        this._episode.lastFailureReason = reason;
        this._episode.attemptedProviders.push(provider);
        this._lastFailureReason = reason;
    }
    /** Apply an exponential consent cooldown after a decline / timeout. */
    recordConsentRefusal() {
        this._consentCooldown.consecutiveRefusals += 1;
        const n = this._consentCooldown.consecutiveRefusals;
        // 1h → 4h → 24h, then 24h forever
        const COOLDOWNS_MS = [60 * 60_000, 4 * 60 * 60_000, 24 * 60 * 60_000];
        const idx = Math.min(n - 1, COOLDOWNS_MS.length - 1);
        const ms = COOLDOWNS_MS[idx] ?? COOLDOWNS_MS[COOLDOWNS_MS.length - 1];
        if (ms === undefined)
            throw new Error('unreachable: cooldown table empty');
        this._consentCooldown.lastExtendedAt = Date.now();
        this._consentCooldown.activeUntil = Date.now() + ms;
        return this.consentCooldown;
    }
    /** Clear cooldown — called on explicit owner opt-in or fresh post-cooldown episode. */
    clearConsentCooldown() {
        this._consentCooldown = { consecutiveRefusals: 0, lastExtendedAt: 0, activeUntil: 0 };
    }
    /** True if the consent prompt is currently suppressed by the cooldown. */
    isConsentSuppressed(now = Date.now()) {
        return this._consentCooldown.activeUntil > now;
    }
    /** Snapshot current state for persistence to tunnel.json. */
    snapshot() {
        return {
            version: 1,
            lastState: this._state,
            lastUrl: null, // populated by the manager that knows the URL
            activeProvider: this._activeProvider,
            rotationPending: this._rotationPending,
            consentCooldown: this.consentCooldown,
            episode: this._episode ? { ...this._episode, attemptedProviders: [...this._episode.attemptedProviders] } : null,
            savedAt: new Date().toISOString(),
        };
    }
    /**
     * Restore from a persisted snapshot. Called at boot ONLY for the
     * cooldown counter + rotationPending flag — the state itself is NOT
     * resumed (every boot starts at `idle` and re-enters `starting` from
     * scratch). The rotationPending flag triggers a rotation-before-
     * traffic in the boot-recovery path; rotation clears the flag.
     */
    restoreFrom(snapshot) {
        this._consentCooldown = { ...snapshot.consentCooldown };
        this._rotationPending = snapshot.rotationPending;
        // _state stays 'idle' by design — boot recovery rotation runs before
        // the new lifecycle starts.
    }
    /** Set the rotation-pending flag (entered on `relay-active`; cleared after rotation). */
    setRotationPending(value) {
        this._rotationPending = value;
    }
    /** Used by callers (manager) that need to validate a consent-reply against the active state. */
    isAwaitingConsent() {
        return this._state === 'awaiting-consent';
    }
}
//# sourceMappingURL=TunnelLifecycle.js.map