// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

import {
    createToolCallError,
    ImageContent,
    LanguageModelMessage,
    LanguageModelResponse,
    LanguageModelStreamResponsePart,
    TextMessage,
    ToolCallResult,
    ToolInvocationContext,
    ToolRequest,
    UserRequest
} from '@theia/ai-core';
import { CancellationToken, nls, unreachable } from '@theia/core';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { injectable } from '@theia/core/shared/inversify';
import { OpenAI } from 'openai';
import type { RunnerOptions } from 'openai/lib/AbstractChatCompletionRunner';
import type {
    FunctionTool,
    Tool,
    ResponseFunctionCallArgumentsDeltaEvent,
    ResponseFunctionCallArgumentsDoneEvent,
    ResponseFunctionToolCall,
    ResponseFunctionWebSearch,
    ResponseInputItem,
    ResponseStreamEvent,
    ResponseToolSearchCall,
    ResponseToolSearchOutputItem
} from 'openai/resources/responses/responses';
import type { ResponsesModel } from 'openai/resources/shared';
import { DeveloperMessageSettings, OpenAiModelUtils } from './openai-language-model';
import { OPENAI_WEB_SEARCH, OPENAI_WEB_SEARCH_REPLAY_DATA_KEY } from './openai-server-tools';

export const OPENAI_FUNCTION_CALL_REASONING_DATA_KEY = 'openAiFunctionCallReasoning';

/**
 * User-facing name under which the provider's built-in deferred-tool search is surfaced in the chat UI.
 * Matches the native Response API tool type (`tool_search`), which OpenAI executes on its own infrastructure.
 */
export const OPENAI_TOOL_SEARCH = 'tool_search';

function openAiToolSearchFoundText(found: number): string {
    return found === 1
        ? nls.localize('theia/ai/openai/toolSearch/foundOne', 'Found 1 tool.')
        : nls.localize('theia/ai/openai/toolSearch/found', 'Found {0} tools.', found);
}

interface ToolCall {
    id: string;
    call_id?: string;
    name: string;
    arguments: string;
    result?: ToolCallResult;
    error?: Error;
    executed: boolean;
    reasoningItems?: ResponseInputItem[];
}

/**
 * Utility class for handling OpenAI Response API requests and tool calling cycles.
 *
 * This class encapsulates the complexity of the Response API's multi-turn conversation
 * patterns for tool calling, keeping the main language model class clean and focused.
 */
@injectable()
export class OpenAiResponseApiUtils {

    /**
     * Handles Response API requests with proper tool calling cycles.
     * Works for both streaming and non-streaming cases.
     */
    async handleRequest(
        openai: OpenAI,
        request: UserRequest,
        settings: Record<string, unknown>,
        model: string,
        modelUtils: OpenAiModelUtils,
        developerMessageSettings: DeveloperMessageSettings,
        runnerOptions: RunnerOptions,
        modelId: string,
        isStreaming: boolean,
        cancellationToken?: CancellationToken
    ): Promise<LanguageModelResponse> {
        if (cancellationToken?.isCancellationRequested) {
            return { text: '' };
        }

        const { instructions, input } = this.processMessages(request.messages, developerMessageSettings, model);
        const tools = this.convertToolsForResponseApi(request.tools, request.deferredToolIds, request.serverTools);
        const include = [...new Set([
            ...(Array.isArray(settings.include) ? settings.include : []),
            ...(request.serverTools?.includes(OPENAI_WEB_SEARCH) ? ['web_search_call.action.sources'] : []),
            ...(request.tools?.length || request.serverTools?.includes(OPENAI_WEB_SEARCH) ? ['reasoning.encrypted_content'] : [])
        ])];
        const effectiveSettings = include.length > 0 ? { ...settings, include } : settings;

        // If no tools are provided, use simple response handling
        if (!tools || tools.length === 0) {
            if (isStreaming) {
                const stream = openai.responses.stream({
                    model: model as ResponsesModel,
                    instructions,
                    input,
                    ...effectiveSettings
                });
                return { stream: this.createSimpleResponseApiStreamIterator(stream, cancellationToken) };
            } else {
                const response = await openai.responses.create({
                    model: model as ResponsesModel,
                    instructions,
                    input,
                    ...effectiveSettings
                });

                return {
                    text: response.output_text || '',
                    usage: response.usage ? {
                        input_tokens: response.usage.input_tokens,
                        output_tokens: response.usage.output_tokens,
                    } : undefined
                };
            }
        }

        // Handle tool calling with multi-turn conversation using the unified iterator
        const iterator = new ResponseApiToolCallIterator(
            openai,
            request,
            effectiveSettings,
            model,
            modelUtils,
            developerMessageSettings,
            runnerOptions,
            modelId,
            this,
            isStreaming,
            cancellationToken
        );

        return { stream: iterator };
    }

