/**
 * SSE-backed streaming client for the ShortcutXL `/api/llm/stream` endpoint.
 * Builds the request, parses the event stream, accumulates partial assistant
 * state, and emits runtime stream events to the host.
 */
import { createAssistantMessageEventStream } from '../ai/event-stream.js';
import { networkErrorDetails, ShortcutXLModelStreamEventKind, ShortcutXLModelStreamFetchStage, ShortcutXLModelStreamMessageRole, ShortcutXLModelStreamStopReason, ShortcutXLModelStreamThinkingType } from '../contracts/model-stream.js';
import { resolveShortcutSpeedForContext } from '../fast-mode.js';
import { buildHttpStreamError, computeRetryDelayMs, isRetryableStreamError, ShortcutXLHttpStreamError } from './stream-errors.js';
import { pushShortcutXLModelStreamEvent } from './stream-events.js';
import { createAssistantMessageSnapshot, createInitialAssistantMessage, processStreamEvent } from './stream-message-state.js';
import { readShortcutStream } from './stream-sse.js';
const SHORTCUT_STREAM_MAX_RETRIES = 2;
const SHORTCUT_STREAM_RETRY_BASE_DELAY_MS = 500;
const SHORTCUT_STREAM_RETRY_MAX_DELAY_MS = 2_000;
/**
 * Ceiling for *server-directed* delays (`Retry-After` / `retry_after_ms`).
 * Kept separate from the client backoff cap: that bound exists to limit our
 * own guesses, not to override an explicit hint from a recovering provider.
 * Still capped so a misbehaving server can't pin clients for minutes.
 */
const SHORTCUT_STREAM_RETRY_SERVER_MAX_DELAY_MS = 30_000;
/**
 * Owned AI `thinkingLevel` ↔ proxy's `effort_level`. Exported so the
 * run-stats accumulator can stamp the same effort label on the run-stats
 * entry that the proxy actually used. Keeping a single source of truth
 * prevents drift between what the LLM saw and what the dropdown shows.
 */
export const ShortcutThinkingLevelToEffort = {
    minimal: 'low',
    low: 'low',
    medium: 'medium',
    high: 'high',
    xhigh: 'max'
};
function createShortcutXLAgentBrowserStreamRequestId() {
    if (typeof globalThis.crypto?.randomUUID === 'function') {
        return globalThis.crypto.randomUUID();
    }
    throw new Error('ShortcutXL runtime browser stream requires globalThis.crypto.randomUUID.');
}
function isUuid(value) {
    return (typeof value === 'string' &&
        /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value));
}
function createShortcutXLAgentRequestHeaders(apiKey, clientPlatform, sessionId, request, workspaceContext) {
    return {
        'Content-Type': 'application/json',
        'X-Agent-Runtime': 'shortcutxl_embedded',
        'X-Request-Id': request.requestId,
        ...(request.agentMode ? { 'X-Agent-Mode': request.agentMode } : {}),
        ...(clientPlatform ? { 'X-Client-Platform': clientPlatform } : {}),
        ...(request.interactionMode ? { 'X-Interaction-Mode': request.interactionMode } : {}),
        ...(request.llmOperation ? { 'X-LLM-Operation': request.llmOperation } : {}),
        ...(sessionId ? { 'X-Session-Id': sessionId } : {}),
        ...(workspaceContext?.workspaceId ? { 'X-Workspace-Id': workspaceContext.workspaceId } : {}),
        ...(request.initialFileId ? { 'X-Initial-File-Id': request.initialFileId } : {}),
        ...(request.fileAttachmentIds?.length
            ? { 'X-File-Attachment-Ids': request.fileAttachmentIds.join(',') }
            : {}),
        ...(workspaceContext?.workspaceType
            ? { 'X-Workspace-Type': workspaceContext.workspaceType }
            : {}),
        ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
    };
}
function getShortcutXLModelStreamUrl(baseUrl, streamBaseUrl) {
    const requestBaseUrl = streamBaseUrl ?? baseUrl;
    if (!requestBaseUrl) {
        throw new Error('ShortcutXL runtime stream requires a baseUrl.');
    }
    return `${requestBaseUrl}/api/llm/stream`;
}
/**
 * Sleep `ms`, exiting early on abort. Resolves (rather than rejects) on
 * abort because the *next* fetch attempt sees the aborted signal and throws
 * `AbortError`, which the retry loop already classifies as non-retryable.
 * Keeping sleep non-throwing avoids a second abort code path.
 */
