// *****************************************************************************
// Copyright (C) 2026 EclipseSource and others.
//
// 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 { expect } from 'chai';
import {
    CompactionMessage, isCompactionResponsePart, isServerToolCallResponsePart, isUsageResponsePart, LanguageModelMessage, LanguageModelStreamResponsePart, UserRequest
} from '@theia/ai-core';
import { OpenAiModelUtils } from './openai-language-model';
import { OPENAI_FUNCTION_CALL_REASONING_DATA_KEY, OpenAiResponseApiUtils } from './openai-response-api-utils';
import { OPENAI_WEB_SEARCH, OPENAI_WEB_SEARCH_REPLAY_DATA_KEY } from './openai-server-tools';

async function* toStream(events: unknown[]): AsyncIterable<unknown> {
    for (const event of events) {
        yield event;
    }
}

describe('OpenAiResponseApiUtils', () => {
    it('passes tool parameters through unchanged in non-strict mode', () => {
        const utils = new OpenAiResponseApiUtils();
        const parameters = {
            type: 'object' as const,
            properties: {
                metadata: {
                    type: 'object' as const,
                    additionalProperties: { type: 'string' }
                }
            }
        };

        const converted = utils.convertToolsForResponseApi([{
            id: 'lookup',
            name: 'lookup',
            parameters,
            handler: async () => 'result'
        }]);

        expect(converted).to.have.lengthOf(1);
        const convertedTool = converted?.[0];
        expect(convertedTool).to.include({
            type: 'function',
            name: 'lookup',
            parameters,
            strict: false
        });
        if (convertedTool?.type !== 'function') {
            throw new Error('Expected a function tool');
        }
        expect(convertedTool.parameters).to.equal(parameters);
    });

    it('adds native web search without requiring client tools', () => {
        const utils = new OpenAiResponseApiUtils();

        expect(utils.convertToolsForResponseApi(undefined, undefined, [OPENAI_WEB_SEARCH])).to.deep.equal([
            { type: 'web_search' }
        ]);
        expect(utils.convertToolsForResponseApi()).to.equal(undefined);
    });

    it('combines native web search with function and deferred-tool search tools', () => {
        const utils = new OpenAiResponseApiUtils();
        const tools = utils.convertToolsForResponseApi([{
            id: 'lookup',
            name: 'lookup',
            parameters: { type: 'object', properties: {} },
            handler: async () => 'result'
        }], ['lookup'], [OPENAI_WEB_SEARCH]);

        expect(tools?.map(tool => tool.type)).to.deep.equal(['function', 'tool_search', 'web_search']);
    });

    it('emits per-iteration usage for Response API tool calls instead of accumulated usage', async () => {
        const utils = new OpenAiResponseApiUtils();
        const streams = [
            [
                {
                    type: 'response.output_item.added',
                    item: {
                        id: 'item-1',
                        call_id: 'call-1',
                        type: 'function_call',
                        name: 'lookup',
                        arguments: '{"query":"test"}'
                    }
                },
                {
                    type: 'response.completed',
                    response: {
                        usage: {
                            input_tokens: 100,
                            output_tokens: 10
                        }
                    }
                }
            ],
            [
                {
                    type: 'response.output_text.delta',
                    delta: 'done'
                },
                {
                    type: 'response.completed',
                    response: {
                        usage: {
                            input_tokens: 200,
                            output_tokens: 20
                        }
                    }
                }
            ]
        ];
        const streamRequests: Record<string, unknown>[] = [];
        const openai = {
            responses: {
                stream: (responseRequest: Record<string, unknown>) => {
                    streamRequests.push(responseRequest);
                    return toStream(streams.shift() ?? []);
                }
            }
        };
        const request: UserRequest = {
            sessionId: 'session-1',
            requestId: 'request-1',
            messages: [{ actor: 'user', type: 'text', text: 'hello' }],
            serverTools: [OPENAI_WEB_SEARCH],
            tools: [{
                id: 'lookup',
                name: 'lookup',
                parameters: { type: 'object', properties: { query: { type: 'string' } } },
                handler: async () => 'result'
            }]
        };

        const response = await utils.handleRequest(
            openai as never,
            request,
            {},
            'gpt-5',
            new OpenAiModelUtils(),
            'developer',
            { maxChatCompletions: 3 },
            'openai/gpt-5',
            true
        );
        const parts: LanguageModelStreamResponsePart[] = [];
        if ('stream' in response) {
            for await (const part of response.stream) {
                parts.push(part);
            }
        }

        expect(parts.filter(isUsageResponsePart)).to.deep.equal([
            { input_tokens: 100, output_tokens: 10 },
            { input_tokens: 200, output_tokens: 20 }
        ]);
        expect(streamRequests).to.have.length(2);
        expect(streamRequests.every(streamRequest =>
            JSON.stringify(streamRequest.include) === JSON.stringify(['web_search_call.action.sources', 'reasoning.encrypted_content'])
        )).to.equal(true);
    });

    it('preserves reasoning and web search items for the next function-tool iteration', async () => {
        const utils = new OpenAiResponseApiUtils();
        const reasoningItem = { id: 'rs-1', type: 'reasoning', summary: [], encrypted_content: 'encrypted-reasoning' };
        const searchCall = {
            id: 'ws-1',
            type: 'web_search_call',
            status: 'completed',
            action: { type: 'search', query: 'news' }
        };
        const streams = [
            [
                { type: 'response.output_item.done', item: reasoningItem },
                { type: 'response.output_item.done', item: searchCall },
                {
                    type: 'response.output_item.added',
                    item: { id: 'item-1', call_id: 'call-1', type: 'function_call', name: 'lookup', arguments: '{"query":"test"}' }
                }
            ],
            [{ type: 'response.output_text.delta', delta: 'done' }]
        ];
        const streamRequests: Record<string, unknown>[] = [];
        const openai = {
            responses: {
                stream: (responseRequest: Record<string, unknown>) => {
                    streamRequests.push(responseRequest);
                    return toStream(streams.shift() ?? []);
                }
            }
        };
        const request: UserRequest = {
            sessionId: 'session-1',
            requestId: 'request-1',
            messages: [{ actor: 'user', type: 'text', text: 'hello' }],
            serverTools: [OPENAI_WEB_SEARCH],
            tools: [{
                id: 'lookup',
                name: 'lookup',
                parameters: { type: 'object', properties: { query: { type: 'string' } } },
                handler: async () => 'result'
            }]
        };

        const response = await utils.handleRequest(
            openai as never, request, {}, 'gpt-5', new OpenAiModelUtils(), 'developer',
            { maxChatCompletions: 3 }, 'openai/gpt-5', true
        );
        const parts: LanguageModelStreamResponsePart[] = [];
        if ('stream' in response) {
            for await (const part of response.stream) {
                parts.push(part);
            }
        }

        expect(parts).to.not.be.empty;

        expect(streamRequests).to.have.length(2);
        expect(streamRequests[1].input).to.deep.include.members([reasoningItem, searchCall]);
        const input = streamRequests[1].input as unknown[];
        expect(input.indexOf(reasoningItem)).to.be.lessThan(input.indexOf(searchCall));
    });

    it('yields a compaction part when the stream contains a response.output_item.done compaction event', async () => {
        const utils = new OpenAiResponseApiUtils();
        const streamEvents = [
            {
                type: 'response.output_item.done',
                item: { type: 'compaction', id: 'c1', encrypted_content: 'enc1' }
            } as never,
            {
                type: 'response.completed',
                response: { usage: { input_tokens: 10, output_tokens: 5 } }
            } as never
        ];
        const openai = {
            responses: {
                stream: () => toStream(streamEvents)
            }
        };
        const request: UserRequest = {
            sessionId: 'session-1',
            requestId: 'request-1',
            messages: [{ actor: 'user', type: 'text', text: 'hello' }]
        };

        const response = await utils.handleRequest(
            openai as never,
            request,
            {},
            'gpt-5',
            new OpenAiModelUtils(),
            'developer',
            { maxChatCompletions: 3 },
            'openai/gpt-5',
            true
        );
        const parts: LanguageModelStreamResponsePart[] = [];
        if ('stream' in response) {
            for await (const part of response.stream) {
                parts.push(part);
            }
        }

        const compactionParts = parts.filter(isCompactionResponsePart);
        expect(compactionParts).to.have.length(1);
        expect(compactionParts[0]).to.deep.equal({
            compaction: {
                provider: 'openai-responses',
                data: { id: 'c1', encrypted_content: 'enc1' }
            }
        });
    });

    it('surfaces the deferred-tool search as a running then finished server tool call', async () => {
        const utils = new OpenAiResponseApiUtils();
        const streams = [
            [
                {
                    type: 'response.output_item.added',
                    item: { id: 'ts-item-1', call_id: 'ts-1', type: 'tool_search_call', execution: 'server', status: 'in_progress', arguments: { query: 'file' } }
                },
                {
                    type: 'response.output_item.done',
                    item: { id: 'ts-out-1', call_id: 'ts-1', type: 'tool_search_output', execution: 'server', status: 'completed', tools: [{}, {}] }
                },
                {
                    type: 'response.output_item.added',
                    item: { id: 'item-1', call_id: 'call-1', type: 'function_call', name: 'lookup', arguments: '{"query":"test"}' }
                },
                { type: 'response.completed', response: { usage: { input_tokens: 100, output_tokens: 10 } } }
            ],
            [
                { type: 'response.output_text.delta', delta: 'done' },
                { type: 'response.completed', response: { usage: { input_tokens: 200, output_tokens: 20 } } }
            ]
        ];
        const openai = {
            responses: {
                stream: () => toStream(streams.shift() ?? [])
            }
        };
        const request: UserRequest = {
            sessionId: 'session-1',
            requestId: 'request-1',
            messages: [{ actor: 'user', type: 'text', text: 'hello' }],
            deferredToolIds: ['lookup'],
            tools: [{
                id: 'lookup',
                name: 'lookup',
                parameters: { type: 'object', properties: { query: { type: 'string' } } },
                handler: async () => 'result'
            }]
        };

        const response = await utils.handleRequest(
            openai as never,
            request,
            {},
            'gpt-5',
            new OpenAiModelUtils(),
            'developer',
            { maxChatCompletions: 3 },
            'openai/gpt-5',
            true
        );
        const parts: LanguageModelStreamResponsePart[] = [];
        if ('stream' in response) {
            for await (const part of response.stream) {
                parts.push(part);
            }
        }

        const calls = parts.filter(isServerToolCallResponsePart).flatMap(p => p.server_tool_calls);
        const running = calls.find(c => !c.finished);
        expect(running).to.not.be.undefined;
        expect(running!.id).to.equal('ts-1');
        expect(running!.name).to.equal('tool_search');

        const finished = calls.find(c => c.finished);
        expect(finished).to.not.be.undefined;
        expect(finished!.id).to.equal('ts-1');
        expect(finished!.name).to.equal('tool_search');
        expect(finished!.result).to.deep.equal({ content: [{ type: 'text', text: 'Found 2 tools.' }] });
    });

    it('surfaces web search as a running then finished server tool call', async () => {
        const utils = new OpenAiResponseApiUtils();
        const reasoningItem = {
            id: 'rs-1',
            type: 'reasoning',
            summary: [],
            encrypted_content: 'encrypted-reasoning'
        };
        const searchCall = {
            id: 'ws-1',
            type: 'web_search_call',
            status: 'failed',
            action: { type: 'search', query: 'latest AI news' }
        };
        const openai = {
            responses: {
                stream: () => toStream([
                    { type: 'response.output_item.done', item: reasoningItem },
                    { type: 'response.output_item.added', item: { ...searchCall, status: 'in_progress' } },
                    { type: 'response.output_item.done', item: searchCall },
                    { type: 'response.output_text.delta', delta: 'Latest news.' },
                    { type: 'response.completed', response: { usage: { input_tokens: 10, output_tokens: 5 } } }
                ])
            }
        };
        const request: UserRequest = {
            sessionId: 'session-1',
            requestId: 'request-1',
            messages: [{ actor: 'user', type: 'text', text: 'What is new?' }],
            serverTools: [OPENAI_WEB_SEARCH]
        };

        const response = await utils.handleRequest(
            openai as never, request, {}, 'gpt-5', new OpenAiModelUtils(), 'developer',
            { maxChatCompletions: 3 }, 'openai/gpt-5', true
        );
        const parts: LanguageModelStreamResponsePart[] = [];
        if ('stream' in response) {
            for await (const part of response.stream) {
                parts.push(part);
            }
        }

        const calls = parts.filter(isServerToolCallResponsePart).flatMap(part => part.server_tool_calls);
        expect(calls).to.have.length(2);
        expect(calls[0]).to.deep.include({ id: 'ws-1', name: OPENAI_WEB_SEARCH, finished: false });
        expect(calls[1]).to.deep.include({ id: 'ws-1', name: OPENAI_WEB_SEARCH, finished: true });
        expect(calls[1].result).to.deep.equal({ content: [{ type: 'text', text: 'Web search failed.' }] });
        expect(calls[1].data).to.deep.equal({
            [OPENAI_WEB_SEARCH_REPLAY_DATA_KEY]: JSON.stringify([reasoningItem, searchCall])
        });
    });

    it('surfaces web search from a non-streaming response and requests sources', async () => {
        const utils = new OpenAiResponseApiUtils();
        let createRequest: Record<string, unknown> | undefined;
        const openai = {
            responses: {
                create: async (responseRequest: Record<string, unknown>) => {
                    createRequest = responseRequest;
                    return {
                        output_text: 'Answer',
                        output: [{ id: 'ws-1', type: 'web_search_call', status: 'completed', action: { type: 'search', query: 'news' } }]
                    };
                }
            }
        };
        const request: UserRequest = {
            sessionId: 'session-1',
            requestId: 'request-1',
            messages: [{ actor: 'user', type: 'text', text: 'Search' }],
            serverTools: [OPENAI_WEB_SEARCH]
        };

        const response = await utils.handleRequest(
            openai as never, request, { include: ['file_search_call.results', 'web_search_call.action.sources'] },
            'gpt-5', new OpenAiModelUtils(), 'developer', { maxChatCompletions: 3 }, 'openai/gpt-5', false
        );
        const parts: LanguageModelStreamResponsePart[] = [];
        if ('stream' in response) {
            for await (const part of response.stream) {
                parts.push(part);
            }
        }

        const calls = parts.filter(isServerToolCallResponsePart).flatMap(part => part.server_tool_calls);
        expect(calls).to.have.length(1);
        expect(calls[0]).to.deep.include({ id: 'ws-1', name: OPENAI_WEB_SEARCH, finished: true });
        expect(createRequest?.include).to.deep.equal(['file_search_call.results', 'web_search_call.action.sources', 'reasoning.encrypted_content']);
    });

    it('replays persisted OpenAI reasoning before its web search call', () => {
        const utils = new OpenAiResponseApiUtils();
        const reasoningItem = {
            id: 'rs-1',
            type: 'reasoning',
            summary: [],
            encrypted_content: 'encrypted-reasoning'
        };
        const searchCall = {
            id: 'ws-1',
            type: 'web_search_call',
            status: 'completed',
            action: {
                type: 'search',
                query: 'news',
                sources: [{ type: 'url', url: 'https://example.com', title: 'Example' }]
            }
        };
        const messages: LanguageModelMessage[] = [{
            actor: 'ai',
            type: 'server_tool_use',
            id: 'ws-1',
            name: OPENAI_WEB_SEARCH,
            input: searchCall.action,
            result: { content: [{ type: 'text', text: 'Web search completed.' }] },
            data: {
                [OPENAI_WEB_SEARCH_REPLAY_DATA_KEY]: JSON.stringify([reasoningItem, searchCall])
            }
        }];

        const { input } = utils.processMessages(messages, 'developer', 'gpt-5');

        expect(input).to.deep.equal([reasoningItem, searchCall]);
    });

    it('persists and replays reasoning before a function call', async () => {
        const utils = new OpenAiResponseApiUtils();
        const reasoningItem = { id: 'rs-1', type: 'reasoning', summary: [], encrypted_content: 'encrypted-reasoning' };
        const functionCall = {
            id: 'fc-1', call_id: 'call-1', type: 'function_call', name: 'lookup', arguments: '{"query":"test"}'
        };
        const requests: Record<string, unknown>[] = [];
        const streams = [
            [
                { type: 'response.output_item.done', item: reasoningItem },
                { type: 'response.output_item.added', item: functionCall }
            ],
            [{ type: 'response.output_text.delta', delta: 'done' }]
        ];
        const openai = {
            responses: {
                stream: (responseRequest: Record<string, unknown>) => {
                    requests.push(responseRequest);
                    return toStream(streams.shift() ?? []);
                }
            }
        };
        const request: UserRequest = {
            sessionId: 'session-1',
            requestId: 'request-1',
            messages: [{ actor: 'user', type: 'text', text: 'hello' }],
            tools: [{
                id: 'lookup',
                name: 'lookup',
                parameters: { type: 'object', properties: { query: { type: 'string' } } },
                handler: async () => 'result'
            }]
        };

        const response = await utils.handleRequest(
            openai as never, request, {}, 'gpt-5', new OpenAiModelUtils(), 'developer',
            { maxChatCompletions: 3 }, 'openai/gpt-5', true
        );
        const parts: LanguageModelStreamResponsePart[] = [];
        if ('stream' in response) {
            for await (const part of response.stream) {
                parts.push(part);
            }
        }

        expect(requests[0].include).to.deep.equal(['reasoning.encrypted_content']);
        const completedCall = parts.flatMap(part => 'tool_calls' in part ? part.tool_calls : []).find(call => call.finished);
        expect(completedCall?.data).to.deep.equal({
            [OPENAI_FUNCTION_CALL_REASONING_DATA_KEY]: JSON.stringify([reasoningItem])
        });
        expect(requests[1].input).to.deep.include.members([reasoningItem]);
        const nextInput = requests[1].input as unknown[];
        const replayedCall = nextInput.find(item => (item as { type?: string }).type === 'function_call');
        expect(nextInput.indexOf(reasoningItem)).to.be.lessThan(nextInput.indexOf(replayedCall));

        const { input } = utils.processMessages([{
            actor: 'ai',
            type: 'tool_use',
            id: 'call-1',
            name: 'lookup',
            input: { query: 'test' },
            data: completedCall?.data
        }], 'developer', 'gpt-5');
        expect(input).to.deep.equal([reasoningItem, {
            type: 'function_call',
            call_id: 'call-1',
            name: 'lookup',
            arguments: '{"query":"test"}'
        }]);
    });

    describe('processMessages server-side compaction replay', () => {

        function userMessage(text: string): LanguageModelMessage {
            return { actor: 'user', type: 'text', text };
        }

        function aiMessage(text: string): LanguageModelMessage {
            return { actor: 'ai', type: 'text', text };
        }

        function compactionMessage(provider: string, encrypted: string, id?: string): CompactionMessage {
            return { actor: 'ai', type: 'compaction', provider, data: { encrypted_content: encrypted, id } };
        }

        function textOf(input: ReturnType<OpenAiResponseApiUtils['processMessages']>['input']): string {
            return JSON.stringify(input);
        }

        function findCompactionItem(input: ReturnType<OpenAiResponseApiUtils['processMessages']>['input']): { type: string; encrypted_content: string } | undefined {
            return input.find((item): item is { type: 'compaction'; encrypted_content: string } =>
                (item as { type?: string }).type === 'compaction') as { type: string; encrypted_content: string } | undefined;
        }

        it('replays the openai-responses compaction marker and drops the prefix before it', () => {
            const utils = new OpenAiResponseApiUtils();
            const messages: LanguageModelMessage[] = [
                userMessage('user A'),
                aiMessage('ai B'),
                compactionMessage('openai-responses', 'enc1'),
                userMessage('user C')
            ];

            const { input } = utils.processMessages(messages, 'developer', 'gpt-5');

            const compaction = findCompactionItem(input);
            expect(compaction, 'compaction item present').to.not.equal(undefined);
            expect(compaction!.encrypted_content).to.equal('enc1');
            expect(input[0]).to.deep.include({ type: 'compaction', encrypted_content: 'enc1' });

            const serialized = textOf(input);
            expect(serialized).to.contain('user C');
            expect(serialized).to.not.contain('user A');
            expect(serialized).to.not.contain('ai B');
        });

        it('only replays the LAST openai-responses marker and drops everything before it', () => {
            const utils = new OpenAiResponseApiUtils();
            const messages: LanguageModelMessage[] = [
                userMessage('user A'),
                compactionMessage('openai-responses', 'enc1'),
                userMessage('user B'),
                compactionMessage('openai-responses', 'enc2'),
                userMessage('user C')
            ];

            const { input } = utils.processMessages(messages, 'developer', 'gpt-5');

            const compactionItems = input.filter(item => (item as { type?: string }).type === 'compaction');
            expect(compactionItems).to.have.length(1);
            expect((compactionItems[0] as { encrypted_content: string }).encrypted_content).to.equal('enc2');

            const serialized = textOf(input);
            expect(serialized).to.contain('user C');
            expect(serialized).to.not.contain('user A');
            expect(serialized).to.not.contain('user B');
            expect(serialized).to.not.contain('enc1');
        });

        it('skips a foreign-provider compaction marker without dropping the prefix', () => {
            const utils = new OpenAiResponseApiUtils();
            const messages: LanguageModelMessage[] = [
                userMessage('user A'),
                compactionMessage('anthropic', 'enc1'),
                userMessage('user B')
            ];

            const { input } = utils.processMessages(messages, 'developer', 'gpt-5');

            expect(findCompactionItem(input), 'no compaction item for foreign provider').to.equal(undefined);
            const serialized = textOf(input);
            expect(serialized).to.contain('user A');
            expect(serialized).to.contain('user B');
            expect(serialized).to.not.contain('enc1');
        });

        it('converts all messages unchanged when there is no compaction marker', () => {
            const utils = new OpenAiResponseApiUtils();
            const messages: LanguageModelMessage[] = [userMessage('user A'), aiMessage('ai B'), userMessage('user C')];

            const { input } = utils.processMessages(messages, 'developer', 'gpt-5');

            expect(findCompactionItem(input)).to.equal(undefined);
            const serialized = textOf(input);
            expect(serialized).to.contain('user A');
            expect(serialized).to.contain('ai B');
            expect(serialized).to.contain('user C');
        });
    });
});