    /**
     * Converts ToolRequest objects to the format expected by the Response API.
     */
    convertToolsForResponseApi(tools?: ToolRequest[], deferredToolIds?: string[], serverTools?: string[]): Tool[] | undefined {
        const deferred = new Set(deferredToolIds ?? []);
        const converted: Tool[] = (tools ?? []).map(tool => ({
            type: 'function' as const,
            name: tool.name,
            description: tool.description || '',
            // Tool schemas are passed through as-is (non-strict). Strict mode (`strict: true`) is opt-in and
            // only supports a JSON-schema subset (all properties required, `additionalProperties: false`
            // everywhere), which cannot express the open-ended maps common in MCP tool schemas.
            parameters: tool.parameters as unknown as FunctionTool['parameters'],
            strict: false,
            defer_loading: deferred.has(tool.id) ? true : undefined
        }));
        if (deferred.size > 0) {
            converted.push({ type: 'tool_search', execution: 'server' });
        }
        if (serverTools?.includes(OPENAI_WEB_SEARCH)) {
            converted.push({ type: 'web_search' });
        }
        if (converted.length === 0) {
            return undefined;
        }
        console.debug(`Converted ${(tools ?? []).length} tools for Response API:`, converted.map(t => t.type === 'function' ? t.name : t.type));
        return converted;
    }

    protected createSimpleResponseApiStreamIterator(
        stream: AsyncIterable<ResponseStreamEvent>,
        cancellationToken?: CancellationToken
    ): AsyncIterable<LanguageModelStreamResponsePart> {
        return {
            async *[Symbol.asyncIterator](): AsyncIterator<LanguageModelStreamResponsePart> {
                let lastUsage: { input_tokens: number; output_tokens: number } | undefined;
                let usageYielded = false;
                try {
                    for await (const event of stream) {
                        if (cancellationToken?.isCancellationRequested) {
                            break;
                        }

                        if (event.type === 'response.output_text.delta') {
                            yield {
                                content: event.delta
                            };
                        } else if (event.type === 'response.output_item.done' && event.item?.type === 'compaction') {
                            yield {
                                compaction: {
                                    provider: 'openai-responses',
                                    data: { id: event.item.id, encrypted_content: event.item.encrypted_content }
                                }
                            };
                        } else if (event.type === 'response.completed') {
                            if (event.response?.usage) {
                                usageYielded = true;
                                yield {
                                    input_tokens: event.response.usage.input_tokens,
                                    output_tokens: event.response.usage.output_tokens,
                                };
                            }
                        } else if (event.type === 'response.created' || event.type === 'response.in_progress') {
                            // Track partial usage from in-progress events if available
                            const responseEvent = event as { response?: { usage?: { input_tokens: number; output_tokens: number } } };
                            if (responseEvent.response?.usage) {
                                lastUsage = {
                                    input_tokens: responseEvent.response.usage.input_tokens,
                                    output_tokens: responseEvent.response.usage.output_tokens,
                                };
                            }
                        } else if (event.type === 'error') {
                            console.error('Response API error:', event.message);
                            throw new Error(`Response API error: ${event.message}`);
                        }
                    }
                } finally {
                    // Yield partial usage data when stream is aborted before response.completed
                    if (!usageYielded && lastUsage && (lastUsage.input_tokens > 0 || lastUsage.output_tokens > 0)) {
                        yield lastUsage;
                    }
                }
            }
        };
    }

