import assert from 'node:assert/strict';

globalThis.window = {
    EnhancedSecureCryptoUtils: { secureLog: { log() {} } }
};
globalThis.CustomEvent = class CustomEvent {
    constructor(type, init) { this.type = type; this.detail = init?.detail; }
};
const dispatched = [];
globalThis.document = { dispatchEvent(e) { dispatched.push(e.type); } };

const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
const P = EnhancedSecureWebRTCManager.prototype;
const T = EnhancedSecureWebRTCManager.MESSAGE_TYPES;
const TIMEOUTS = EnhancedSecureWebRTCManager.TIMEOUTS;

// Fake timers: the retry cycle is self-rescheduling by design (bounded only by
// RECONNECT_MAX_DURATION), so real timers would keep the test process alive.
// Driving them by hand also lets the backoff schedule itself be asserted.
const realTimers = {
    setTimeout: globalThis.setTimeout,
    clearTimeout: globalThis.clearTimeout,
    setInterval: globalThis.setInterval,
    clearInterval: globalThis.clearInterval
};
let scheduled = [];
globalThis.setTimeout = (callback, delay) => {
    const timer = { kind: 'timeout', callback, delay, cleared: false };
    scheduled.push(timer);
    return timer;
};
globalThis.setInterval = (callback, delay) => {
    const timer = { kind: 'interval', callback, delay, cleared: false };
    scheduled.push(timer);
    return timer;
};
globalThis.clearTimeout = (timer) => { if (timer) timer.cleared = true; };
globalThis.clearInterval = (timer) => { if (timer) timer.cleared = true; };

const pending = () => scheduled.filter((t) => !t.cleared);
// A real timeout stops pending once it fires; mirror that so the next lookup
// finds the newly scheduled retry rather than the spent one.
const fire = (timer) => { timer.cleared = true; return timer.callback(); };
// Let queued microtasks settle: several recovery entry points fire an async
// restart without awaiting it.
const settle = async () => { for (let i = 0; i < 8; i += 1) await Promise.resolve(); };

const FP_A = 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99';
const FP_B = '99:88:77:66:55:44:33:22:11:00:FF:EE:DD:CC:BB:AA';
const sdpWith = (fp) => `v=0\r\no=- 1 1 IN IP4 0.0.0.0\r\ns=-\r\na=fingerprint:sha-256 ${fp}\r\na=setup:actpass\r\n`;

// Minimal manager stub carrying only the recovery surface under test.
function makeManager(overrides = {}) {
    const statuses = [];
    const sent = [];
    const ui = [];
    const mgr = {
        isVerified: true,
        isInitiator: true,
        intentionalDisconnect: false,
        reconnectionFailedNotificationSent: false,
        // Raw frames written straight to the channel (heartbeats/probes), as
        // opposed to `sent`, which collects sendSystemMessage payloads.
        _sentFrames: [],
        _heartbeatConfig: { enabled: true, lastHeartbeat: 0 },
        _livenessProbeAt: 0,
        _livenessArmed: true,
        isConnected: () => true,
        dataChannel: { readyState: 'open', send: (raw) => { mgr._sentFrames.push(JSON.parse(raw)); } },
        peerConnection: {
            connectionState: 'disconnected',
            signalingState: 'stable',
            currentRemoteDescription: { sdp: sdpWith(FP_A) },
            localDescription: { sdp: sdpWith(FP_B) },
            createOffer: async () => ({ type: 'offer', sdp: sdpWith(FP_B) }),
            createAnswer: async () => ({ type: 'answer', sdp: sdpWith(FP_B) }),
            setLocalDescription: async () => {},
            setRemoteDescription: async (d) => { mgr._applied.push(d.type); }
        },
        _applied: [],
        _reconnect: {
            phase: 'idle', attempts: 0, startedAt: 0,
            graceTimer: null, retryTimer: null, restartTimer: null, pendingRole: null
        },
        _activeTimers: new Set(),
        _lastInboundAt: 0,
        _livenessTimer: null,
        _heartbeatTimer: null,
        _secureLog() {},
        _clearVerificationStates() { mgr._verificationCleared = true; },
        _verificationCleared: false,
        onStatusChange: (s) => statuses.push(s),
        deliverMessageToUI: (m) => ui.push(m),
        processMessageQueue() { mgr._queueFlushed = true; },
        _queueFlushed: false,
        waitForIceGathering: async () => true,
        sendSystemMessage: async (m) => { sent.push(m); return true; },
        // Real implementations under test
        _trackActiveTimer: P._trackActiveTimer,
        _noteInboundActivity: P._noteInboundActivity,
        handleHeartbeat: P.handleHeartbeat,
        _sendHeartbeat: P._sendHeartbeat,
        _checkLiveness: P._checkLiveness,
        isReconnecting: P.isReconnecting,
        _resetReconnectState: P._resetReconnectState,
        _onPathDegraded: P._onPathDegraded,
        _onPathLost: P._onPathLost,
        _onPathRecovered: P._onPathRecovered,
        _attemptIceRestart: P._attemptIceRestart,
        _scheduleReconnectRetry: P._scheduleReconnectRetry,
        _sendIceRestartOffer: P._sendIceRestartOffer,
        _currentRemoteDtlsFingerprint: P._currentRemoteDtlsFingerprint,
        _assertSameRemoteIdentity: P._assertSameRemoteIdentity,
        _handleIceRestartSignal: P._handleIceRestartSignal,
        _giveUpAutoReconnect: P._giveUpAutoReconnect,
        _extractDTLSFingerprintFromSDP: P._extractDTLSFingerprintFromSDP,
        _validateDTLSFingerprint: P._validateDTLSFingerprint,
        ...overrides
    };
    scheduled = [];
    return { mgr, statuses, sent, ui };
}

