/**
 * Multi-strategy error recovery system.
 *
 * Replaces the single-strategy SessionRetry with per-category error
 * classification and distinct recovery strategies. Different errors
 * get fundamentally different treatment:
 *
 * - rate_limit: long exponential backoff (5s base)
 * - overloaded: shorter backoff with jitter, optional model fallback
 * - server_error: exponential backoff with jitter (1s base, 5 attempts, ~24s total)
 * - context_overflow: escalating compaction (standard → aggressive → surface)
 * - auth_error: no retry, surface immediately
 * - tool_error: not retried at session level (model handles via error tool_results)
 * - unknown: conservative single retry
 *
 * The sync/async split from SessionRetry is preserved:
 * classifyError + promise creation happens synchronously in _handleAgentLoopEvent,
 * strategy dispatch happens asynchronously in the queued _processAgentLoopEvent.
 */
import { sleep } from '../../utils/sleep.js';
/** Accessor for the runtime-only `errorDetails` field on a owned AI assistant message. */
export function getErrorDetails(message) {
    return message.errorDetails;
}
// ============================================================================
// Error Classification
// ============================================================================
// Auth errors — surface immediately, no retry
const AUTH_ERROR_PATTERN = /\b401\b|invalid.?api.?key|unauthorized|authentication.?failed/i;
// Rate limits — long backoff
const RATE_LIMIT_PATTERN = /rate.?limit|too many requests|\b429\b/i;
// Overloaded — shorter backoff with optional fallback
const OVERLOADED_PATTERN = /overloaded|\b529\b/i;
// Server errors — quick retry
const SERVER_ERROR_PATTERN = /\b500\b|\b502\b|\b503\b|\b504\b|\b524\b|service.?unavailable|server error|internal error/i;
// Connection errors — treated as server errors (transient).
// Includes browser/WebView fetch-rejection strings so that the embedded
// runtime (Excel WKWebView, etc.) doesn't fall through to `unknown` and
// get the weakest retry strategy when the fetch itself rejects before
// reaching the backend.
//   - Safari/WebKit:  "Load failed", "The Internet connection appears to be offline"
//   - Firefox:        "NetworkError when attempting to fetch resource"
//   - Chrome/Node:    "Failed to fetch", "network error", and the
//                     already-listed connection/reset strings
const CONNECTION_ERROR_PATTERN = /connection.?error|connection.?refused|other side closed|fetch failed|failed to fetch|upstream.?connect|reset before headers|terminated|retry delay|peer closed|complete message body|load failed|network.?error|internet connection appears to be offline/i;
function classifyStructuredError(details) {
    if (!details)
        return null;
    if (details.providerErrorType === 'unauthorized' || details.status === 401)
        return 'auth_error';
    if (details.providerErrorType === 'rate_limit' || details.status === 429)
        return 'rate_limit';
    if (details.providerErrorType === 'overloaded' || details.status === 529)
        return 'overloaded';
    if (details.providerErrorType === 'network' ||
        (details.retryable === true &&
            details.status !== undefined &&
            [500, 502, 503, 504, 524].includes(details.status))) {
        return 'server_error';
    }
    if (details.retryable === false || details.providerErrorType === 'invalid_request') {
        return 'terminal';
    }
    return null;
}
/**
 * Classify an error into a recovery category.
 *
 * MUST remain synchronous — called from the sync _handleAgentLoopEvent handler
 * before async queue processing, to ensure the recovery promise exists
 * before waitForRetry() is called.
 *
 * @returns ErrorCategory if error is classifiable, null if not an error
 */
