/**
 * Remote Substrate — Reconnect Engine
 *
 * Implements reconnect with handshake tokens, epoch tracking,
 * replay from last acknowledged offset, and idempotent command submission.
 *
 * The reconnect engine drives the transport state machine:
 *   disconnected → initializing → authenticating → connected → syncing → degraded
 *   → reconnecting → ... (retry loop)
 *   → terminal_failure (max attempts exceeded or non-retryable error)
 */
import { CONTROL_RETRY_POLICY, computeRetryDelay, shouldRetry, } from './transport-contract.js';
import { randomUUID } from 'node:crypto';
import { logger } from '../../utils/logger.js';
import { summarizeError } from '../../utils/error-display.js';
/** Default reconnect policy. */
const DEFAULT_RECONNECT_POLICY = Object.freeze({
    ...CONTROL_RETRY_POLICY,
    maxAttempts: 10,
    initialDelayMs: 1_000,
    maxDelayMs: 30_000,
    retryOn: ['network', 'timeout', 'server', 'transient'],
});
/** Default replay configuration. */
const DEFAULT_REPLAY_CONFIG = Object.freeze({
    lastAckedOffset: 0,
    maxReplayCount: 500,
    enabled: true,
});
/**
 * ReconnectEngine — manages the full reconnect lifecycle with backoff and replay.
 *
 * Usage:
 * ```ts
 * const engine = new ReconnectEngine(adapter, identity, replayConfig, callbacks);
 * await engine.connect(); // blocks until connected or terminal failure
 * // After a failure detected externally:
 * await engine.reconnect(); // drives the retry loop
 * engine.dispose(); // cancel any pending reconnect
 * ```
 */