    /**
     * Processes the provided list of messages by applying system message adjustments and converting
     * them directly to the format expected by the OpenAI Response API.
     *
     * This method converts messages directly without going through ChatCompletionMessageParam types.
     *
     * @param messages the list of messages to process.
     * @param developerMessageSettings how system and developer messages are handled during processing.
     * @param model the OpenAI model identifier. Currently not used, but allows subclasses to implement model-specific behavior.
     * @returns an object containing instructions and input formatted for the Response API.
     */
    processMessages(
        messages: LanguageModelMessage[],
        developerMessageSettings: DeveloperMessageSettings,
        model: string
    ): { instructions?: string; input: ResponseInputItem[] } {
        const processed = this.processSystemMessages(messages, developerMessageSettings)
            .filter(m => m.type !== 'thinking');

        // Extract system/developer messages for instructions
        const systemMessages = processed.filter((m): m is TextMessage => m.type === 'text' && m.actor === 'system');
        const instructions = systemMessages.length > 0
            ? systemMessages.map(m => m.text).join('\n')
            : undefined;

        // Convert non-system messages to Response API input items
        const nonSystemMessages = processed.filter(m => m.actor !== 'system');
        const input: ResponseInputItem[] = [];

        // Server-side compaction replay: the latest openai-responses compaction marker carries the (encrypted) context for
        // everything before it. Drop that prefix and replay the marker as a compaction input item; earlier markers are subsumed.
        let sliceStart = 0;
        for (let i = nonSystemMessages.length - 1; i >= 0; i--) {
            const candidate = nonSystemMessages[i];
            if (LanguageModelMessage.isCompactionMessage(candidate) && candidate.provider === 'openai-responses') {
                const data = candidate.data as { encrypted_content: string; id?: string };
                input.push({
                    type: 'compaction',
                    encrypted_content: data.encrypted_content,
                    id: data.id
                });
                sliceStart = i + 1;
                break;
            }
        }

        for (const message of nonSystemMessages.slice(sliceStart)) {
            if (LanguageModelMessage.isCompactionMessage(message)) {
                // Skip any remaining compaction marker (foreign provider, or a non-final openai-responses one):
                // it is not the prefix-drop item, so it carries no input for this provider.
                continue;
            } else if (LanguageModelMessage.isTextMessage(message)) {
                if (message.actor === 'ai') {
                    // Assistant messages use ResponseOutputMessage format
                    input.push({
                        id: `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`,
                        type: 'message',
                        role: 'assistant',
                        status: 'completed',
                        content: [{
                            type: 'output_text',
                            text: message.text,
                            annotations: []
                        }]
                    });
                } else {
                    // User messages use input format
                    input.push({
                        type: 'message',
                        role: 'user',
                        content: [{
                            type: 'input_text',
                            text: message.text
                        }]
                    });
                }
            } else if (LanguageModelMessage.isToolUseMessage(message)) {
                const rawReasoning = message.data?.[OPENAI_FUNCTION_CALL_REASONING_DATA_KEY];
                if (rawReasoning) {
                    try {
                        input.push(...JSON.parse(rawReasoning) as ResponseInputItem[]);
                    } catch {
                        // Skip malformed provider data.
                    }
                }
                input.push({
                    type: 'function_call',
                    call_id: message.id,
                    name: message.name,
                    arguments: JSON.stringify(message.input)
                });
            } else if (LanguageModelMessage.isToolResultMessage(message)) {
                const content = typeof message.content === 'string' ? message.content : JSON.stringify(message.content);
                input.push({
                    type: 'function_call_output',
                    call_id: message.tool_use_id,
                    output: content
                });
            } else if (LanguageModelMessage.isImageMessage(message)) {
                input.push({
                    type: 'message',
                    role: 'user',
                    content: [{
                        type: 'input_image',
                        detail: 'auto',
                        image_url: ImageContent.isBase64(message.image) ?
                            `data:${message.image.mimeType};base64,${message.image.base64data}` :
                            message.image.url
                    }]
                });
            } else if (LanguageModelMessage.isThinkingMessage(message)) {
                // Pass
            } else if (LanguageModelMessage.isServerToolUseMessage(message)) {
                const rawReplay = message.data?.[OPENAI_WEB_SEARCH_REPLAY_DATA_KEY];
                if (message.name === OPENAI_WEB_SEARCH && rawReplay) {
                    try {
                        input.push(...JSON.parse(rawReplay) as ResponseInputItem[]);
                    } catch {
                        // Skip malformed provider data.
                    }
                }
            } else {
                unreachable(message);
            }
        }

        return { instructions, input };
    }