export function classifyError(message, isContextOverflow) {
    if (message.stopReason !== 'error')
        return null;
    if (!message.errorMessage)
        return null;
    // Context overflow has highest priority — exclusive path
    if (isContextOverflow(message))
        return 'context_overflow';
    const structuredCategory = classifyStructuredError(getErrorDetails(message));
    if (structuredCategory === 'terminal') {
        return null;
    }
    if (structuredCategory)
        return structuredCategory;
    const err = message.errorMessage;
    // Auth errors — must check before server errors (401 overlaps)
    if (AUTH_ERROR_PATTERN.test(err))
        return 'auth_error';
    // Rate limits
    if (RATE_LIMIT_PATTERN.test(err))
        return 'rate_limit';
    // Overloaded
    if (OVERLOADED_PATTERN.test(err))
        return 'overloaded';
    // Server errors
    if (SERVER_ERROR_PATTERN.test(err))
        return 'server_error';
    // Connection errors → server_error (transient infrastructure)
    if (CONNECTION_ERROR_PATTERN.test(err))
        return 'server_error';
    return 'unknown';
}
/**
 * Server-error retry budget. 5 attempts × exponential backoff (1s, 2s, 4s,
 * 8s, 10s capped) with ±25% jitter rides out the 5–30s WKWebView fetch flakes
 * ("Load failed", connection resets) that were killing real Excel runs on
 * staging with the previous 2-attempt strategy. Exported so tests can assert
 * against the same source of truth.
 */
export const SERVER_ERROR_MAX_ATTEMPTS = 5;
const STRATEGY_CONFIGS = {
    rate_limit: {
        maxAttempts: 3,
        baseDelayMs: 5000,
        maxDelayMs: 60000,
        jitter: false,
        supportsFallback: false
    },
    overloaded: {
        maxAttempts: 3,
        baseDelayMs: 2000,
        maxDelayMs: 30000,
        jitter: true,
        supportsFallback: true
    },
    server_error: {
        maxAttempts: SERVER_ERROR_MAX_ATTEMPTS,
        baseDelayMs: 1000,
        maxDelayMs: 10000,
        jitter: true,
        supportsFallback: false
    },
    context_overflow: {
        maxAttempts: 3,
        baseDelayMs: 0, // No delay — compaction is the recovery, not waiting
        maxDelayMs: 0,
        jitter: false,
        supportsFallback: false
    },
    auth_error: {
        maxAttempts: 0,
        baseDelayMs: 0,
        maxDelayMs: 0,
        jitter: false,
        supportsFallback: false,
        userMessage: 'Authentication failed. Run /login to re-authenticate.'
    },
    tool_error: {
        maxAttempts: 0,
        baseDelayMs: 0,
        maxDelayMs: 0,
        jitter: false,
        supportsFallback: false
    },
    max_output_tokens: {
        maxAttempts: 0,
        baseDelayMs: 0,
        maxDelayMs: 0,
        jitter: false,
        supportsFallback: false
    },
    unknown: {
        maxAttempts: 1,
        baseDelayMs: 2000,
        maxDelayMs: 10000,
        jitter: false,
        supportsFallback: false
    }
};
/** Compute delay for a given attempt using the strategy config. */
export function computeDelay(config, attempt) {
    if (config.baseDelayMs === 0)
        return 0;
    const base = Math.min(config.baseDelayMs * 2 ** (attempt - 1), config.maxDelayMs);
    if (!config.jitter)
        return base;
    // Add ±25% jitter to prevent thundering herd
    const jitterRange = base * 0.25;
    return Math.round(base + (Math.random() * 2 - 1) * jitterRange);
}
/**
 * Escalating compaction keepRecentTokens by attempt.
 * Attempt 1: standard (20000), Attempt 2: aggressive (10000)
 */
