/**
 * TunnelNotifier — two-channel routing of tunnel lifecycle events to
 * the user.
 *
 * Per spec specs/dev-infrastructure/tunnel-failure-resilience.md Part 3.
 *
 * Strict channel separation (the GPT external review's CRITICAL finding):
 *   - GROUP topic (Dashboard / Lifeline fallback / General fallback):
 *     status text only. NEVER the URL, NEVER the PIN, NEVER a signed
 *     view link. Anyone in the group can read those, which would defeat
 *     the owner-only consent gate.
 *   - OWNER DM (private bot chat with the owner principal): the actual
 *     URL + PIN + signed view links. The only credential-bearing channel.
 *
 * Class-based throttling (verification finding V2 + GPT finding #5):
 *   - action-required: NEVER throttled within an episode. Cross-episode
 *     cooldown applies (consent prompts) but the lifecycle decides that,
 *     not the notifier.
 *   - state-change: light — at most one per (episode, state) pair, and
 *     at most one per 15 minutes within an episode on re-entry of the
 *     same state.
 *   - noise: heavy — flapping episodes collapse into one "tunnel
 *     unstable" message, suppressed for the rest of the episode.
 *
 * Dedup by transition epoch (the lifecycle's monotonic counter): the
 * notifier never emits twice for the same epoch, even if the same
 * event fires through multiple listeners.
 */