    protected processSystemMessages(
        messages: LanguageModelMessage[],
        developerMessageSettings: DeveloperMessageSettings
    ): LanguageModelMessage[] {
        return processSystemMessages(messages, developerMessageSettings);
    }
}

/**
 * Iterator for handling Response API streaming with tool calls.
 * Based on the pattern from openai-streaming-iterator.ts but adapted for Response API.
 */
class ResponseApiToolCallIterator implements AsyncIterableIterator<LanguageModelStreamResponsePart> {
    protected readonly requestQueue = new Array<Deferred<IteratorResult<LanguageModelStreamResponsePart>>>();
    protected readonly messageCache = new Array<LanguageModelStreamResponsePart>();
    protected done = false;
    protected terminalError: Error | undefined = undefined;

    // Current iteration state
    protected currentInput: ResponseInputItem[];
    protected currentToolCalls = new Map<string, ToolCall>();
    // Id under which the current deferred-tool search is surfaced, so the running and finished parts merge in the UI.
    protected toolSearchCallId: string | undefined;
    protected iteration = 0;
    protected readonly maxIterations: number;
    protected readonly tools: Tool[] | undefined;
    protected readonly instructions?: string;
    protected currentResponseText = '';
    protected pendingReasoningItems: ResponseInputItem[] = [];
    protected currentWebSearchReplayItems: ResponseInputItem[] = [];

    constructor(
        protected readonly openai: OpenAI,
        protected readonly request: UserRequest,
        protected readonly settings: Record<string, unknown>,
        protected readonly model: string,
        protected readonly modelUtils: OpenAiModelUtils,
        protected readonly developerMessageSettings: DeveloperMessageSettings,
        protected readonly runnerOptions: RunnerOptions,
        protected readonly modelId: string,
        protected readonly utils: OpenAiResponseApiUtils,
        protected readonly isStreaming: boolean,
        protected readonly cancellationToken?: CancellationToken
    ) {
        const { instructions, input } = utils.processMessages(request.messages, developerMessageSettings, model);
        this.instructions = instructions;
        this.currentInput = input;
        this.tools = utils.convertToolsForResponseApi(request.tools, request.deferredToolIds, request.serverTools);
        this.maxIterations = runnerOptions.maxChatCompletions || 100;

        // Start the first iteration
        this.startIteration();
    }

    [Symbol.asyncIterator](): AsyncIterableIterator<LanguageModelStreamResponsePart> {
        return this;
    }