const COMPACTION_ESCALATION = [20000, 10000];
/** Exported for tests */
export function getCompactionKeepRecentTokens(attempt) {
    return COMPACTION_ESCALATION[Math.min(attempt - 1, COMPACTION_ESCALATION.length - 1)];
}
/** Get the strategy config for a category. Exported for tests. */
export function getStrategyConfig(category) {
    return STRATEGY_CONFIGS[category];
}
// ============================================================================
// Error Recovery State Machine
// ============================================================================
export class SessionErrorRecovery {
    _abortController = undefined;
    _attempt = 0;
    _category = null;
    _promise = undefined;
    _resolve = undefined;
    _listener = undefined;
    _sleep;
    constructor(options = {}) {
        this._sleep = options.sleep ?? sleep;
    }
    get attempt() {
        return this._attempt;
    }
    get category() {
        return this._category;
    }
    get isRetrying() {
        return this._promise !== undefined;
    }
    onEvent(listener) {
        this._listener = listener;
    }
    _emit(event) {
        this._listener?.(event);
    }
    async _continueRecovery(category, attempt, actions) {
        try {
            // Keep the recovery promise armed until the continuation turn emits its
            // own terminal agent_end. Otherwise callers can submit into a still-live
            // retry and trip the "previous turn is still streaming" guard.
            //
            // Contract: `actions.continue` is wired in compose.ts to `agent.continue()`,
            // which resolves only after the next turn reaches agent_end (or rejects on
            // failure). See `createEmbeddedRecoveryWiring` callsite in
            // `embedded-agent/compose.ts`.
            await actions.continue();
            return true;
        }
        catch (error) {
            this._emit({
                type: 'recovery_end',
                category,
                success: false,
                attempt,
                finalError: error instanceof Error ? error.message : String(error)
            });
            this._reset();
            return false;
        }
    }
    /** Reset all state to idle. */
    _reset() {
        this._attempt = 0;
        this._category = null;
        this._resolve?.();
        this._resolve = undefined;
        this._promise = undefined;
    }
    /**
     * Create the recovery promise synchronously when an agent_end event arrives.
     *
     * This MUST be called synchronously from the agent event handler, before
     * any async processing. The reason: agent.prompt() resolves as soon as
     * agent_end fires, and the caller immediately calls wait().
     * If the promise isn't created yet, wait() returns immediately
     * and the caller proceeds before the recovery completes.
     *
     * @returns the classified ErrorCategory if a recovery promise was created, null otherwise
     */
    prepareForAgentEnd(lastAssistant, isContextOverflow, settings) {
        if (this._promise)
            return null;
        if (!settings.enabled)
            return null;
        if (!lastAssistant)
            return null;
        const category = classifyError(lastAssistant, isContextOverflow);
        if (!category)
            return null;
        const config = STRATEGY_CONFIGS[category];
        if (config.maxAttempts === 0)
            return null; // No retry for this category
        this._promise = new Promise((resolve) => {
            this._resolve = resolve;
        });
        return category;
    }
    /**
     * Handle a classified error. Called from the async agent_end processing.
     * Dispatches to the appropriate per-category strategy.
     *
     * @returns true if recovery was initiated (retry or fallback), false if exhausted
     */
    async handleError(message, category, settings, actions) {
        if (!settings.enabled) {
            this._reset();
            return false;
        }
        const config = STRATEGY_CONFIGS[category];
        // No retry for non-retryable categories
        if (config.maxAttempts === 0) {
            this._reset();
            return false;
        }
        // Defensive fallback: create promise if prepareForAgentEnd wasn't called.
        if (!this._promise) {
            this._promise = new Promise((resolve) => {
                this._resolve = resolve;
            });
        }
        this._category = category;
        this._attempt++;
        if (this._attempt > config.maxAttempts) {
            // Retries exhausted — try model fallback for supported categories
            if (config.supportsFallback &&
                settings.modelFallback?.enabled &&
                settings.modelFallback.fallbackModel) {
                return this._attemptModelFallback(message, category, settings.modelFallback, actions);
            }
            this._emit({
                type: 'recovery_end',
                category,
                success: false,
                attempt: this._attempt - 1,
                finalError: message.errorMessage
            });
            this._reset();
            return false;
        }
        const delayMs = computeDelay(config, this._attempt);
        this._emit({
            type: 'recovery_start',
            category,
            attempt: this._attempt,
            maxAttempts: config.maxAttempts,
            delayMs,
            errorMessage: message.errorMessage || 'Unknown error'
        });
        // Category-specific pre-retry actions
        if (category === 'context_overflow') {
            return this._handleContextOverflow(message, actions);
        }
        // Standard retry: remove error, sleep, continue
        actions.removeLastAssistantMessage();
        if (delayMs > 0) {
            this._abortController = new AbortController();
            try {
                await this._sleep(delayMs, this._abortController.signal);
            }
            catch {
                return this._handleAbort(category);
            }
            this._abortController = undefined;
        }
        return this._continueRecovery(category, this._attempt, actions);
    }
    /**
     * Handle context overflow with escalating compaction.
     * Attempt 1: standard compaction (keep 20k tokens)
     * Attempt 2: aggressive compaction (keep 10k tokens)
     * Attempt 3+: surface error to user
     */
    async _handleContextOverflow(_message, actions) {
        const keepRecentTokens = getCompactionKeepRecentTokens(this._attempt);
        try {
            const success = await actions.runCompaction(keepRecentTokens);
            if (!success) {
                this._emit({
                    type: 'recovery_end',
                    category: 'context_overflow',
                    success: false,
                    attempt: this._attempt,
                    finalError: 'Compaction failed'
                });
                this._reset();
                return false;
            }
        }
        catch {
            this._emit({
                type: 'recovery_end',
                category: 'context_overflow',
                success: false,
                attempt: this._attempt,
                finalError: 'Compaction threw an error'
            });
            this._reset();
            return false;
        }
        // Compaction succeeded — continue agent.
        // Note: runCompaction already removes the error assistant message internally,
        // so we do NOT call removeLastAssistantMessage() here (double removal would
        // delete a legitimate prior assistant message).
        return this._continueRecovery('context_overflow', this._attempt, actions);
    }
    /**
     * Attempt model fallback after retries are exhausted.
     */
    async _attemptModelFallback(_message, category, fallback, actions) {
        this._emit({
            type: 'recovery_fallback',
            category,
            fallbackModel: fallback.fallbackModel
        });
        try {
            const switched = await actions.switchToFallbackModel(fallback.fallbackModel, fallback.stripThinking);
            if (!switched) {
                this._emit({
                    type: 'recovery_end',
                    category,
                    success: false,
                    attempt: this._attempt - 1,
                    finalError: `Model fallback to ${fallback.fallbackModel} failed`
                });
                this._reset();
                return false;
            }
        }
        catch {
            this._emit({
                type: 'recovery_end',
                category,
                success: false,
                attempt: this._attempt - 1,
                finalError: `Model fallback to ${fallback.fallbackModel} threw an error`
            });
            this._reset();
            return false;
        }
        // Fallback succeeded — retry with new model
        actions.removeLastAssistantMessage();
        return this._continueRecovery(category, this._attempt - 1, actions);
    }
    /** Handle abort during sleep. */
    _handleAbort(category) {
        const attempt = this._attempt;
        this._abortController = undefined;
        this._emit({
            type: 'recovery_end',
            category,
            success: false,
            attempt,
            finalError: 'Recovery cancelled'
        });
        this._reset();
        return false;
    }
    /**
     * Called when a successful assistant response arrives mid-recovery.
     * Resets state and resolves the promise.
     */
    onSuccess() {
        if (this._attempt <= 0)
            return;
        const category = this._category ?? 'unknown';
        this._emit({
            type: 'recovery_end',
            category,
            success: true,
            attempt: this._attempt
        });
        this._reset();
    }
    /** Cancel in-progress recovery. */
    abort() {
        this._abortController?.abort();
        this._reset();
    }
    /** Wait for any in-progress recovery to complete. */
    async wait() {
        if (this._promise) {
            await this._promise;
        }
    }
}
//# sourceMappingURL=session-error-recovery.js.map