export class ReconnectEngine {
    adapter;
    identity;
    callbacks;
    _policy;
    _replay;
    _handshakeToken;
    _epoch = 0;
    _lastAckedOffset;
    _attempts = 0;
    _disposed = false;
    _pendingTimer = null;
    /** Negotiated protocol from the last successful handshake. */
    _negotiatedProtocol;
    constructor(adapter, identity, replayConfig, callbacks, reconnectPolicy) {
        this.adapter = adapter;
        this.identity = identity;
        this.callbacks = callbacks;
        this._policy = reconnectPolicy
            ? Object.freeze({ ...DEFAULT_RECONNECT_POLICY, ...reconnectPolicy })
            : DEFAULT_RECONNECT_POLICY;
        this._replay = replayConfig
            ? Object.freeze({ ...DEFAULT_REPLAY_CONFIG, ...replayConfig })
            : DEFAULT_REPLAY_CONFIG;
        this._lastAckedOffset = this._replay.lastAckedOffset;
    }
    /** Current handshake token (undefined until first successful connect). */
    get handshakeToken() {
        return this._handshakeToken;
    }
    /** Current server epoch. */
    get epoch() {
        return this._epoch;
    }
    /** Offset of the last acknowledged message. */
    get lastAckedOffset() {
        return this._lastAckedOffset;
    }
    /** Number of reconnect attempts since last successful connect. */
    get attempts() {
        return this._attempts;
    }
    /**
     * The negotiated protocol from the last successful handshake.
     * Undefined until the first successful connect.
     */
    get negotiatedProtocol() {
        return this._negotiatedProtocol;
    }
    /**
     * Update the last acknowledged offset.
     * Call this whenever an ack is sent or a message is durably processed.
     *
     * @param offset - The offset to record as acknowledged.
     */
    ackOffset(offset) {
        if (offset > this._lastAckedOffset) {
            this._lastAckedOffset = offset;
        }
    }
    /**
     * Perform the initial connect attempt.
     *
     * @param authToken - Bearer token from the AuthProvider.
     * @returns True on success, false on terminal failure.
     */
    async connect(authToken) {
        if (this._disposed)
            return false;
        return this._attemptConnect(authToken, false);
    }
    /**
     * Drive the reconnect retry loop.
     *
     * Attempts reconnection up to `policy.maxAttempts` times using
     * exponential backoff with jitter. Calls callbacks at each state transition.
     *
     * @param getAuthToken - Called before each attempt to get a fresh token.
     * @returns True if eventually connected, false on terminal failure.
     */
    async reconnect(getAuthToken) {
        if (this._disposed)
            return false;
        while (this._attempts < this._policy.maxAttempts) {
            if (this._disposed)
                return false;
            this._attempts++;
            const delayMs = computeRetryDelay(this._policy, this._attempts);
            this.callbacks?.onReconnecting?.(this._attempts, this._policy.maxAttempts, delayMs);
            logger.debug('ReconnectEngine: scheduling reconnect attempt', {
                attempt: this._attempts,
                maxAttempts: this._policy.maxAttempts,
                delayMs,
            });
            await this._sleep(delayMs);
            if (this._disposed)
                return false;
            const authToken = await getAuthToken();
            const connected = await this._attemptConnect(authToken, true);
            if (connected) {
                this._attempts = 0;
                return true;
            }
            // If not connected and not retryable, terminal failure was already signalled
        }
        const error = `ReconnectEngine: max reconnect attempts (${this._policy.maxAttempts}) exceeded`;
        logger.error(error);
        this.callbacks?.onTerminalFailure(error);
        return false;
    }
    /** Cancel any pending reconnect timer and mark engine as disposed. */
    dispose() {
        this._disposed = true;
        if (this._pendingTimer !== null) {
            clearTimeout(this._pendingTimer);
            this._pendingTimer = null;
        }
    }
    // ── Private helpers ──────────────────────────────────────────────────────────
    async _attemptConnect(authToken, isReconnect) {
        if (this._disposed)
            return false;
        this.callbacks?.onInitializing?.(this._attempts);
        this.callbacks?.onAuthenticating?.();
        const outcome = await this.adapter.connect(this.identity, this._lastAckedOffset, authToken);
        if (!outcome.success) {
            // Version unsupported is always terminal — never retry
            if (outcome.unsupportedCode) {
                logger.error('ReconnectEngine: unsupported peer version — terminal failure', {
                    error: outcome.error,
                    unsupportedCode: outcome.unsupportedCode,
                });
                if (this._disposed)
                    return false;
                this.callbacks?.onTerminalFailure(outcome.error);
                return false;
            }
            const retryable = outcome.retryable && shouldRetry(this._policy, outcome.category);
            logger.warn('ReconnectEngine: connect attempt failed', {
                error: outcome.error,
                category: outcome.category,
                retryable,
                isReconnect,
            });
            if (!retryable) {
                this.callbacks?.onTerminalFailure(outcome.error);
                return false;
            }
            this.callbacks?.onDisconnected?.(outcome.error, true);
            return false;
        }
        // Success path
        const { token, epoch, replayFromOffset, negotiatedProtocol } = outcome;
        this._epoch = epoch;
        this._handshakeToken = token;
        this._negotiatedProtocol = negotiatedProtocol;
        if (this.callbacks?.onVersionNegotiated) {
            this.callbacks.onVersionNegotiated(negotiatedProtocol);
        }
        this.callbacks?.onConnected(token, epoch);
        // Trigger replay if enabled and the server offers messages to replay
        if (this._replay.enabled && replayFromOffset > 0) {
            this.callbacks?.onSyncing?.();
            try {
                await this.adapter.requestReplay(replayFromOffset, this._replay.maxReplayCount);
            }
            catch (err) {
                logger.warn('ReconnectEngine: replay request failed (degraded mode)', {
                    err: summarizeError(err),
                });
            }
        }
        logger.debug('ReconnectEngine: connected successfully', {
            sessionId: this.identity.sessionId,
            epoch,
            lastAckedOffset: this._lastAckedOffset,
        });
        return true;
    }
    _sleep(ms) {
        return new Promise((resolve) => {
            this._pendingTimer = setTimeout(() => {
                this._pendingTimer = null;
                resolve();
            }, ms);
            this._pendingTimer.unref?.();
        });
    }
}
/**
 * Generate an idempotent submission key for a command.
 *
 * The key combines the session ID with a random UUID so that retried
 * submissions with the same key can be deduplicated by the server.
 *
 * @param sessionId - Durable session ID.
 * @returns A stable key string for use as `idempotencyKey`.
 */
export function generateIdempotencyKey(sessionId) {
    return `${sessionId}:${randomUUID()}`;
}