    async next(): Promise<IteratorResult<LanguageModelStreamResponsePart>> {
        if (this.messageCache.length && this.requestQueue.length) {
            throw new Error('Assertion error: cache and queue should not both be populated.');
        }

        // Deliver all the messages we got, even if we've since terminated.
        if (this.messageCache.length) {
            return {
                done: false,
                value: this.messageCache.shift()!
            };
        } else if (this.terminalError) {
            throw this.terminalError;
        } else if (this.done) {
            return {
                done: true,
                value: undefined
            };
        } else {
            const deferred = new Deferred<IteratorResult<LanguageModelStreamResponsePart>>();
            this.requestQueue.push(deferred);
            return deferred.promise;
        }
    }

    protected async startIteration(): Promise<void> {
        try {
            while (this.iteration < this.maxIterations && !this.cancellationToken?.isCancellationRequested) {
                console.debug(`Starting Response API iteration ${this.iteration} with ${this.currentInput.length} input messages`);

                await this.processStream();

                // Check if we have tool calls that need execution
                if (this.currentToolCalls.size === 0) {
                    // No tool calls, we're done
                    this.finalize();
                    return;
                }

                // Execute all tool calls
                await this.executeToolCalls();

                // Prepare for next iteration
                this.prepareNextIteration();
                this.iteration++;
            }

            // Max iterations reached
            this.finalize();
        } catch (error) {
            this.terminalError = error instanceof Error ? error : new Error(String(error));
            this.finalize();
        }
    }

    protected async processStream(): Promise<void> {
        this.currentToolCalls.clear();
        this.currentResponseText = '';
        this.pendingReasoningItems = [];
        this.currentWebSearchReplayItems = [];

        if (this.isStreaming) {
            // Use streaming API
            const stream = this.openai.responses.stream({
                model: this.model as ResponsesModel,
                instructions: this.instructions,
                input: this.currentInput,
                tools: this.tools,
                ...this.settings
            });

            for await (const event of stream) {
                if (this.cancellationToken?.isCancellationRequested) {
                    break;
                }
                await this.handleStreamEvent(event);
            }
        } else {
            // Use non-streaming API but yield results incrementally
            await this.processNonStreamingResponse();
        }
    }

    protected async processNonStreamingResponse(): Promise<void> {
        const response = await this.openai.responses.create({
            model: this.model as ResponsesModel,
            instructions: this.instructions,
            input: this.currentInput,
            tools: this.tools,
            ...this.settings
        });

        // Record token usage
        if (response.usage) {
            this.handleIncoming({
                input_tokens: response.usage.input_tokens,
                output_tokens: response.usage.output_tokens,
            });
        }

        // First, yield any text content from the response
        this.currentResponseText = response.output_text || '';
        if (this.currentResponseText) {
            this.handleIncoming({ content: this.currentResponseText });
        }

        const pendingReasoningItems: ResponseInputItem[] = [];
        for (const item of response.output ?? []) {
            if (item.type === 'reasoning') {
                pendingReasoningItems.push(item);
            } else if (item.type === 'web_search_call') {
                this.handleWebSearchCall(item, true, [...pendingReasoningItems, item]);
                pendingReasoningItems.length = 0;
            } else if (item.type === 'function_call' && item.id) {
                const toolCall = this.createToolCall(item, item.id);
                toolCall.reasoningItems = pendingReasoningItems.splice(0);
                this.currentToolCalls.set(item.id, toolCall);
                this.handleFunctionCall(toolCall, false);
            }
        }

        // Surface any deferred-tool search OpenAI executed while producing this response (see handleToolSearchOutput).
        const toolSearchOutputs = response.output?.filter((item): item is ResponseToolSearchOutputItem => item.type === 'tool_search_output') || [];
        for (const output of toolSearchOutputs) {
            const found = output.tools?.length ?? 0;
            this.handleIncoming({
                server_tool_calls: [{
                    id: output.call_id ?? output.id,
                    name: OPENAI_TOOL_SEARCH,
                    finished: true,
                    result: { content: [{ type: 'text', text: openAiToolSearchFoundText(found) }] }
                }]
            });
        }

    }