async function sleep(ms, signal) {
    if (ms <= 0)
        return;
    if (signal?.aborted)
        return;
    await new Promise((resolve) => {
        const timeout = setTimeout(resolve, ms);
        signal?.addEventListener('abort', () => {
            clearTimeout(timeout);
            resolve();
        }, { once: true });
    });
}
function convertMessages(context) {
    const messages = [];
    if (context.systemPrompt) {
        messages.push({
            role: ShortcutXLModelStreamMessageRole.System,
            content: context.systemPrompt
        });
    }
    for (const message of context.messages) {
        if (message.role === 'user') {
            messages.push({
                role: ShortcutXLModelStreamMessageRole.Human,
                content: typeof message.content === 'string'
                    ? message.content
                    : message.content
                        .map((item) => item.type === 'text' ? (item.text ?? '') : '')
                        .join('')
            });
            continue;
        }
        if (message.role === 'assistant') {
            let content = '';
            let thinking;
            let thinkingSignature;
            const toolCalls = [];
            for (const item of message.content) {
                if (item.type === 'text') {
                    content += item.text;
                    continue;
                }
                if (item.type === 'thinking') {
                    thinking = item.thinking;
                    thinkingSignature = item.thinkingSignature;
                    continue;
                }
                if (item.type === 'toolCall') {
                    toolCalls.push({
                        id: item.id,
                        name: item.name,
                        arguments: JSON.stringify(item.arguments)
                    });
                }
            }
            messages.push({
                role: ShortcutXLModelStreamMessageRole.Ai,
                content,
                thinking,
                thinkingSignature,
                toolCalls: toolCalls.length === 0 ? undefined : toolCalls
            });
            continue;
        }
        messages.push({
            role: ShortcutXLModelStreamMessageRole.Tool,
            content: message.content
                .map((item) => item.type === 'text' ? (item.text ?? '') : '')
                .join(''),
            toolCallId: message.toolCallId
        });
    }
    return messages;
}
function convertTools(context) {
    if (!context.tools || context.tools.length === 0) {
        return undefined;
    }
    return context.tools.map((tool) => ({
        name: tool.name,
        description: tool.description,
        parameters: tool.parameters
    }));
}
/** Convert owned AI `Context` + model into the ShortcutXL stream request shape. */
export function createShortcutXLModelStreamRequest(options) {
    // `speed` rides on AgentLoopConfig (our extension of SimpleStreamOptions);
    // owned AI's stock options surface doesn't have it. Read defensively as an
    // optional extension field.
    const speed = options.options?.speed;
    const initialFileId = options.options?.initialFileId;
    const userMessageId = options.options?.userMessageId;
    const proxyUserMessageId = isUuid(userMessageId) ? userMessageId : undefined;
    const fileAttachmentIds = options.options?.fileAttachmentIds;
    const metadataOptions = options.options;
    const messages = convertMessages(options.context);
    const tools = convertTools(options.context);
    const resolvedSpeed = resolveShortcutSpeedForContext(options.model.id, speed, options.context);
    return {
        ...(metadataOptions?.agentMode ? { agentMode: metadataOptions.agentMode } : {}),
        effortLevel: options.options?.reasoning
            ? ShortcutThinkingLevelToEffort[options.options.reasoning]
            : undefined,
        ...(fileAttachmentIds && fileAttachmentIds.length > 0 ? { fileAttachmentIds } : {}),
        ...(initialFileId ? { initialFileId } : {}),
        ...(metadataOptions?.interactionMode
            ? { interactionMode: metadataOptions.interactionMode }
            : {}),
        ...(metadataOptions?.llmOperation ? { llmOperation: metadataOptions.llmOperation } : {}),
        maxTokens: options.options?.maxTokens,
        messages,
        model: options.model.id,
        requestId: options.requestId,
        speed: resolvedSpeed,
        thinkingType: options.options?.reasoning
            ? ShortcutXLModelStreamThinkingType.Adaptive
            : ShortcutXLModelStreamThinkingType.Off,
        tools,
        ...(proxyUserMessageId ? { userMessageId: proxyUserMessageId } : {})
    };
}
export function buildShortcutRuntimeRequestBody(request) {
    const proxyUserMessageId = isUuid(request.userMessageId) ? request.userMessageId : undefined;
    return {
        effort_level: request.effortLevel,
        max_tokens: request.maxTokens,
        messages: request.messages.map((message) => ({
            content: message.content,
            role: message.role,
            thinking: message.thinking,
            thinking_signature: message.thinkingSignature,
            tool_call_id: message.toolCallId,
            tool_calls: message.toolCalls?.map((toolCall) => ({
                arguments: toolCall.arguments,
                id: toolCall.id,
                name: toolCall.name
            }))
        })),
        model: request.model,
        ...(request.speed !== undefined ? { speed: request.speed } : {}),
        thinking_type: request.thinkingType,
        ...(proxyUserMessageId ? { user_message_id: proxyUserMessageId } : {}),
        tools: request.tools?.map((tool) => ({
            description: tool.description,
            name: tool.name,
            parameters: tool.parameters
        }))
    };
}
function emitModelRequestTrace(hook, request, body) {
    if (!hook)
        return;
    try {
        hook(request, body);
    }
    catch (error) {
        console.warn('[ShortcutXL] model request trace hook failed:', error instanceof Error ? error.message : String(error));
    }
}
function emitRuntimeStreamEvent(onEvent, event) {
    onEvent(event);
}
function getRequestOrigin(baseUrl) {
    if (!baseUrl)
        return undefined;
    try {
        return new URL(baseUrl).origin;
    }
    catch {
        return undefined;
    }
}
function getUtf8ByteLength(value) {
    if (typeof TextEncoder !== 'undefined') {
        return new TextEncoder().encode(value).byteLength;
    }
    return value.length;
}
/**
 * Drive a single fetch+stream attempt. Returns:
 *  - `success` if the stream completed with a terminal `done`/`error` event,
 *  - `retry` if the attempt failed in a retryable way and another attempt is allowed,
 *  - `failure` with the partial we accumulated so the caller can format the
 *    terminal Error event.
 *
 * Handles the in-attempt 401→refresh→retry-once dance internally; cross-attempt
 * retries are the outer loop's job.
 */
