import { getErrorMeta } from "../../../util/logger.js";
import { LLMProviderError } from "../error.js";
import { AbortedError, RetriesExhaustedError } from "./errors.js";
import { createManagedStream } from "./managed-stream.js";
import { RetryEngine } from "./retry-engine.js";
import { StreamSession } from "./session.js";
/**
 * The main coordinator for stream operations.
 *
 * Brings together all components:
 * - StreamSession for state management
 * - RetryEngine for retry/fallback decisions
 * - ManagedStream for lifecycle tracking
 * - Observers for side effects
 *
 * @example
 * ```typescript
 * const orchestrator = new StreamOrchestrator({
 *   provider: createVercelProvider(),
 *   config: StreamConfig.create({ provider: "vertex", model: "claude-sonnet-4-5", retry: { maxAttempts: 3 } }),
 *   observers: [new LoggingObserver()],
 *   logger: getLogger(),
 * });
 *
 * const result = await orchestrator.execute({
 *   model,
 *   messages,
 *   tools,
 * });
 *
 * for await (const chunk of result.fullStream) {
 *   console.log(chunk);
 * }
 * ```
 */
export class StreamOrchestrator {
    provider;
    config;
    observers;
    logger;
    organizationId;
    applicationId;
    getJwt;
    constructor(options) {
        this.provider = options.provider;
        this.config = options.config;
        this.observers = options.observers ?? [];
        this.logger = options.logger;
        this.organizationId = options.organizationId;
        this.applicationId = options.applicationId;
        this.getJwt = options.getJwt;
    }
    async execute(options) {
        if (options.abortSignal?.aborted) {
            throw new Error("aborted");
        }
        const retryEngine = new RetryEngine(this.config);
        const initialProvider = retryEngine.getInitialProvider();
        const session = new StreamSession({
            config: this.config,
            initialProvider,
            organizationId: this.organizationId,
            applicationId: this.applicationId,
            getJwt: this.getJwt,
        });
        const internalAbortController = new AbortController();
        if (options.abortSignal) {
            options.abortSignal.addEventListener("abort", () => {
                internalAbortController.abort();
            });
        }
        const optionsWithAbort = {
            ...options,
            abortSignal: internalAbortController.signal,
        };
        this.logger.info(`[orchestrator] Starting session ${session.id} with provider=${initialProvider.name} model=${initialProvider.model}`);
        for (const observer of this.observers) {
            observer.attach(session.events, this.config);
        }
        await session.events.emit("session:start", session, {
            system: options.system,
            messages: options.messages ?? [],
            tools: options.tools,
        });
        internalAbortController.signal.addEventListener("abort", () => {
            void session.events.emit("session:abort", session);
        });
        const result = await this.startStreamWithRetry(optionsWithAbort, session, retryEngine);
        // wrapper order matters here. managed stream throws errors (error chunks), retry wrapper catches them.
        const managedStream = createManagedStream(result.fullStream, session);
        const retryableFullStream = this.wrapWithRetry(managedStream, (r) => createManagedStream(r.fullStream, session), optionsWithAbort, session, retryEngine, internalAbortController);
        return {
            ...result,
            fullStream: retryableFullStream,
            textStream: result.textStream,
        };
    }
    /**
     * Wraps a fullStream with retry logic. When an error occurs (either thrown or
     * as an error chunk), makes a retry decision and either creates a new stream
     * or throws the final error. It catches errors from the managed stream (inner)
     * and decides whether to retry or give up. Session finalization happens here,
     * not in the managed stream. If you add finalization to managed-stream.ts,
     * retries will silently fail (the new stream will close immediately because
     * session.isPending() returns false).
     */
    wrapWithRetry(initialStream, getNewStream, options, session, retryEngine, abortController) {
        const handleRetry = this.handleRetryDecision.bind(this);
        let currentStream = initialStream;
        async function* createRetryIterator() {
            while (true) {
                try {
                    for await (const chunk of currentStream) {
                        // throw for the retry logic to take over
                        if (chunk.type === "error") {
                            throw chunk.error;
                        }
                        yield chunk;
                    }
                    return;
                }
                catch (error) {
                    const retryResult = await handleRetry(error, options, session, retryEngine);
                    if (!retryResult.shouldRetry) {
                        // abort the internal signal so any in-flight stream processing stops.
                        // without this, tools keep getting called in the background since the stream is not closed.
                        abortController.abort();
                        throw retryResult.error;
                    }
                    currentStream = getNewStream(retryResult.newResult);
                }
            }
        }
        const sharedIterator = createRetryIterator();
        const readable = new ReadableStream({
            async pull(controller) {
                try {
                    const result = await sharedIterator.next();
                    if (result.done) {
                        controller.close();
                    }
                    else {
                        controller.enqueue(result.value);
                    }
                }
                catch (error) {
                    controller.error(error);
                }
            },
            async cancel() {
                await sharedIterator.return?.(undefined);
            },
        });
        return readable;
    }
    /**
     * Handle an error by making a retry decision. Returns either a new result to continue
     * with, or the final error to throw.
     */
    async handleRetryDecision(error, options, session, retryEngine) {
        const decision = retryEngine.getRetryDecision(error, session, options.abortSignal);
        if (!decision.shouldRetry) {
            this.logger.warn(`[orchestrator] Not retrying: reason=${decision.reason}`, getErrorMeta(error));
            session.finalize("error", error);
            return {
                shouldRetry: false,
                error: this.wrapFinalError(error, decision.reason ?? "unknown", session),
            };
        }
        await session.events.emit("retry", error, session.attempt, decision.delayMs, session);
        if (decision.newProvider) {
            const oldProvider = session.provider;
            session.provider = decision.newProvider;
            this.logger.warn(`[orchestrator] Switching provider: ${oldProvider.name}/${oldProvider.model} -> ${decision.newProvider.name}/${decision.newProvider.model}`, getErrorMeta(error));
            await session.events.emit("fallback", oldProvider, decision.newProvider, this.getFallbackReason(error), session);
        }
        session.recordRetry(decision.delayMs);
        this.logger.warn(`[orchestrator] Retrying in ${decision.delayMs}ms (attempt ${session.attempt})`, getErrorMeta(error));
        await this.delay(decision.delayMs, options.abortSignal);
        const newResult = await this.startStreamWithRetry(options, session, retryEngine);
        return { shouldRetry: true, newResult };
    }
    async startStreamWithRetry(options, session, retryEngine) {
        while (true) {
            try {
                return await this.startStream(options, session);
            }
            catch (error) {
                const decision = retryEngine.getRetryDecision(error, session, options.abortSignal);
                if (!decision.shouldRetry) {
                    this.logger.error(`[orchestrator] Startup error, not retrying: reason=${decision.reason}`, {
                        error: {
                            kind: error instanceof Error ? error.name : "unknown",
                            message: error instanceof Error ? error.message : String(error),
                            stack: error instanceof Error ? error.stack : undefined,
                        },
                    });
                    await session.events.emit("session:error", error, session);
                    throw this.wrapFinalError(error, decision.reason ?? "unknown", session);
                }
                await session.events.emit("retry", error, session.attempt, decision.delayMs, session);
                if (decision.newProvider) {
                    const oldProvider = session.provider;
                    session.provider = decision.newProvider;
                    this.logger.warn(`[orchestrator] Switching provider: ${oldProvider.name}/${oldProvider.model} -> ${decision.newProvider.name}/${decision.newProvider.model}`, getErrorMeta(error));
                    await session.events.emit("fallback", oldProvider, decision.newProvider, this.getFallbackReason(error), session);
                }
                session.recordRetry(decision.delayMs);
                this.logger.info(`[orchestrator] Startup retry in ${decision.delayMs}ms (attempt ${session.attempt})`);
                await this.delay(decision.delayMs, options.abortSignal);
            }
        }
    }
    // todo(@omar): this needs to use the wrapError helper
    wrapFinalError(error, reason, session) {
        if (reason === "aborted") {
            return new AbortedError();
        }
        if (reason === "max-attempts" || reason === "max-time") {
            return new RetriesExhaustedError(session.attempt, session.getElapsedMs(), error);
        }
        return new LLMProviderError({
            provider: session.provider.name,
            message: error instanceof Error
                ? (error.message ?? "All retries exhausted")
                : "All retries exhausted",
            type: "retries_exhausted",
            isRetryable: false,
            cause: error,
        });
    }
    async startStream(options, session) {
        if (options.abortSignal?.aborted) {
            throw new AbortedError();
        }
        const providerOptions = this.applyProvider(options, session.provider, session);
        this.logger.debug(`[orchestrator] Attempt ${session.attempt} with provider=${session.provider.name} model=${session.provider.model}`);
        return this.provider.streamText({
            ...providerOptions,
            prepareStep: async (step) => {
                const preparedStep = (await options.prepareStep?.(step)) ?? step;
                await session.events.emit("step:start", {
                    stepNumber: preparedStep.stepNumber,
                    activeTools: preparedStep.activeTools,
                }, session);
                return preparedStep;
            },
            onStepFinish: async (step) => {
                await session.events.emit("step:finish", step, session);
                await options.onStepFinish?.(step);
            },
            onFinish: async (finalResult) => {
                session.setTotalTokens(finalResult.totalUsage?.totalTokens ?? 0);
                await session.events.emit("stream:finish", finalResult, session);
                return options.onFinish?.(finalResult);
            },
            onChunk: options.onChunk,
        });
    }
    applyProvider(options, provider, session) {
        const baseOptions = this.config.fallback.enabled
            ? {
                ...options,
                providerOptions: {
                    ...options.providerOptions,
                    unified: {
                        ...(options.providerOptions?.unified ?? {}),
                        forceProvider: provider.name,
                        coreModel: provider.model,
                    },
                },
            }
            : options;
        // Add billing metadata for retry tracking
        return {
            ...baseOptions,
            providerOptions: {
                ...baseOptions.providerOptions,
                billing: {
                    retry_count: session.metrics.retryCount,
                    workflow_failed: session.outcome === "error",
                },
            },
        };
    }
    getFallbackReason(error) {
        if (error && typeof error === "object" && "type" in error) {
            const errorType = error.type;
            if (errorType === "no_output")
                return "no_output";
        }
        return "error";
    }
    delay(ms, abortSignal) {
        return new Promise((resolve) => {
            if (abortSignal?.aborted) {
                resolve();
                return;
            }
            const timeoutId = setTimeout(resolve, ms);
            abortSignal?.addEventListener("abort", () => {
                clearTimeout(timeoutId);
                resolve();
            }, { once: true });
        });
    }
}
//# sourceMappingURL=orchestrator.js.map