    protected async handleStreamEvent(event: ResponseStreamEvent): Promise<void> {
        switch (event.type) {
            case 'response.output_text.delta':
                this.currentResponseText += event.delta;
                this.handleIncoming({ content: event.delta });
                break;

            case 'response.output_item.added':
                if (event.item?.type === 'function_call') {
                    this.handleFunctionCallAdded(event.item);
                } else if (event.item?.type === 'tool_search_call') {
                    this.handleToolSearchCall(event.item);
                } else if (event.item?.type === 'web_search_call') {
                    this.handleWebSearchCall(event.item, false);
                }
                break;

            case 'response.function_call_arguments.delta':
                this.handleFunctionCallArgsDelta(event);
                break;

            case 'response.function_call_arguments.done':
                await this.handleFunctionCallArgsDone(event);
                break;

            case 'response.output_item.done':
                if (event.item?.type === 'function_call') {
                    this.handleFunctionCallDone(event.item);
                } else if (event.item?.type === 'compaction') {
                    this.handleIncoming({
                        compaction: {
                            provider: 'openai-responses',
                            data: { id: event.item.id, encrypted_content: event.item.encrypted_content }
                        }
                    });
                } else if (event.item?.type === 'tool_search_output') {
                    this.handleToolSearchOutput(event.item);
                } else if (event.item?.type === 'reasoning') {
                    this.pendingReasoningItems.push(event.item);
                } else if (event.item?.type === 'web_search_call') {
                    this.handleWebSearchCall(event.item, true, [...this.pendingReasoningItems, event.item]);
                    this.pendingReasoningItems = [];
                }
                break;

            case 'response.completed':
                if (event.response?.usage) {
                    this.handleIncoming({
                        input_tokens: event.response.usage.input_tokens,
                        output_tokens: event.response.usage.output_tokens,
                    });
                }
                break;

            case 'error':
                console.error('Response API error:', event.message);
                throw new Error(`Response API error: ${event.message}`);
        }
    }

    protected handleFunctionCallAdded(functionCall: ResponseFunctionToolCall): void {
        if (functionCall.id && functionCall.call_id) {
            console.debug(`Function call added: ${functionCall.name} with id ${functionCall.id} and call_id ${functionCall.call_id}`);

            const toolCall = this.createToolCall(functionCall, functionCall.id);
            toolCall.reasoningItems = this.pendingReasoningItems.splice(0);
            this.currentToolCalls.set(functionCall.id, toolCall);
            this.handleFunctionCall(toolCall, false);
        }
    }

    protected createToolCall(functionCall: ResponseFunctionToolCall, id: string): ToolCall {
        return {
            id,
            call_id: functionCall.call_id || functionCall.id,
            name: functionCall.name || '',
            arguments: functionCall.arguments || '',
            executed: false
        };
    }

    protected handleFunctionCall(toolCall: ToolCall, finished: boolean, result?: ToolCallResult): void {
        this.handleIncoming({
            tool_calls: [{
                id: toolCall.id,
                finished,
                function: {
                    name: toolCall.name,
                    arguments: toolCall.arguments
                },
                result,
                data: finished && toolCall.reasoningItems?.length ? {
                    [OPENAI_FUNCTION_CALL_REASONING_DATA_KEY]: JSON.stringify(toolCall.reasoningItems)
                } : undefined
            }]
        });
    }

    protected handleFunctionCallArgsDelta(event: ResponseFunctionCallArgumentsDeltaEvent): void {
        const toolCall = this.currentToolCalls.get(event.item_id);
        if (toolCall) {
            toolCall.arguments += event.delta;

            if (event.delta) {
                this.handleIncoming({
                    tool_calls: [{
                        id: event.item_id,
                        argumentsDelta: true,
                        function: {
                            arguments: event.delta
                        }
                    }]
                });
            }
        }
    }