async function runSingleStreamAttempt(deps) {
    const { attempt, maxAttempts, request, body, startedWithBearer, sessionId, workspaceContext, options, fetchImpl, retryBaseDelayMs, retryMaxDelayMs, retryServerMaxDelayMs } = deps;
    let apiKey = deps.apiKey;
    const partial = createInitialAssistantMessage(request.model);
    const toolCallArguments = new Map();
    const thinkingText = new Map();
    let hasEmittedVisibleEvent = false;
    let hasEmittedTerminalEvent = false;
    let startEmitted = false;
    let fetchAnchorAt = Date.now();
    let afterAuthRefresh = false;
    let lastRequestHeaders;
    let streamBytesReceived = 0;
    let streamEventsReceived = 0;
    let firstStreamByteAt;
    let lastStreamByteAt;
    let firstStreamEventAt;
    let lastStreamEventAt;
    const requestOrigin = getRequestOrigin(options.streamBaseUrl ?? options.baseUrl);
    const requestBodyJson = JSON.stringify(body);
    const requestBodyBytes = getUtf8ByteLength(requestBodyJson);
    const emitFetchTrace = (stage, error, status, retry) => {
        if (!options.onFetchTrace)
            return;
        const aborted = options.signal?.aborted === true || (error instanceof Error && error.name === 'AbortError');
        const errorName = error instanceof Error ? error.name : undefined;
        const errorMessage = error instanceof Error ? error.message : error !== undefined ? String(error) : '';
        try {
            options.onFetchTrace({
                stage,
                requestId: request.requestId,
                ...(requestOrigin !== undefined ? { requestOrigin } : {}),
                requestBodyBytes,
                model: request.model,
                ...(request.effortLevel !== undefined ? { effortLevel: request.effortLevel } : {}),
                ...(request.speed !== undefined ? { speed: request.speed } : {}),
                ...(request.thinkingType !== undefined ? { thinkingType: request.thinkingType } : {}),
                messageCount: request.messages.length,
                toolCount: request.tools?.length ?? 0,
                ...(options.clientPlatform !== undefined ? { clientPlatform: options.clientPlatform } : {}),
                fetchContext: 'model_stream_worker',
                ...(lastRequestHeaders !== undefined
                    ? {
                        requestHeaderNames: Object.keys(lastRequestHeaders).sort(),
                        hasAuthorizationHeader: lastRequestHeaders.Authorization !== undefined,
                        hasSessionHeader: lastRequestHeaders['X-Session-Id'] !== undefined,
                        hasWorkspaceHeader: lastRequestHeaders['X-Workspace-Id'] !== undefined
                    }
                    : {}),
                attempt,
                maxAttempts,
                willRetry: retry.willRetry,
                ...(retry.delayMs !== undefined ? { retryDelayMs: retry.delayMs } : {}),
                timeToErrorMs: Date.now() - fetchAnchorAt,
                ...(stage === ShortcutXLModelStreamFetchStage.ResponseBody
                    ? {
                        streamBytesReceived,
                        streamEventsReceived,
                        ...(firstStreamByteAt !== undefined
                            ? { timeToFirstByteMs: firstStreamByteAt - fetchAnchorAt }
                            : {}),
                        ...(firstStreamEventAt !== undefined
                            ? { timeToFirstEventMs: firstStreamEventAt - fetchAnchorAt }
                            : {}),
                        ...(lastStreamByteAt !== undefined
                            ? { timeSinceLastByteMs: Date.now() - lastStreamByteAt }
                            : {}),
                        ...(lastStreamEventAt !== undefined
                            ? { timeSinceLastEventMs: Date.now() - lastStreamEventAt }
                            : {})
                    }
                    : {}),
                ...(errorName !== undefined ? { errorName } : {}),
                errorMessage,
                ...(status !== undefined ? { status } : {}),
                aborted,
                afterAuthRefresh
            });
        }
        catch {
            // Telemetry must never break the stream. Swallow.
        }
    };
    const executeFetch = (token) => {
        const headers = createShortcutXLAgentRequestHeaders(token, options.clientPlatform, sessionId, request, workspaceContext);
        lastRequestHeaders = headers;
        return fetchImpl(getShortcutXLModelStreamUrl(options.baseUrl, options.streamBaseUrl), {
            method: 'POST',
            credentials: 'include',
            headers,
            body: requestBodyJson,
            signal: options.signal
        });
    };
    const classifyFailure = (stage, error, status) => {
        const aborted = options.signal?.aborted === true || (error instanceof Error && error.name === 'AbortError');
        const canRetry = !aborted && !hasEmittedVisibleEvent && attempt < maxAttempts && isRetryableStreamError(error);
        const delayMs = canRetry
            ? computeRetryDelayMs(error, attempt, retryBaseDelayMs, retryMaxDelayMs, retryServerMaxDelayMs)
            : undefined;
        emitFetchTrace(stage, error, status, { willRetry: canRetry, delayMs });
        if (canRetry) {
            return { kind: 'retry', delayMs: delayMs ?? 0, partial };
        }
        return { kind: 'failure', partial, error, stage };
    };
    const eventState = {
        partial,
        toolCallArguments,
        thinkingText,
        emitStartIfNeeded() {
            if (startEmitted)
                return;
            startEmitted = true;
            hasEmittedVisibleEvent = true;
            emitRuntimeStreamEvent(options.onEvent, {
                kind: ShortcutXLModelStreamEventKind.Start,
                partial: createAssistantMessageSnapshot(partial)
            });
        },
        markTerminal() {
            hasEmittedTerminalEvent = true;
        }
    };
    let response;
    try {
        response = await executeFetch(apiKey);
    }
    catch (error) {
        return classifyFailure(ShortcutXLModelStreamFetchStage.BeforeResponse, error, undefined);
    }
    if (response.status === 401 && options.refreshAccessToken && !options.signal?.aborted) {
        await response.text().catch(() => undefined);
        try {
            apiKey = await options.refreshAccessToken();
        }
        catch (error) {
            // A host throwing from refreshAccessToken is a hard auth failure — no
            // point retrying with the same flow. Convert to a non-retryable failure
            // so the outer loop emits a terminal Error rather than an unhandled
            // rejection.
            return {
                kind: 'failure',
                partial,
                error,
                stage: ShortcutXLModelStreamFetchStage.NonOkResponse
            };
        }
        deps.onApiKeyRefreshed(apiKey);
        // Host swallows refresh errors and replies undefined to avoid hanging the
        // worker on AuthTokenResponse. If this request needs a bearer (not a
        // cookie-auth flow) and the refresh returned none, surface a terminal
        // failure so the UI sees a clear sign-in prompt instead of a raw 401.
        if (startedWithBearer && !apiKey) {
            return {
                kind: 'failure',
                partial,
                error: new Error('ShortcutXL stream: authentication refresh did not return a token — please sign in again.'),
                stage: ShortcutXLModelStreamFetchStage.NonOkResponse
            };
        }
        afterAuthRefresh = true;
        try {
            response = await executeFetch(apiKey);
        }
        catch (error) {
            return classifyFailure(ShortcutXLModelStreamFetchStage.BeforeResponse, error, undefined);
        }
    }
    if (!response.ok) {
        let httpError;
        try {
            httpError = await buildHttpStreamError(response);
        }
        catch (error) {
            // Body read failed (network reset mid-error-body, etc.). Treat as a
            // non-OK response with an unknown body so we still classify, emit the
            // trace, and avoid an unhandled rejection.
            return classifyFailure(ShortcutXLModelStreamFetchStage.NonOkResponse, error, response.status);
        }
        return classifyFailure(ShortcutXLModelStreamFetchStage.NonOkResponse, httpError, response.status);
    }
    // Re-anchor so `response_body` traces report time spent *reading*, not the
    // (already-recorded) time spent connecting.
    fetchAnchorAt = Date.now();
    try {
        await readShortcutStream(response, (event) => processStreamEvent(event, eventState, (streamEvent) => emitRuntimeStreamEvent(options.onEvent, streamEvent), () => !hasEmittedVisibleEvent && attempt < maxAttempts), options.streamIdleTimeoutMs, {
            onBytes(length) {
                const now = Date.now();
                streamBytesReceived += length;
                firstStreamByteAt ??= now;
                lastStreamByteAt = now;
            },
            onEvent() {
                const now = Date.now();
                streamEventsReceived += 1;
                firstStreamEventAt ??= now;
                lastStreamEventAt = now;
            }
        });
        if (!hasEmittedTerminalEvent) {
            throw new Error('ShortcutXL runtime stream closed before done.');
        }
    }
    catch (error) {
        return classifyFailure(ShortcutXLModelStreamFetchStage.ResponseBody, error, undefined);
    }
    return { kind: 'success' };
}
/**
 * Drive one stream request to completion. Emits Start/Delta/.../Done|Error
 * events through `onEvent`. Always emits a terminal Error event on abort or
 * failure so consumers never need to time out.
 */