try {
    // ── the heartbeat handler exists and answers probes ──────────────────────
    // It used to be dispatched to but never defined, so every inbound heartbeat
    // threw a TypeError and liveness was never actually observed.
    {
        assert.equal(typeof P.handleHeartbeat, 'function', 'handleHeartbeat must exist');

        // A probe must be answered: that reply is the only thing that proves this
        // side is alive when its own timers are throttled by a backgrounded tab.
        const { mgr } = makeManager();
        mgr._lastInboundAt = 0;
        mgr.handleHeartbeat({ type: 'heartbeat', ack: false });
        assert.ok(mgr._lastInboundAt > 0, 'heartbeat must refresh the liveness clock');
        assert.equal(mgr._sentFrames.length, 1);
        assert.equal(mgr._sentFrames[0].ack, true, 'a probe must be acked');

        // An ack must NOT be acked, or the two sides ping-pong forever.
        const { mgr: acked } = makeManager();
        acked.handleHeartbeat({ type: 'heartbeat', ack: true });
        assert.deepEqual(acked._sentFrames, [], 'an ack must not be answered');
    }

    // ── the answerer starts its watchdog even on an already-open channel ─────
    // `ondatachannel` can hand over a channel that is ALREADY 'open', so the
    // 'open' event has been dispatched before onopen is assigned. That left the
    // answering side with no heartbeats and no liveness watchdog at all — the
    // peer whose network was fine kept showing "connected" indefinitely because
    // nothing on that side was running to notice the other one had vanished.
    {
        const started = [];
        const base = {
            isVerified: false,
            pendingSASCode: null,
            _secureLog() {},
            establishConnection: async () => {},
            initializeFileTransfer() {},
            _notifyVerificationReadyIfPossible() {},
            initiateVerification() {},
            processMessageQueue() {},
            onStatusChange() {},
            _resetReconnectState() {},
            _teardownRecoveryLifecycleListeners() {},
            _noteInboundActivity() {},
            startHeartbeat() { started.push(Date.now()); },
            setupDataChannel: P.setupDataChannel
        };

        // Channel already open when handed over: the handler must still run.
        const already = { ...base };
        already.setupDataChannel({ readyState: 'open', send() {} });
        await settle();
        assert.equal(started.length, 1, 'an already-open channel must still start the watchdog');

        // Normal case: the event fires, and the handler must not run twice.
        started.length = 0;
        const later = { ...base };
        const channel = { readyState: 'connecting', send() {} };
        later.setupDataChannel(channel);
        channel.readyState = 'open';
        await channel.onopen();
        await settle();
        assert.equal(started.length, 1, 'the open handler must run exactly once');
    }

    // ── a peer never heard from is not a dead peer ───────────────────────────
    // Right after connecting, the two sides finish SAS verification at different
    // moments, so one can be probing while the other still cannot answer.
    // Without a baseline, that silence used to read as death and tore down a
    // healthy, freshly established session.
    {
        const { mgr, statuses } = makeManager({ _livenessArmed: false });
        mgr._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 10_000);
        mgr._checkLiveness();
        await settle();
        assert.deepEqual(statuses, [], 'silence before the first frame must not start recovery');
        assert.deepEqual(mgr._sentFrames, [], 'and must not even probe yet');

        // One frame from the peer establishes the baseline; from then on it counts.
        mgr._noteInboundActivity();
        assert.equal(mgr._livenessArmed, true);
        mgr._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 10_000);
        mgr._checkLiveness();
        assert.equal(mgr._sentFrames.length, 1, 'a known peer that goes quiet is probed');
        mgr._resetReconnectState();
    }

    // ── a peer answers probes before it has finished verifying ───────────────
    // The heartbeat path is gated on the channel, not on isVerified: a human has
    // to compare the SAS code, and for that whole window the other side would
    // otherwise be unable to answer and would be declared dead.
    {
        const { mgr } = makeManager({ isVerified: false, isConnected: () => false });
        mgr.handleHeartbeat({ type: 'heartbeat', ack: false });
        assert.equal(mgr._sentFrames.length, 1, 'an unverified peer must still answer a probe');
        assert.equal(mgr._sentFrames[0].ack, true);
    }

    // ── liveness: silence prompts a probe, only an unanswered probe kills ────
    {
        // Busy conversation: recent inbound activity must NOT trip the watchdog.
        const { mgr, statuses } = makeManager();
        mgr._lastInboundAt = Date.now();
        mgr._checkLiveness();
        assert.deepEqual(statuses, [], 'recent traffic must not trigger recovery');
        assert.deepEqual(mgr._sentFrames, [], 'no probe while the peer is chatting');

        // Silence alone must NOT declare the path dead — a backgrounded tab has
        // its timers throttled and legitimately goes quiet. It only earns a probe.
        const { mgr: quiet, statuses: quietStatuses } = makeManager();
        quiet._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 1000);
        quiet._checkLiveness();
        assert.deepEqual(quietStatuses, [], 'silence alone must not start recovery');
        assert.equal(quiet._sentFrames.length, 1, 'silence must trigger a probe');
        assert.equal(quiet._sentFrames[0].ack, false);
        assert.ok(quiet._livenessProbeAt > 0);

        // The peer answers → still alive, no recovery, probe cleared.
        quiet.handleHeartbeat({ type: 'heartbeat', ack: true });
        quiet._checkLiveness();
        assert.deepEqual(quietStatuses, [], 'an answered probe proves the path is alive');
        assert.equal(quiet._livenessProbeAt, 0);

        // A peer whose tab the OS froze cannot answer anything — no JavaScript
        // runs in it at all. But ICE consent checks live in the browser's network
        // stack, not the page's thread, so a 'connected' ICE state proves the peer
        // is still reachable and the silence is a sleeping tab. Tearing the
        // session down here is what broke a healthy chat every time a phone
        // locked its screen.
        const { mgr: asleep, statuses: asleepStatuses } = makeManager();
        asleep.peerConnection.connectionState = 'connected';
        asleep._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 1000);
        asleep._checkLiveness();
        asleep._livenessProbeAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_TIMEOUT + 1000);
        asleep._checkLiveness();
        await settle();
        assert.deepEqual(asleepStatuses, [], 'a silent peer on a healthy ICE path must be left alone');
        assert.equal(asleep.isReconnecting(), false);
        // And since the answer would change nothing, it must not even be asked:
        // probing here was pure traffic for as long as the peer's phone slept.
        assert.deepEqual(asleep._sentFrames, [], 'a healthy ICE path must not be probed at all');
        assert.equal(asleep._livenessProbeAt, 0);
        asleep._resetReconnectState();

        // Probe goes unanswered past its deadline → the path really is dead.
        // This is the Wi-Fi → LTE case: nothing closes, nothing errors, packets
        // just stop and readyState still reads 'open'.
        const { mgr: dead, statuses: deadStatuses } = makeManager();
        dead._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 1000);
        dead._checkLiveness();                                   // sends the probe
        dead._livenessProbeAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_TIMEOUT + 1000);
        dead._checkLiveness();                                   // deadline passed
        await settle();
        assert.ok(deadStatuses.includes('reconnecting'), 'an unanswered probe must start recovery');
        dead._resetReconnectState();
    }

    // ── an unverified session is never dragged into recovery ────────────────
    {
        const { mgr, statuses } = makeManager({ isVerified: false });
        mgr._lastInboundAt = Date.now() - 10 * 60 * 1000;
        mgr._checkLiveness();
        mgr._onPathDegraded('ice_disconnected');
        mgr._onPathLost('ice_failed');
        await settle();
        assert.deepEqual(statuses, [], 'recovery must not run before verification');
    }

    // ── degraded path: UI says reconnecting, session is NOT torn down ────────
    {
        const { mgr, statuses } = makeManager();
        mgr._onPathDegraded('ice_disconnected');
        assert.deepEqual(statuses, ['reconnecting']);
        assert.equal(mgr._verificationCleared, false, 'a transient drop must not clear verification');
        assert.equal(mgr.isVerified, true, 'keys and SAS verification survive a path glitch');
        assert.equal(mgr.isReconnecting(), true);

        // The restart is held back for the grace window, because 'disconnected'
        // usually clears on its own.
        const grace = pending().find((t) => t.delay === TIMEOUTS.ICE_DISCONNECT_GRACE);
        assert.ok(grace, 'a grace window must be scheduled before spending a restart');

        // A second event while already recovering must not restart the cycle.
        mgr._onPathDegraded('ice_disconnected');
        assert.deepEqual(statuses, ['reconnecting'], 'recovery must not be re-entered');
        mgr._resetReconnectState();
        assert.equal(grace.cleared, true, 'reset must clear the grace timer');
    }

    // ── the grace window is sized to the browser's own ICE timings ───────────
    // 'disconnected' arrives after ~5 s of missed consent responses — ordinary
    // packet loss reaches that — and the browser then holds it ~25 s before
    // 'failed'. That window is where self-healing happens, and a phone with its
    // screen off produces these episodes constantly. Restarting at the start of
    // it answered every one with a renegotiation that broke a connection about
    // to recover. https://blog.mozilla.org/webrtc/ice-disconnected-not/
    {
        assert.ok(TIMEOUTS.ICE_DISCONNECT_GRACE >= 5000,
            'must outlast the ~5 s of loss that produces "disconnected" in the first place');
        assert.ok(TIMEOUTS.ICE_DISCONNECT_GRACE + TIMEOUTS.ICE_RESTART_TIMEOUT < 30000,
            'but a restart must still complete before the browser gives up at ~30 s');
    }

    // ── the path recovering during the grace window costs no restart ─────────
    {
        const { mgr, statuses, sent } = makeManager();
        mgr._onPathDegraded('ice_disconnected');
        const grace = pending().find((t) => t.delay === TIMEOUTS.ICE_DISCONNECT_GRACE);
        mgr.peerConnection.connectionState = 'connected';
        fire(grace);
        await settle();
        assert.deepEqual(sent, [], 'a self-healing glitch must not renegotiate');
        assert.deepEqual(statuses, ['reconnecting', 'connected']);
        mgr._resetReconnectState();
    }

    // ── role split: the offerer restarts, the answerer asks ─────────────────
    {
        const { mgr, sent } = makeManager({ isInitiator: true });
        mgr._reconnect.startedAt = Date.now();
        await mgr._attemptIceRestart();
        assert.equal(sent.length, 1);
        assert.equal(sent[0].type, T.ICE_RESTART_OFFER, 'offerer drives the restart');
        assert.ok(sent[0].sdp, 'restart offer carries SDP');
        mgr._resetReconnectState();

        // Both sides offering at once is glare, and with no signalling server
        // there is no referee — so the answerer asks instead of acting.
        const { mgr: answerer, sent: answererSent } = makeManager({ isInitiator: false });
        answerer._reconnect.startedAt = Date.now();
        await answerer._attemptIceRestart();
        assert.equal(answererSent[0].type, T.ICE_RESTART_REQUEST, 'answerer must not create a competing offer');
        answerer._resetReconnectState();
    }

    // ── retries back off, and stop the moment the path is back ──────────────
    {
        const { mgr, sent } = makeManager({ isInitiator: true });
        mgr._reconnect.startedAt = Date.now();
        const backoff = EnhancedSecureWebRTCManager.RECONNECT_BACKOFF;

        await mgr._attemptIceRestart();
        let retry = pending().find((t) => t.kind === 'timeout');
        assert.equal(retry.delay, backoff[0], 'first retry uses the head of the backoff');

        // The round-trip completes but the pair still fails to connect, so the
        // next retry is a genuine second attempt and backs off further.
        mgr.peerConnection.signalingState = 'have-local-offer';
        await mgr._handleIceRestartSignal(T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP_A) });
        fire(retry);
        await settle();
        retry = pending().find((t) => t.kind === 'timeout');
        assert.equal(retry.delay, backoff[1], 'each failed attempt backs off further');
        assert.equal(sent.length, 2, 'each attempt re-sends the restart offer');
        mgr.peerConnection.signalingState = 'have-local-offer';
        await mgr._handleIceRestartSignal(T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP_A) });
        const sentAfterTwoAttempts = sent.length;

        // Path comes back: the pending retry must not fire another restart.
        mgr.peerConnection.connectionState = 'connected';
        fire(retry);
        await settle();
        assert.equal(sent.length, sentAfterTwoAttempts, 'recovery stops as soon as the path is up');
        assert.equal(mgr.isReconnecting(), false);
        mgr._resetReconnectState();
    }

    // ── a healthy connection is never renegotiated ───────────────────────────
    // Recovery can be entered on a stale reading — a mobile tab thawing out of
    // the background is the common one, since its timers were frozen and
    // connectionState is still catching up. Restarting from there broke a
    // working session and stranded it in "reconnecting", where sending is
    // blocked while messages keep arriving.
    {
        const { mgr, sent, statuses } = makeManager();
        mgr._reconnect.phase = 'grace';
        mgr._reconnect.startedAt = Date.now();
        mgr.peerConnection.connectionState = 'connected';

        await mgr._attemptIceRestart();
        assert.deepEqual(sent, [], 'a connected path must not be renegotiated');
        assert.equal(mgr.isReconnecting(), false, 'recovery must stand down, not linger');
        assert.equal(statuses.at(-1), 'connected');
        assert.equal(mgr._queueFlushed, true, 'anything queued meanwhile must go out');
    }

    // ── returning to the foreground asks the peer, it does not accuse it ─────
    {
        const { mgr, statuses } = makeManager();
        mgr._setupRecoveryLifecycleListeners = P._setupRecoveryLifecycleListeners;

        const listeners = {};
        globalThis.window = {
            ...globalThis.window,
            addEventListener: (name, fn) => { listeners[name] = fn; },
            removeEventListener: () => {}
        };
        const realDocument = globalThis.document;
        globalThis.document = {
            visibilityState: 'visible',
            addEventListener: (name, fn) => { listeners[name] = fn; },
            removeEventListener: () => {},
            dispatchEvent: () => {}
        };
        try {
            mgr._setupRecoveryLifecycleListeners();
            // Long silence, but only because the tab's timers were frozen.
            mgr._lastInboundAt = Date.now() - 10 * 60 * 1000;
            listeners.visibilitychange();
            await settle();

            assert.deepEqual(statuses, [], 'coming back must not declare the session broken');
            assert.equal(mgr._sentFrames.length, 1, 'it must probe the peer instead');
            assert.equal(mgr._sentFrames[0].ack, false);
            assert.ok(mgr._livenessProbeAt > 0, 'the probe deadline must be armed');
        } finally {
            globalThis.document = realDocument;
        }
        mgr._resetReconnectState();
    }

    // ── one round-trip at a time ─────────────────────────────────────────────
    // A restart takes seconds end to end, far longer than the head of the
    // backoff. Without this guard each retry replaced the local description out
    // from under the attempt already in flight, so every try cancelled the last
    // and recovery never converged — observed as a long run of attempts that
    // ended in a plain timeout.
    {
        const { mgr, sent } = makeManager({ isInitiator: true });
        mgr._reconnect.startedAt = Date.now();

        await mgr._attemptIceRestart();
        assert.equal(sent.length, 1);
        assert.ok(mgr._reconnect.inFlightAt > 0, 'the round-trip must be marked in flight');

        // The retry fires while the round-trip is still running: it must wait,
        // not launch a competing restart.
        let retry = pending().find((t) => t.kind === 'timeout');
        fire(retry);
        await settle();
        assert.equal(sent.length, 1, 'no competing restart while one is in flight');
        assert.ok(pending().some((t) => t.kind === 'timeout'), 'it must keep waiting, not give up');

        // Round-trip completes (answer applied) → a further attempt is allowed.
        mgr.peerConnection.signalingState = 'have-local-offer';
        await mgr._handleIceRestartSignal(T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP_A) });
        assert.equal(mgr._reconnect.inFlightAt, 0, 'a completed round-trip clears the in-flight mark');
        retry = pending().find((t) => t.kind === 'timeout');
        fire(retry);
        await settle();
        assert.equal(sent.length, 2, 'the next attempt runs once the round-trip is done');
        mgr._resetReconnectState();
    }

    // ── a restart gathers candidates on a budget that fits the round-trip ─────
    {
        const budget = TIMEOUTS.ICE_RESTART_GATHERING;
        assert.ok(budget < TIMEOUTS.ICE_GATHERING_TIMEOUT, 'recovery must gather faster than a fresh handshake');
        assert.ok(budget * 2 < TIMEOUTS.ICE_RESTART_TIMEOUT, 'both gathering legs must fit inside the round-trip budget');

        const { mgr } = makeManager({ isInitiator: true });
        const budgets = [];
        mgr.waitForIceGathering = async (ms) => { budgets.push(ms); return true; };
        mgr._reconnect.startedAt = Date.now();
        await mgr._attemptIceRestart();
        assert.deepEqual(budgets, [budget], 'the restart must pass the short budget');
        mgr._resetReconnectState();
    }

    // ── MITM guard: a restart must never re-point the session at a new identity
    {
        // Same fingerprint as the live session → accepted, answer goes back.
        const { mgr, sent } = makeManager({ isInitiator: false });
        await mgr._handleIceRestartSignal(T.ICE_RESTART_OFFER, { sdp: sdpWith(FP_A) });
        assert.deepEqual(mgr._applied, ['offer']);
        assert.equal(sent.at(-1).type, T.ICE_RESTART_ANSWER);
        mgr._resetReconnectState();

        // Different fingerprint → refused before the peer connection is touched.
        const { mgr: attacked, sent: attackedSent } = makeManager({ isInitiator: false });
        await assert.rejects(
            () => attacked._handleIceRestartSignal(T.ICE_RESTART_OFFER, { sdp: sdpWith(FP_B) }),
            /mismatch/i,
            'a restart offer with a different DTLS fingerprint must be refused'
        );
        assert.deepEqual(attacked._applied, [], 'nothing may be applied to the peer connection');
        assert.deepEqual(attackedSent, [], 'no answer may be sent to an unverified identity');
        attacked._resetReconnectState();

        // Same guard on the answer leg.
        const { mgr: offerer } = makeManager({ isInitiator: true });
        offerer.peerConnection.signalingState = 'have-local-offer';
        await assert.rejects(
            () => offerer._handleIceRestartSignal(T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP_B) }),
            /mismatch/i,
            'a restart answer with a different DTLS fingerprint must be refused'
        );
        assert.deepEqual(offerer._applied, []);
        offerer._resetReconnectState();

        // With no live session to compare against, recovery refuses rather than
        // accepting an unverifiable identity.
        const { mgr: blind } = makeManager({ isInitiator: false });
        blind.peerConnection.currentRemoteDescription = null;
        blind.peerConnection.remoteDescription = null;
        await assert.rejects(
            () => blind._handleIceRestartSignal(T.ICE_RESTART_OFFER, { sdp: sdpWith(FP_A) }),
            /identity/i,
            'no baseline fingerprint must mean refusal, not blind trust'
        );
        blind._resetReconnectState();
    }

    // ── a restart request is only honoured by the offerer ───────────────────
    {
        const { mgr, sent } = makeManager({ isInitiator: false });
        await mgr._handleIceRestartSignal(T.ICE_RESTART_REQUEST, {});
        assert.deepEqual(sent, [], 'the answerer must ignore a restart request');
        mgr._resetReconnectState();

        const { mgr: offerer, sent: offererSent } = makeManager({ isInitiator: true });
        await offerer._handleIceRestartSignal(T.ICE_RESTART_REQUEST, {});
        assert.equal(offererSent[0].type, T.ICE_RESTART_OFFER);
        offerer._resetReconnectState();
    }

    // ── recovery completes: the same session resumes, the queue drains ───────
    {
        dispatched.length = 0;
        const { mgr, statuses } = makeManager();
        mgr._onPathDegraded('ice_disconnected');
        statuses.length = 0;
        mgr.peerConnection.connectionState = 'connected';
        mgr._onPathRecovered();
        assert.deepEqual(statuses, ['connected']);
        assert.equal(mgr._queueFlushed, true, 'messages sent into the dead path must go out');
        assert.equal(mgr.isReconnecting(), false);
        assert.equal(mgr._reconnect.attempts, 0, 'attempt counter resets for the next drop');
        assert.ok(dispatched.includes('connection-recovered'));

        // Recovering from an idle state is a no-op — no spurious 'connected'.
        const { mgr: idle, statuses: idleStatuses } = makeManager();
        idle._onPathRecovered();
        assert.deepEqual(idleStatuses, [], 'no status churn when nothing was broken');
    }

    // ── recovery stops when the channel cannot carry the renegotiation ───────
    // Every route out of a broken path runs over the data channel. If the peer
    // has been completely silent since recovery began, it carries nothing in
    // either direction and no further attempt can succeed. This matters most for
    // the answerer, which cannot renegotiate on its own and would otherwise sit
    // out the whole deadline sending requests nobody can receive — observed as a
    // long run of attempts ending in a plain timeout.
    {
        for (const role of [true, false]) {
            const { mgr, statuses, ui } = makeManager({ isInitiator: role });
            mgr._reconnect.startedAt = Date.now() - 60_000;
            mgr._lastInboundAt = Date.now() - 60_000;
            mgr._reconnect.attempts = 3;

            await mgr._attemptIceRestart();
            assert.equal(mgr._reconnect.phase, 'exhausted',
                `a silent channel must end recovery (${role ? 'offerer' : 'answerer'})`);
            assert.equal(statuses.at(-1), 'recovery_failed',
                'the UI must be told to tear the conversation down, not merely show a drop');
            assert.match(ui[0], /wiped/i, 'and the user must be told the chat is being closed');
        }

        // A peer that is still answering keeps recovery alive.
        const { mgr: alive, sent } = makeManager();
        alive._reconnect.startedAt = Date.now() - 60_000;
        alive._lastInboundAt = Date.now();          // heard from just now
        alive._reconnect.attempts = 3;
        await alive._attemptIceRestart();
        assert.notEqual(alive._reconnect.phase, 'exhausted', 'a responsive peer must not be abandoned');
        assert.equal(sent.length, 1, 'and the attempt goes out');
        alive._resetReconnectState();

        // Early attempts are never cut short on silence alone — a restart
        // round-trip has to be given a chance to produce its first reply.
        const { mgr: early } = makeManager();
        early._reconnect.startedAt = Date.now() - 60_000;
        early._lastInboundAt = Date.now() - 60_000;
        early._reconnect.attempts = 0;
        await early._attemptIceRestart();
        assert.notEqual(early._reconnect.phase, 'exhausted', 'the first attempt must still be tried');
        early._resetReconnectState();
    }

    // ── an ICE agent that cannot gather at all is not worth retrying ─────────
    // After a network change a PeerConnection is often left bound to interfaces
    // that no longer exist: every STUN and TURN request times out and each
    // restart fails with zero candidate pairs. restartIce() cannot rebind it, so
    // spending the whole two-minute deadline on it is two minutes of the user
    // watching nothing happen when the outcome was already decided.
    {
        const { mgr, statuses, ui } = makeManager();
        mgr._noteIceFailureDiagnostics = P._noteIceFailureDiagnostics;
        mgr._reconnect.phase = 'restarting';
        mgr._reconnect.startedAt = Date.now();

        // A failure that still produced pairs is an ordinary flaky path.
        mgr._noteIceFailureDiagnostics({ pairCount: 3 });
        assert.equal(mgr._reconnect.barrenFailures, 0);
        assert.notEqual(statuses.at(-1), 'disconnected');

        // Barren failures accumulate; one alone is not enough to conclude.
        mgr._noteIceFailureDiagnostics({ pairCount: 0 });
        assert.equal(mgr.isReconnecting(), true, 'one barren failure must not end recovery');

        // A pair appearing resets the count — the agent is evidently alive.
        mgr._noteIceFailureDiagnostics({ pairCount: 1 });
        assert.equal(mgr._reconnect.barrenFailures, 0, 'evidence of life resets the count');

        mgr._noteIceFailureDiagnostics({ pairCount: 0 });
        mgr._noteIceFailureDiagnostics({ pairCount: 0 });
        assert.equal(mgr._reconnect.phase, 'exhausted', 'a barren agent ends recovery promptly');
        assert.equal(statuses.at(-1), 'recovery_failed');
        assert.equal(ui.length, 1, 'and the user is told once, with the way out');
    }

    // ── a closed data channel cannot be repaired by an ICE restart ───────────
    // SCTP goes with it, so there is nothing left to carry the renegotiation.
    {
        const { mgr, statuses, ui } = makeManager();
        mgr.dataChannel = { readyState: 'closed' };
        mgr._reconnect.startedAt = Date.now();
        await mgr._attemptIceRestart();
        assert.equal(mgr._reconnect.phase, 'exhausted');
        assert.equal(statuses.at(-1), 'recovery_failed');
        assert.equal(mgr._verificationCleared, true, 'an unrecoverable session clears verification');
        assert.equal(ui.length, 1);
        assert.match(ui[0], /wiped/i, 'the user is told the chat is being closed and wiped');
        assert.deepEqual(pending().filter((t) => t.kind === 'timeout'), [], 'giving up leaves no timers behind');
    }

    // ── a device with no network holds the session instead of losing it ──────
    // A five-minute tunnel must not cost the user their session: no restart can
    // succeed with the radio off, so the give-up deadline is held open.
    {
        // Node exposes navigator as a getter-only global, so swap the descriptor.
        const realNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
        const fakeNavigator = { onLine: false };
        Object.defineProperty(globalThis, 'navigator', { value: fakeNavigator, configurable: true });
        try {
            const { mgr, sent, statuses } = makeManager();
            mgr._reconnect.startedAt = Date.now() - (TIMEOUTS.RECONNECT_MAX_DURATION + 60_000);
            await mgr._attemptIceRestart();
            assert.equal(mgr._reconnect.phase, 'waiting', 'recovery waits rather than giving up');
            assert.notEqual(statuses.at(-1), 'disconnected', 'an offline device must not end the session');
            assert.deepEqual(sent, [], 'nothing is transmitted with no network');

            // The hold must schedule a real retry, not a 0 ms spin.
            const retry = pending().find((t) => t.kind === 'timeout');
            assert.ok(retry && retry.delay > 0, 'the offline hold must schedule a sane retry delay');

            // Network comes back → retry immediately, deadline starts fresh.
            fakeNavigator.onLine = true;
            await mgr._attemptIceRestart();
            assert.equal(sent.at(-1).type, T.ICE_RESTART_OFFER, 'recovery resumes once the radio is back');
            mgr._resetReconnectState();
        } finally {
            if (realNavigator) Object.defineProperty(globalThis, 'navigator', realNavigator);
            else delete globalThis.navigator;
        }
    }

    // ── giving up is bounded by the overall deadline ─────────────────────────
    {
        const { mgr, statuses } = makeManager();
        mgr._reconnect.startedAt = Date.now() - (TIMEOUTS.RECONNECT_MAX_DURATION + 1000);
        await mgr._attemptIceRestart();
        assert.equal(mgr._reconnect.phase, 'exhausted');
        assert.equal(statuses.at(-1), 'recovery_failed');
    }

    // ── manual retry restarts the cycle with a fresh deadline ───────────────
    {
        const { mgr, sent } = makeManager();
        mgr.reconnectionFailedNotificationSent = true;
        mgr._reconnect.phase = 'exhausted';
        const ok = P.attemptReconnection.call(mgr);
        await settle();
        assert.equal(ok, true);
        assert.equal(mgr.reconnectionFailedNotificationSent, false, 'the user can be told again if it fails again');
        assert.equal(sent.at(-1).type, T.ICE_RESTART_OFFER);
        mgr._resetReconnectState();

        // Nothing to restart over: report failure instead of pretending.
        const { mgr: gone, ui } = makeManager();
        gone.dataChannel = { readyState: 'closed' };
        assert.equal(P.attemptReconnection.call(gone), false);
        assert.match(ui[0], /new connection is required/i);
    }

    // ── backoff is bounded and monotonic ─────────────────────────────────────
    {
        const backoff = EnhancedSecureWebRTCManager.RECONNECT_BACKOFF;
        assert.ok(backoff.length > 0);
        for (let i = 1; i < backoff.length; i += 1) {
            assert.ok(backoff[i] >= backoff[i - 1], 'backoff must not shrink');
        }
        assert.ok(Object.isFrozen(backoff));
    }

    // ── recovery frames never surface as chat messages ───────────────────────
    {
        const manager = {
            _debugMode: false,
            _secureLog() {},
            getSecurityStatus: () => ({ activeFeaturesCount: 0 }),
            _checkInboundRateLimit: () => true
        };
        for (const type of [T.ICE_RESTART_OFFER, T.ICE_RESTART_ANSWER, T.ICE_RESTART_REQUEST]) {
            const result = await P.removeSecurityLayers.call(manager, JSON.stringify({ type, sdp: 'v=0' }));
            assert.equal(result, 'SYSTEM_MESSAGE_FILTERED', `${type} must not reach the chat log`);
        }
    }

    console.log('session-recovery.test.mjs: all assertions passed');
} finally {
    Object.assign(globalThis, realTimers);
}