    protected async handleFunctionCallArgsDone(event: ResponseFunctionCallArgumentsDoneEvent): Promise<void> {
        let toolCall = this.currentToolCalls.get(event.item_id);
        if (!toolCall) {
            // Create if we didn't see the added event
            toolCall = {
                id: event.item_id,
                name: event.name || '',
                arguments: event.arguments || '',
                executed: false,
                reasoningItems: this.pendingReasoningItems.splice(0)
            };
            this.currentToolCalls.set(event.item_id, toolCall);

            this.handleIncoming({
                tool_calls: [{
                    id: event.item_id,
                    finished: false,
                    function: {
                        name: event.name || '',
                        arguments: event.arguments || ''
                    }
                }]
            });
        } else {
            // Update with final values
            toolCall.name = event.name || toolCall.name;
            toolCall.arguments = event.arguments || toolCall.arguments;
        }
    }

    protected handleWebSearchCall(item: ResponseFunctionWebSearch, finished: boolean, replayItems?: ResponseInputItem[]): void {
        if (finished && replayItems) {
            this.currentWebSearchReplayItems.push(...replayItems);
        }
        const action = JSON.stringify(item.action);
        this.handleIncoming({
            server_tool_calls: [{
                id: item.id,
                name: OPENAI_WEB_SEARCH,
                arguments: action,
                finished,
                result: finished ? {
                    content: [{
                        type: 'text',
                        text: item.status === 'failed'
                            ? nls.localize('theia/ai/openai/webSearch/failed', 'Web search failed.')
                            : nls.localize('theia/ai/openai/webSearch/completed', 'Web search completed.')
                    }]
                } : undefined,
                data: finished && replayItems ? {
                    [OPENAI_WEB_SEARCH_REPLAY_DATA_KEY]: JSON.stringify(replayItems)
                } : undefined
            }]
        });
    }