export class TunnelNotifier {
    sink;
    clock;
    credentialProvider;
    suppressConsentDM;
    stateChangeMinIntervalMs;
    flapThreshold;
    // Dedup state. The lifecycle's epoch is monotonic per manager instance,
    // so once we've emitted for epoch N we never re-emit for N.
    lastEmittedEpoch = -1;
    // Per-episode bookkeeping.
    currentEpisodeId = null;
    /** Last-emitted timestamps keyed by `<state>::<channel>` so the group
     * pointer and the owner-DM credential delivery are throttled
     * independently — otherwise the DM gets swallowed after the group
     * pointer fires for the same state-change. */
    lastEmittedAt = new Map();
    /** Track noise-collapse emission per episode. Once emitted, further
     * noise messages in the same episode are suppressed. */
    noiseEmittedThisEpisode = false;
    flapCycles = 0;
    flapCollapsed = false;
    constructor(opts) {
        this.sink = opts.sink;
        this.clock = opts.clock ?? { now: () => Date.now() };
        this.credentialProvider = opts.credentialProvider;
        this.suppressConsentDM = opts.suppressConsentDM ?? false;
        this.stateChangeMinIntervalMs = opts.stateChangeMinIntervalMs ?? 15 * 60_000;
        this.flapThreshold = opts.flapThreshold ?? 3;
    }
    /**
     * Drive the notifier from a lifecycle transition. Idempotent on the
     * transition epoch — repeated calls with the same epoch are no-ops.
     */
    async onTransition(e) {
        // Dedup on epoch FIRST: never emit twice for the same epoch even if
        // the listener fires multiple times.
        if (e.epoch <= this.lastEmittedEpoch)
            return;
        this.lastEmittedEpoch = e.epoch;
        // Reset per-episode bookkeeping when the episode id changes.
        const newEpisodeId = e.episode?.episodeId ?? null;
        if (newEpisodeId !== this.currentEpisodeId) {
            this.currentEpisodeId = newEpisodeId;
            this.lastEmittedAt = new Map();
            this.noiseEmittedThisEpisode = false;
            this.flapCycles = 0;
            this.flapCollapsed = false;
        }
        // Build the message for THIS transition (if any).
        const messages = this.composeMessages(e);
        for (const m of messages) {
            const allowed = this.allow(m, e.to);
            if (!allowed)
                continue;
            try {
                if (m.channel === 'group') {
                    await this.sink.sendGroup(m.text);
                }
                else {
                    await this.sink.sendOwnerDM(m.text);
                }
                // Update bookkeeping AFTER successful send (cheap-best-effort).
                const key = throttleKey(e.to, m.channel);
                this.lastEmittedAt.set(key, this.clock.now());
                if (m.class === 'noise') {
                    this.noiseEmittedThisEpisode = true;
                }
            }
            catch {
                // Never throw into the tunnel path — preserves existing
                // .catch(() => {}) semantics from server.ts.
            }
        }
    }
    /**
     * Compose the user-facing messages for a transition. May return zero,
     * one, or two messages (some transitions emit to BOTH channels, e.g.
     * "back online — link in your DM" + DM with the URL).
     *
     * The DM credential payload is filled in by the manager via a callback
     * (the notifier itself doesn't know URLs/PINs); for now the DM strings
     * are placeholders that the manager substitutes before send. Future:
     * extend onTransition signature to accept the credential payload.
     */
    composeMessages(e) {
        const ep = e.episode?.episodeId ?? null;
        const messages = [];
        switch (e.to) {
            case 'retrying':
                // Routine Tier-1 retry churn is SILENT (operator feedback
                // 2026-05-23: the group topic was being spammed with "still
                // retrying / unstable" on every background-retry cycle). A
                // temporary Cloudflare outage is self-evident if the user tries
                // the link; they don't need a per-cycle play-by-play. The user is
                // only messaged when there's something to DO (consent) or
                // something USABLE (a new link) — see awaiting-consent /
                // relay-active / active. GET /tunnel exposes live state for
                // anyone who wants to check.
                break;
            case 'active':
                if (e.from === 'self-healing' || e.from === 'relay-active') {
                    // Self-healed back to Tier 1.
                    messages.push({
                        channel: 'group',
                        class: 'state-change',
                        text: `Your permanent link is back. New link in your DM.`,
                        episodeId: ep,
                        epoch: e.epoch,
                    });
                    messages.push({
                        channel: 'owner-dm',
                        class: 'state-change',
                        text: this.composeRestoredDM(),
                        episodeId: ep,
                        epoch: e.epoch,
                    });
                }
                else if (e.from === 'retrying') {
                    // Cloudflare came back during retry.
                    messages.push({
                        channel: 'group',
                        class: 'state-change',
                        text: `Back online. Link in your DM.`,
                        episodeId: ep,
                        epoch: e.epoch,
                    });
                    messages.push({
                        channel: 'owner-dm',
                        class: 'state-change',
                        text: this.composeRecoveredDM(),
                        episodeId: ep,
                        epoch: e.epoch,
                    });
                }
                // Other 'active' arrivals (initial startup) are non-events from the user's perspective.
                break;
            case 'awaiting-consent':
                // Consent prompt: owner DM only, brief group pointer.
                messages.push({
                    channel: 'group',
                    class: 'state-change',
                    text: `Cloudflare is down. I've messaged you in DM about a possible backup.`,
                    episodeId: ep,
                    epoch: e.epoch,
                });
                if (!this.suppressConsentDM) {
                    messages.push({
                        channel: 'owner-dm',
                        class: 'action-required',
                        text: this.composeConsentPromptDM(),
                        episodeId: ep,
                        epoch: e.epoch,
                    });
                }
                break;
            case 'relay-active':
                // A Tier-2 relay is up: group pointer + DM with URL + rotated PIN.
                messages.push({
                    channel: 'group',
                    class: 'state-change',
                    text: `Backup tunnel is up. New link in your DM.`,
                    episodeId: ep,
                    epoch: e.epoch,
                });
                messages.push({
                    channel: 'owner-dm',
                    class: 'state-change',
                    text: this.composeRelayActiveDM(),
                    episodeId: ep,
                    epoch: e.epoch,
                });
                break;
            case 'exhausted':
                // Also SILENT (operator feedback 2026-05-23). "All options
                // unavailable" fired on every retry cycle's exhaustion and was a
                // primary noise source. The background retry continues silently;
                // the user is messaged only on a usable-link change. GET /tunnel
                // reports `lifecycle.state: 'exhausted'` for anyone checking.
                break;
            case 'self-healing':
                // No user-visible message at entry. The "permanent link is back"
                // emits on self-healing → active (above).
                break;
            // No notification on idle / starting transitions.
            case 'idle':
            case 'starting':
                break;
        }
        return messages;
    }
    /**
     * Throttle decision per spec Part 3:
     *   - action-required: NEVER throttled within an episode.
     *   - state-change: at most one per (episode, state, channel) combo
     *     within `stateChangeMinIntervalMs`. The channel scoping is
     *     load-bearing: the group pointer and the owner-DM credential
     *     delivery are TWO messages for the same state-change; they must
     *     both emit, not throttle each other out.
     *   - noise: emits at most once per episode. The flap-collapse
     *     message goes out the first time the threshold is crossed; all
     *     subsequent noise in the same episode is suppressed.
     */
    allow(m, toState) {
        if (m.class === 'action-required')
            return true;
        if (m.class === 'noise') {
            return !this.noiseEmittedThisEpisode;
        }
        // state-change: keyed per (state, channel).
        const key = throttleKey(toState, m.channel);
        const lastAt = this.lastEmittedAt.get(key);
        if (lastAt === undefined)
            return true;
        return this.clock.now() - lastAt >= this.stateChangeMinIntervalMs;
    }
    // ── Owner-DM message composers ───────────────────────────────────
    /** Snapshot of credentials at compose time (safely degrades when no provider). */
    creds() {
        if (!this.credentialProvider)
            return { url: null };
        try {
            return this.credentialProvider();
        }
        catch {
            return { url: null };
        }
    }
    renderLink() {
        const { url, pin } = this.creds();
        if (!url)
            return `(link not available yet — I'll send it as soon as the tunnel is up)`;
        if (pin)
            return `Link: ${url}\nPIN: ${pin}`;
        return `Link: ${url}`;
    }
    composeRecoveredDM() {
        return `Cloudflare is back. Your dashboard link:\n\n${this.renderLink()}`;
    }
    composeRestoredDM() {
        return `Your permanent Cloudflare link is back — switched off the backup. New link:\n\n${this.renderLink()}\n\nIf you had your dashboard open, you may need to re-enter the PIN.`;
    }
    composeRelayActiveDM() {
        return `Backup tunnel is up and serving your dashboard. New link below.\n\nHeads up: this routes through a third-party server, so your dashboard traffic is briefly going through their machines while Cloudflare is down. The agent will switch you back to your normal link automatically as soon as Cloudflare recovers.\n\n${this.renderLink()}`;
    }
    composeConsentPromptDM() {
        return `Cloudflare is unavailable and I can't get you a dashboard link through the usual path.\n\nI can try a backup that routes through a third-party server. This means your dashboard PIN and any private view links would briefly be visible to whoever operates that backup while it's in use — and your auth token will be rotated after the backup is no longer needed, which will sign you out of any open dashboard tabs and invalidate any previously-shared private view links.\n\nReply "yes, use a backup" to approve, or "no" to keep waiting for Cloudflare. If you don't reply, I'll keep waiting for Cloudflare and won't use a backup.`;
    }
}
function throttleKey(state, channel) {
    return `${state}::${channel}`;
}
//# sourceMappingURL=TunnelNotifier.js.map