/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import { expect, it, describe, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { CoreToolCallStatus } from '../scheduler/types.js';
import { ChatRecordingService } from './chatRecordingService.js';
import { getProjectHash } from '../utils/paths.js';
vi.mock('../utils/paths.js');
vi.mock('node:crypto', () => {
    let count = 0;
    return {
        randomUUID: vi.fn(() => `test-uuid-${count++}`),
        createHash: vi.fn(() => ({
            update: vi.fn(() => ({
                digest: vi.fn(() => 'mocked-hash'),
            })),
        })),
    };
});
describe('ChatRecordingService', () => {
    let chatRecordingService;
    let mockConfig;
    let testTempDir;
    beforeEach(async () => {
        testTempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'chat-recording-test-'));
        mockConfig = {
            getSessionId: vi.fn().mockReturnValue('test-session-id'),
            getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
            storage: {
                getProjectTempDir: vi.fn().mockReturnValue(testTempDir),
            },
            getModel: vi.fn().mockReturnValue('gemini-pro'),
            getDebugMode: vi.fn().mockReturnValue(false),
            getToolRegistry: vi.fn().mockReturnValue({
                getTool: vi.fn().mockReturnValue({
                    displayName: 'Test Tool',
                    description: 'A test tool',
                    isOutputMarkdown: false,
                }),
            }),
        };
        vi.mocked(getProjectHash).mockReturnValue('test-project-hash');
        chatRecordingService = new ChatRecordingService(mockConfig);
    });
    afterEach(async () => {
        vi.restoreAllMocks();
        if (testTempDir) {
            await fs.promises.rm(testTempDir, { recursive: true, force: true });
        }
    });
    describe('initialize', () => {
        it('should create a new session if none is provided', () => {
            chatRecordingService.initialize();
            chatRecordingService.recordMessage({
                type: 'user',
                content: 'ping',
                model: 'm',
            });
            const chatsDir = path.join(testTempDir, 'chats');
            expect(fs.existsSync(chatsDir)).toBe(true);
            const files = fs.readdirSync(chatsDir);
            expect(files.length).toBeGreaterThan(0);
            expect(files[0]).toMatch(/^session-.*-test-ses\.json$/);
        });
        it('should include the conversation kind when specified', () => {
            chatRecordingService.initialize(undefined, 'subagent');
            chatRecordingService.recordMessage({
                type: 'user',
                content: 'ping',
                model: 'm',
            });
            const sessionFile = chatRecordingService.getConversationFilePath();
            const conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
            expect(conversation.kind).toBe('subagent');
        });
        it('should resume from an existing session if provided', () => {
            const chatsDir = path.join(testTempDir, 'chats');
            fs.mkdirSync(chatsDir, { recursive: true });
            const sessionFile = path.join(chatsDir, 'session.json');
            const initialData = {
                sessionId: 'old-session-id',
                projectHash: 'test-project-hash',
                messages: [],
            };
            fs.writeFileSync(sessionFile, JSON.stringify(initialData));
            chatRecordingService.initialize({
                filePath: sessionFile,
                conversation: {
                    sessionId: 'old-session-id',
                },
            });
            const conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
            expect(conversation.sessionId).toBe('old-session-id');
        });
    });
    describe('recordMessage', () => {
        beforeEach(() => {
            chatRecordingService.initialize();
        });
        it('should record a new message', () => {
            chatRecordingService.recordMessage({
                type: 'user',
                content: 'Hello',
                displayContent: 'User Hello',
                model: 'gemini-pro',
            });
            const sessionFile = chatRecordingService.getConversationFilePath();
            const conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
            expect(conversation.messages).toHaveLength(1);
            expect(conversation.messages[0].content).toBe('Hello');
            expect(conversation.messages[0].displayContent).toBe('User Hello');
            expect(conversation.messages[0].type).toBe('user');
        });
        it('should create separate messages when recording multiple messages', () => {
            chatRecordingService.recordMessage({
                type: 'user',
                content: 'World',
                model: 'gemini-pro',
            });
            const sessionFile = chatRecordingService.getConversationFilePath();
            const conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
            expect(conversation.messages).toHaveLength(1);
            expect(conversation.messages[0].content).toBe('World');
        });
    });
    describe('recordThought', () => {
        it('should queue a thought', () => {
            chatRecordingService.initialize();
            chatRecordingService.recordThought({
                subject: 'Thinking',
                description: 'Thinking...',
            });
            // @ts-expect-error private property
            expect(chatRecordingService.queuedThoughts).toHaveLength(1);
            // @ts-expect-error private property
            expect(chatRecordingService.queuedThoughts[0].subject).toBe('Thinking');
        });
    });
    describe('recordMessageTokens', () => {
        beforeEach(() => {
            chatRecordingService.initialize();
        });
        it('should update the last message with token info', () => {
            chatRecordingService.recordMessage({
                type: 'gemini',
                content: 'Response',
                model: 'gemini-pro',
            });
            chatRecordingService.recordMessageTokens({
                promptTokenCount: 1,
                candidatesTokenCount: 2,
                totalTokenCount: 3,
                cachedContentTokenCount: 0,
            });
            const sessionFile = chatRecordingService.getConversationFilePath();
            const conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
            const geminiMsg = conversation.messages[0];
            expect(geminiMsg.tokens).toEqual({
                input: 1,
                output: 2,
                total: 3,
                cached: 0,
                thoughts: 0,
                tool: 0,
            });
        });
        it('should queue token info if the last message already has tokens', () => {
            chatRecordingService.recordMessage({
                type: 'gemini',
                content: 'Response',
                model: 'gemini-pro',
            });
            chatRecordingService.recordMessageTokens({
                promptTokenCount: 1,
                candidatesTokenCount: 1,
                totalTokenCount: 2,
                cachedContentTokenCount: 0,
            });
            chatRecordingService.recordMessageTokens({
                promptTokenCount: 2,
                candidatesTokenCount: 2,
                totalTokenCount: 4,
                cachedContentTokenCount: 0,
            });
            // @ts-expect-error private property
            expect(chatRecordingService.queuedTokens).toEqual({
                input: 2,
                output: 2,
                total: 4,
                cached: 0,
                thoughts: 0,
                tool: 0,
            });
        });
    });
    describe('recordToolCalls', () => {
        beforeEach(() => {
            chatRecordingService.initialize();
        });
        it('should add new tool calls to the last message', () => {
            chatRecordingService.recordMessage({
                type: 'gemini',
                content: '',
                model: 'gemini-pro',
            });
            const toolCall = {
                id: 'tool-1',
                name: 'testTool',
                args: {},
                status: CoreToolCallStatus.AwaitingApproval,
                timestamp: new Date().toISOString(),
            };
            chatRecordingService.recordToolCalls('gemini-pro', [toolCall]);
            const sessionFile = chatRecordingService.getConversationFilePath();
            const conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
            const geminiMsg = conversation.messages[0];
            expect(geminiMsg.toolCalls).toHaveLength(1);
            expect(geminiMsg.toolCalls[0].name).toBe('testTool');
        });
        it('should create a new message if the last message is not from gemini', () => {
            chatRecordingService.recordMessage({
                type: 'user',
                content: 'call a tool',
                model: 'gemini-pro',
            });
            const toolCall = {
                id: 'tool-1',
                name: 'testTool',
                args: {},
                status: CoreToolCallStatus.AwaitingApproval,
                timestamp: new Date().toISOString(),
            };
            chatRecordingService.recordToolCalls('gemini-pro', [toolCall]);
            const sessionFile = chatRecordingService.getConversationFilePath();
            const conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
            expect(conversation.messages).toHaveLength(2);
            expect(conversation.messages[1].type).toBe('gemini');
            expect(conversation.messages[1]
                .toolCalls).toHaveLength(1);
        });
    });
    describe('deleteSession', () => {
        it('should delete the session file, tool outputs, session directory, and logs if they exist', () => {
            const sessionId = 'test-session-id';
            const chatsDir = path.join(testTempDir, 'chats');
            const logsDir = path.join(testTempDir, 'logs');
            const toolOutputsDir = path.join(testTempDir, 'tool-outputs');
            const sessionDir = path.join(testTempDir, sessionId);
            fs.mkdirSync(chatsDir, { recursive: true });
            fs.mkdirSync(logsDir, { recursive: true });
            fs.mkdirSync(toolOutputsDir, { recursive: true });
            fs.mkdirSync(sessionDir, { recursive: true });
            const sessionFile = path.join(chatsDir, `${sessionId}.json`);
            fs.writeFileSync(sessionFile, '{}');
            const logFile = path.join(logsDir, `session-${sessionId}.jsonl`);
            fs.writeFileSync(logFile, '{}');
            const toolOutputDir = path.join(toolOutputsDir, `session-${sessionId}`);
            fs.mkdirSync(toolOutputDir, { recursive: true });
            chatRecordingService.deleteSession(sessionId);
            expect(fs.existsSync(sessionFile)).toBe(false);
            expect(fs.existsSync(logFile)).toBe(false);
            expect(fs.existsSync(toolOutputDir)).toBe(false);
            expect(fs.existsSync(sessionDir)).toBe(false);
        });
        it('should not throw if session file does not exist', () => {
            expect(() => chatRecordingService.deleteSession('non-existent')).not.toThrow();
        });
    });
    describe('recordDirectories', () => {
        beforeEach(() => {
            chatRecordingService.initialize();
        });
        it('should save directories to the conversation', () => {
            chatRecordingService.recordMessage({
                type: 'user',
                content: 'ping',
                model: 'm',
            });
            chatRecordingService.recordDirectories([
                '/path/to/dir1',
                '/path/to/dir2',
            ]);
            const sessionFile = chatRecordingService.getConversationFilePath();
            const conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
            expect(conversation.directories).toEqual([
                '/path/to/dir1',
                '/path/to/dir2',
            ]);
        });
        it('should overwrite existing directories', () => {
            chatRecordingService.recordMessage({
                type: 'user',
                content: 'ping',
                model: 'm',
            });
            chatRecordingService.recordDirectories(['/old/dir']);
            chatRecordingService.recordDirectories(['/new/dir1', '/new/dir2']);
            const sessionFile = chatRecordingService.getConversationFilePath();
            const conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
            expect(conversation.directories).toEqual(['/new/dir1', '/new/dir2']);
        });
    });
    describe('rewindTo', () => {
        it('should rewind the conversation to a specific message ID', () => {
            chatRecordingService.initialize();
            // Record some messages
            chatRecordingService.recordMessage({
                type: 'user',
                content: 'msg1',
                model: 'm',
            });
            chatRecordingService.recordMessage({
                type: 'gemini',
                content: 'msg2',
                model: 'm',
            });
            chatRecordingService.recordMessage({
                type: 'user',
                content: 'msg3',
                model: 'm',
            });
            const sessionFile = chatRecordingService.getConversationFilePath();
            let conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
            const secondMsgId = conversation.messages[1].id;
            const result = chatRecordingService.rewindTo(secondMsgId);
            expect(result).not.toBeNull();
            expect(result.messages).toHaveLength(1);
            expect(result.messages[0].content).toBe('msg1');
            conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
            expect(conversation.messages).toHaveLength(1);
        });
        it('should return the original conversation if the message ID is not found', () => {
            chatRecordingService.initialize();
            chatRecordingService.recordMessage({
                type: 'user',
                content: 'msg1',
                model: 'm',
            });
            const result = chatRecordingService.rewindTo('non-existent');
            expect(result).not.toBeNull();
            expect(result.messages).toHaveLength(1);
        });
    });
    describe('ENOSPC (disk full) graceful degradation - issue #16266', () => {
        it('should disable recording and not throw when ENOSPC occurs during initialize', () => {
            const enospcError = new Error('ENOSPC: no space left on device');
            enospcError.code = 'ENOSPC';
            const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {
                throw enospcError;
            });
            // Should not throw
            expect(() => chatRecordingService.initialize()).not.toThrow();
            // Recording should be disabled (conversationFile set to null)
            expect(chatRecordingService.getConversationFilePath()).toBeNull();
            mkdirSyncSpy.mockRestore();
        });
        it('should disable recording and not throw when ENOSPC occurs during writeConversation', () => {
            chatRecordingService.initialize();
            const enospcError = new Error('ENOSPC: no space left on device');
            enospcError.code = 'ENOSPC';
            const writeFileSyncSpy = vi
                .spyOn(fs, 'writeFileSync')
                .mockImplementation(() => {
                throw enospcError;
            });
            // Should not throw when recording a message
            expect(() => chatRecordingService.recordMessage({
                type: 'user',
                content: 'Hello',
                model: 'gemini-pro',
            })).not.toThrow();
            // Recording should be disabled (conversationFile set to null)
            expect(chatRecordingService.getConversationFilePath()).toBeNull();
            writeFileSyncSpy.mockRestore();
        });
        it('should skip recording operations when recording is disabled', () => {
            chatRecordingService.initialize();
            const enospcError = new Error('ENOSPC: no space left on device');
            enospcError.code = 'ENOSPC';
            const writeFileSyncSpy = vi
                .spyOn(fs, 'writeFileSync')
                .mockImplementationOnce(() => {
                throw enospcError;
            });
            chatRecordingService.recordMessage({
                type: 'user',
                content: 'First message',
                model: 'gemini-pro',
            });
            // Reset mock to track subsequent calls
            writeFileSyncSpy.mockClear();
            // Subsequent calls should be no-ops (not call writeFileSync)
            chatRecordingService.recordMessage({
                type: 'user',
                content: 'Second message',
                model: 'gemini-pro',
            });
            chatRecordingService.recordThought({
                subject: 'Test',
                description: 'Test thought',
            });
            chatRecordingService.saveSummary('Test summary');
            // writeFileSync should not have been called for any of these
            expect(writeFileSyncSpy).not.toHaveBeenCalled();
            writeFileSyncSpy.mockRestore();
        });
        it('should return null from getConversation when recording is disabled', () => {
            chatRecordingService.initialize();
            const enospcError = new Error('ENOSPC: no space left on device');
            enospcError.code = 'ENOSPC';
            const writeFileSyncSpy = vi
                .spyOn(fs, 'writeFileSync')
                .mockImplementation(() => {
                throw enospcError;
            });
            // Trigger ENOSPC
            chatRecordingService.recordMessage({
                type: 'user',
                content: 'Hello',
                model: 'gemini-pro',
            });
            // getConversation should return null when disabled
            expect(chatRecordingService.getConversation()).toBeNull();
            expect(chatRecordingService.getConversationFilePath()).toBeNull();
            writeFileSyncSpy.mockRestore();
        });
        it('should still throw for non-ENOSPC errors', () => {
            chatRecordingService.initialize();
            const otherError = new Error('Permission denied');
            otherError.code = 'EACCES';
            const writeFileSyncSpy = vi
                .spyOn(fs, 'writeFileSync')
                .mockImplementation(() => {
                throw otherError;
            });
            // Should throw for non-ENOSPC errors
            expect(() => chatRecordingService.recordMessage({
                type: 'user',
                content: 'Hello',
                model: 'gemini-pro',
            })).toThrow('Permission denied');
            // Recording should NOT be disabled for non-ENOSPC errors (file path still exists)
            expect(chatRecordingService.getConversationFilePath()).not.toBeNull();
            writeFileSyncSpy.mockRestore();
        });
    });
    describe('updateMessagesFromHistory', () => {
        beforeEach(() => {
            chatRecordingService.initialize();
        });
        it('should update tool results from API history (masking sync)', () => {
            // 1. Record an initial message and tool call
            chatRecordingService.recordMessage({
                type: 'gemini',
                content: 'I will list the files.',
                model: 'gemini-pro',
            });
            const callId = 'tool-call-123';
            const originalResult = [{ text: 'a'.repeat(1000) }];
            chatRecordingService.recordToolCalls('gemini-pro', [
                {
                    id: callId,
                    name: 'list_files',
                    args: { path: '.' },
                    result: originalResult,
                    status: CoreToolCallStatus.Success,
                    timestamp: new Date().toISOString(),
                },
            ]);
            // 2. Prepare mock history with masked content
            const maskedSnippet = '<tool_output_masked>short preview</tool_output_masked>';
            const history = [
                {
                    role: 'model',
                    parts: [
                        { functionCall: { name: 'list_files', args: { path: '.' } } },
                    ],
                },
                {
                    role: 'user',
                    parts: [
                        {
                            functionResponse: {
                                name: 'list_files',
                                id: callId,
                                response: { output: maskedSnippet },
                            },
                        },
                    ],
                },
            ];
            // 3. Trigger sync
            chatRecordingService.updateMessagesFromHistory(history);
            // 4. Verify disk content
            const sessionFile = chatRecordingService.getConversationFilePath();
            const conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
            const geminiMsg = conversation.messages[0];
            if (geminiMsg.type !== 'gemini')
                throw new Error('Expected gemini message');
            expect(geminiMsg.toolCalls).toBeDefined();
            expect(geminiMsg.toolCalls[0].id).toBe(callId);
            // The implementation stringifies the response object
            const result = geminiMsg.toolCalls[0].result;
            if (!Array.isArray(result))
                throw new Error('Expected array result');
            const firstPart = result[0];
            expect(firstPart.functionResponse).toBeDefined();
            expect(firstPart.functionResponse.id).toBe(callId);
            expect(firstPart.functionResponse.response).toEqual({
                output: maskedSnippet,
            });
        });
        it('should preserve multi-modal sibling parts during sync', () => {
            chatRecordingService.initialize();
            const callId = 'multi-modal-call';
            const originalResult = [
                {
                    functionResponse: {
                        id: callId,
                        name: 'read_file',
                        response: { content: '...' },
                    },
                },
                { inlineData: { mimeType: 'image/png', data: 'base64...' } },
            ];
            chatRecordingService.recordMessage({
                type: 'gemini',
                content: '',
                model: 'gemini-pro',
            });
            chatRecordingService.recordToolCalls('gemini-pro', [
                {
                    id: callId,
                    name: 'read_file',
                    args: { path: 'image.png' },
                    result: originalResult,
                    status: CoreToolCallStatus.Success,
                    timestamp: new Date().toISOString(),
                },
            ]);
            const maskedSnippet = '<masked>';
            const history = [
                {
                    role: 'user',
                    parts: [
                        {
                            functionResponse: {
                                name: 'read_file',
                                id: callId,
                                response: { output: maskedSnippet },
                            },
                        },
                        { inlineData: { mimeType: 'image/png', data: 'base64...' } },
                    ],
                },
            ];
            chatRecordingService.updateMessagesFromHistory(history);
            const sessionFile = chatRecordingService.getConversationFilePath();
            const conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
            const lastMsg = conversation.messages[0];
            const result = lastMsg.toolCalls[0].result;
            expect(result).toHaveLength(2);
            expect(result[0].functionResponse.response).toEqual({
                output: maskedSnippet,
            });
            expect(result[1].inlineData).toBeDefined();
            expect(result[1].inlineData.mimeType).toBe('image/png');
        });
        it('should handle parts appearing BEFORE the functionResponse in a content block', () => {
            chatRecordingService.initialize();
            const callId = 'prefix-part-call';
            chatRecordingService.recordMessage({
                type: 'gemini',
                content: '',
                model: 'gemini-pro',
            });
            chatRecordingService.recordToolCalls('gemini-pro', [
                {
                    id: callId,
                    name: 'read_file',
                    args: { path: 'test.txt' },
                    result: [],
                    status: CoreToolCallStatus.Success,
                    timestamp: new Date().toISOString(),
                },
            ]);
            const history = [
                {
                    role: 'user',
                    parts: [
                        { text: 'Prefix metadata or text' },
                        {
                            functionResponse: {
                                name: 'read_file',
                                id: callId,
                                response: { output: 'file content' },
                            },
                        },
                    ],
                },
            ];
            chatRecordingService.updateMessagesFromHistory(history);
            const sessionFile = chatRecordingService.getConversationFilePath();
            const conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
            const lastMsg = conversation.messages[0];
            const result = lastMsg.toolCalls[0].result;
            expect(result).toHaveLength(2);
            expect(result[0].text).toBe('Prefix metadata or text');
            expect(result[1].functionResponse.id).toBe(callId);
        });
    });
    describe('ENOENT (missing directory) handling', () => {
        it('should ensure directory exists before writing conversation file', () => {
            chatRecordingService.initialize();
            const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync');
            const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync');
            chatRecordingService.recordMessage({
                type: 'user',
                content: 'Hello after dir cleanup',
                model: 'gemini-pro',
            });
            // mkdirSync should be called with the parent directory and recursive option
            const conversationFile = chatRecordingService.getConversationFilePath();
            expect(mkdirSyncSpy).toHaveBeenCalledWith(path.dirname(conversationFile), { recursive: true });
            // mkdirSync should be called before writeFileSync
            const mkdirCallOrder = mkdirSyncSpy.mock.invocationCallOrder;
            const writeCallOrder = writeFileSyncSpy.mock.invocationCallOrder;
            const lastMkdir = mkdirCallOrder[mkdirCallOrder.length - 1];
            const lastWrite = writeCallOrder[writeCallOrder.length - 1];
            expect(lastMkdir).toBeLessThan(lastWrite);
            mkdirSyncSpy.mockRestore();
            writeFileSyncSpy.mockRestore();
        });
    });
});
//# sourceMappingURL=chatRecordingService.test.js.map