    /**
     * The deferred-tool search runs on OpenAI's infrastructure and is reported as `tool_search_call` /
     * `tool_search_output` output items. Surface it as a server tool call so the chat UI shows the search
     * running and, once finished, how many tools were loaded. It is never executed by a client handler.
     */
    protected handleToolSearchCall(item: ResponseToolSearchCall): void {
        this.toolSearchCallId = item.call_id ?? item.id;
        this.handleIncoming({
            server_tool_calls: [{
                id: this.toolSearchCallId,
                name: OPENAI_TOOL_SEARCH,
                arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments ?? ''),
                finished: false
            }]
        });
    }

    protected handleToolSearchOutput(item: ResponseToolSearchOutputItem): void {
        const found = item.tools?.length ?? 0;
        this.handleIncoming({
            server_tool_calls: [{
                id: this.toolSearchCallId ?? item.call_id ?? item.id,
                name: OPENAI_TOOL_SEARCH,
                finished: true,
                result: { content: [{ type: 'text', text: openAiToolSearchFoundText(found) }] }
            }]
        });
        this.toolSearchCallId = undefined;
    }

    protected handleFunctionCallDone(functionCall: ResponseFunctionToolCall): void {
        if (!functionCall.id) { console.warn('Unexpected absence of ID for call ID', functionCall.call_id); return; }
        const toolCall = this.currentToolCalls.get(functionCall.id);
        if (toolCall && !toolCall.call_id && functionCall.call_id) {
            toolCall.call_id = functionCall.call_id;
        }
    }

    protected async executeToolCalls(): Promise<void> {
        for (const [itemId, toolCall] of this.currentToolCalls) {
            if (toolCall.executed) {
                continue;
            }

            const tool = this.request.tools?.find(t => t.name === toolCall.name);
            if (tool) {
                try {
                    const result = await tool.handler(toolCall.arguments, ToolInvocationContext.create(itemId));
                    toolCall.result = result;

                    // Yield the tool call completion
                    this.handleFunctionCall(toolCall, true, result);
                } catch (error) {
                    console.error(`Error executing tool ${toolCall.name}:`, error);
                    toolCall.error = error instanceof Error ? error : new Error(String(error));

                    // Yield the tool call error
                    this.handleFunctionCall(toolCall, true, createToolCallError(error instanceof Error ? error.message : String(error)));

                }
            } else {
                console.warn(`Tool ${toolCall.name} not found in request tools`);
                toolCall.error = new Error(`Tool ${toolCall.name} not found`);

                // Yield the tool call error
                this.handleFunctionCall(
                    toolCall,
                    true,
                    createToolCallError(`Tool '${toolCall.name}' not found in the available tools for this request.`, 'tool-not-available')
                );
            }

            toolCall.executed = true;
        }
    }

    protected prepareNextIteration(): void {
        // Add assistant response with the actual text that was streamed
        const assistantMessage: ResponseInputItem = {
            role: 'assistant',
            content: this.currentResponseText
        };

        // Add the function calls that were made by the assistant
        const functionCalls: ResponseInputItem[] = [];
        for (const [itemId, toolCall] of this.currentToolCalls) {
            functionCalls.push(...toolCall.reasoningItems ?? [], {
                type: 'function_call',
                call_id: toolCall.call_id || itemId,
                name: toolCall.name,
                arguments: toolCall.arguments
            });
        }

        // Add tool results
        const toolResults: ResponseInputItem[] = [];
        for (const [itemId, toolCall] of this.currentToolCalls) {
            const callId = toolCall.call_id || itemId;

            if (toolCall.result !== undefined) {
                const resultContent = typeof toolCall.result === 'string' ? toolCall.result : JSON.stringify(toolCall.result);
                toolResults.push({
                    type: 'function_call_output',
                    call_id: callId,
                    output: resultContent
                });
            } else if (toolCall.error) {
                toolResults.push({
                    type: 'function_call_output',
                    call_id: callId,
                    output: `Error: ${toolCall.error.message}`
                });
            }
        }

        this.currentInput = [...this.currentInput, ...this.currentWebSearchReplayItems, assistantMessage, ...functionCalls, ...toolResults];
    }

    protected handleIncoming(message: LanguageModelStreamResponsePart): void {
        if (this.messageCache.length && this.requestQueue.length) {
            throw new Error('Assertion error: cache and queue should not both be populated.');
        }

        if (this.requestQueue.length) {
            this.requestQueue.shift()!.resolve({
                done: false,
                value: message
            });
        } else {
            this.messageCache.push(message);
        }
    }

    protected async finalize(): Promise<void> {
        this.done = true;

        // Resolve any outstanding requests
        if (this.terminalError) {
            this.requestQueue.forEach(request => request.reject(this.terminalError));
        } else {
            this.requestQueue.forEach(request => request.resolve({ done: true, value: undefined }));
        }
        this.requestQueue.length = 0;
    }
}

export function processSystemMessages(
    messages: LanguageModelMessage[],
    developerMessageSettings: DeveloperMessageSettings
): LanguageModelMessage[] {
    if (developerMessageSettings === 'skip') {
        return messages.filter(message => message.actor !== 'system');
    } else if (developerMessageSettings === 'mergeWithFollowingUserMessage') {
        const updated = messages.slice();
        for (let i = updated.length - 1; i >= 0; i--) {
            if (updated[i].actor === 'system') {
                const systemMessage = updated[i] as TextMessage;
                if (i + 1 < updated.length && updated[i + 1].actor === 'user') {
                    // Merge system message with the next user message
                    const userMessage = updated[i + 1] as TextMessage;
                    updated[i + 1] = {
                        ...updated[i + 1],
                        text: systemMessage.text + '\n' + userMessage.text
                    } as TextMessage;
                    updated.splice(i, 1);
                } else {
                    // The message directly after is not a user message (or none exists), so create a new user message right after
                    updated.splice(i + 1, 0, { actor: 'user', type: 'text', text: systemMessage.text });
                    updated.splice(i, 1);
                }
            }
        }
        return updated;
    }
    return messages;
}