export async function runShortcutXLModelStreamRequest(request, options) {
    const fetchImpl = options.fetchImpl ?? fetch;
    const body = buildShortcutRuntimeRequestBody(request);
    const maxRetries = Math.max(0, options.maxStreamRetries ?? SHORTCUT_STREAM_MAX_RETRIES);
    const maxAttempts = maxRetries + 1;
    const retryBaseDelayMs = options.streamRetryBaseDelayMs ?? SHORTCUT_STREAM_RETRY_BASE_DELAY_MS;
    const retryMaxDelayMs = options.streamRetryMaxDelayMs ?? SHORTCUT_STREAM_RETRY_MAX_DELAY_MS;
    const retryServerMaxDelayMs = options.streamRetryServerMaxDelayMs ?? SHORTCUT_STREAM_RETRY_SERVER_MAX_DELAY_MS;
    emitModelRequestTrace(options.onRequest, request, body);
    // `outcome.partial` from the last attempt; used to format the terminal Error
    // event if every attempt fails. Carries whatever content the failing attempt
    // accumulated before failing (typically empty for transport errors, but may
    // contain a chunk of text before a peer-closed mid-stream).
    let lastAttemptPartial = createInitialAssistantMessage(request.model);
    let lastError;
    let lastFailureStage;
    try {
        const sessionId = options.getSessionId?.();
        const workspaceContext = options.getWorkspaceContext?.();
        let apiKey = options.apiKey ?? (await options.getAccessToken?.());
        // Track whether this request started authenticated by a bearer token (vs.
        // cookie auth, where apiKey is intentionally undefined). On a 401 retry
        // we use this to distinguish "refresh returned no token" (real auth
        // failure for bearer flows) from "no bearer needed" (cookie flow).
        const startedWithBearer = apiKey !== undefined;
        for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
            const outcome = await runSingleStreamAttempt({
                attempt,
                maxAttempts,
                request,
                body,
                apiKey,
                startedWithBearer,
                sessionId,
                workspaceContext,
                options,
                fetchImpl,
                retryBaseDelayMs,
                retryMaxDelayMs,
                retryServerMaxDelayMs,
                onApiKeyRefreshed: (refreshed) => {
                    apiKey = refreshed;
                }
            });
            if (outcome.kind === 'success')
                return;
            lastAttemptPartial = outcome.partial;
            if (outcome.kind === 'failure') {
                lastError = outcome.error;
                lastFailureStage = outcome.stage;
                break;
            }
            await sleep(outcome.delayMs, options.signal);
        }
    }
    catch (error) {
        // Belt-and-suspenders: catch anything the attempt loop didn't already
        // convert into a `failure` outcome — host hooks (`getAccessToken`,
        // `getSessionId`, `getWorkspaceContext`, `refreshAccessToken`) that
        // throw, `buildHttpStreamError` reading a poisoned response body, or
        // anything else that escapes. Without this, the caller (which fires
        // `runShortcutXLModelStreamRequest` as a void promise) would see an
        // unhandled rejection and stream consumers would wait forever.
        lastError = error;
    }
    // Format terminal Error event from the last attempt's partial. Treat both
    // an externally aborted signal and a thrown AbortError as a user
    // cancellation rather than a stream error so the caller can react.
    const aborted = options.signal?.aborted === true ||
        (lastError instanceof Error && lastError.name === 'AbortError');
    lastAttemptPartial.stopReason = aborted
        ? ShortcutXLModelStreamStopReason.Aborted
        : ShortcutXLModelStreamStopReason.Error;
    lastAttemptPartial.errorMessage =
        lastError instanceof Error ? lastError.message : String(lastError);
    if (lastError instanceof ShortcutXLHttpStreamError && lastError.details) {
        lastAttemptPartial.errorDetails = lastError.details;
    }
    else if (!aborted) {
        // The fetch itself rejected before any HTTP response (DNS failure,
        // offline, captive portal, WKWebView "Load failed", etc.) or the SSE
        // reader hit the idle timeout. Mark it network/retryable so the
        // structured classifier picks server_error regardless of which
        // platform-specific message string the WebView happens to surface.
        lastAttemptPartial.errorDetails = networkErrorDetails(lastFailureStage);
    }
    emitRuntimeStreamEvent(options.onEvent, {
        kind: ShortcutXLModelStreamEventKind.Error,
        error: createAssistantMessageSnapshot(lastAttemptPartial),
        partial: createAssistantMessageSnapshot(lastAttemptPartial),
        reason: aborted
            ? ShortcutXLModelStreamStopReason.Aborted
            : ShortcutXLModelStreamStopReason.Error
    });
}
/**
 * Build a owned AI-compatible `streamFn` bound to the given host. The returned
 * function fires off one request per call and pushes events into a fresh
 * assistant-message event stream.
 */
export function createShortcutXLModelStream(hostOptions = {}) {
    return function shortcutBrowserStream(model, context, options) {
        const stream = createAssistantMessageEventStream();
        const request = createShortcutXLModelStreamRequest({
            context,
            model,
            options,
            requestId: createShortcutXLAgentBrowserStreamRequestId()
        });
        void runShortcutXLModelStreamRequest(request, {
            ...hostOptions,
            apiKey: options?.apiKey,
            onEvent(event) {
                pushShortcutXLModelStreamEvent(stream, event);
            },
            signal: options?.signal
        });
        return stream;
    };
}
//# sourceMappingURL=stream.js.map