/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import {} from '../scheduler/types.js';
import {} from '../utils/thoughtUtils.js';
import { getProjectHash } from '../utils/paths.js';
import { sanitizeFilenamePart } from '../utils/fileUtils.js';
import path from 'node:path';
import fs from 'node:fs';
import { randomUUID } from 'node:crypto';
import { debugLogger } from '../utils/debugLogger.js';
export const SESSION_FILE_PREFIX = 'session-';
/**
 * Warning message shown when recording is disabled due to disk full.
 */
const ENOSPC_WARNING_MESSAGE = 'Chat recording disabled: No space left on device. ' +
    'The conversation will continue but will not be saved to disk. ' +
    'Free up disk space and restart to enable recording.';
/**
 * Service for automatically recording chat conversations to disk.
 *
 * This service provides comprehensive conversation recording that captures:
 * - All user and assistant messages
 * - Tool calls and their execution results
 * - Token usage statistics
 * - Assistant thoughts and reasoning
 *
 * Sessions are stored as JSON files in ~/.gemini/tmp/<project_hash>/chats/
 */
export class ChatRecordingService {
    conversationFile = null;
    cachedLastConvData = null;
    cachedConversation = null;
    sessionId;
    projectHash;
    kind;
    queuedThoughts = [];
    queuedTokens = null;
    context;
    constructor(context) {
        this.context = context;
        this.sessionId = context.promptId;
        this.projectHash = getProjectHash(context.config.getProjectRoot());
    }
    /**
     * Initializes the chat recording service: creates a new conversation file and associates it with
     * this service instance, or resumes from an existing session if resumedSessionData is provided.
     *
     * @param resumedSessionData Data from a previous session to resume from.
     * @param kind The kind of conversation (main or subagent).
     */
    initialize(resumedSessionData, kind) {
        try {
            this.kind = kind;
            if (resumedSessionData) {
                // Resume from existing session
                this.conversationFile = resumedSessionData.filePath;
                this.sessionId = resumedSessionData.conversation.sessionId;
                this.kind = resumedSessionData.conversation.kind;
                // Update the session ID in the existing file
                this.updateConversation((conversation) => {
                    conversation.sessionId = this.sessionId;
                });
                // Clear any cached data to force fresh reads
                this.cachedLastConvData = null;
                this.cachedConversation = null;
            }
            else {
                // Create new session
                this.sessionId = this.context.promptId;
                const chatsDir = path.join(this.context.config.storage.getProjectTempDir(), 'chats');
                fs.mkdirSync(chatsDir, { recursive: true });
                const timestamp = new Date()
                    .toISOString()
                    .slice(0, 16)
                    .replace(/:/g, '-');
                const filename = `${SESSION_FILE_PREFIX}${timestamp}-${this.sessionId.slice(0, 8)}.json`;
                this.conversationFile = path.join(chatsDir, filename);
                this.writeConversation({
                    sessionId: this.sessionId,
                    projectHash: this.projectHash,
                    startTime: new Date().toISOString(),
                    lastUpdated: new Date().toISOString(),
                    messages: [],
                    kind: this.kind,
                });
            }
            // Clear any queued data since this is a fresh start
            this.queuedThoughts = [];
            this.queuedTokens = null;
        }
        catch (error) {
            // Handle disk full (ENOSPC) gracefully - disable recording but allow CLI to continue
            if (error instanceof Error &&
                'code' in error &&
                // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
                error.code === 'ENOSPC') {
                this.conversationFile = null;
                debugLogger.warn(ENOSPC_WARNING_MESSAGE);
                return; // Don't throw - allow the CLI to continue
            }
            debugLogger.error('Error initializing chat recording service:', error);
            throw error;
        }
    }
    getLastMessage(conversation) {
        return conversation.messages.at(-1);
    }
    newMessage(type, content, displayContent) {
        return {
            id: randomUUID(),
            timestamp: new Date().toISOString(),
            type,
            content,
            displayContent,
        };
    }
    /**
     * Records a message in the conversation.
     */
    recordMessage(message) {
        if (!this.conversationFile)
            return;
        try {
            this.updateConversation((conversation) => {
                const msg = this.newMessage(message.type, message.content, message.displayContent);
                if (msg.type === 'gemini') {
                    // If it's a new Gemini message then incorporate any queued thoughts.
                    conversation.messages.push({
                        ...msg,
                        thoughts: this.queuedThoughts,
                        tokens: this.queuedTokens,
                        model: message.model,
                    });
                    this.queuedThoughts = [];
                    this.queuedTokens = null;
                }
                else {
                    // Or else just add it.
                    conversation.messages.push(msg);
                }
            });
        }
        catch (error) {
            debugLogger.error('Error saving message to chat history.', error);
            throw error;
        }
    }
    /**
     * Records a thought from the assistant's reasoning process.
     */
    recordThought(thought) {
        if (!this.conversationFile)
            return;
        try {
            this.queuedThoughts.push({
                ...thought,
                timestamp: new Date().toISOString(),
            });
        }
        catch (error) {
            debugLogger.error('Error saving thought to chat history.', error);
            throw error;
        }
    }
    /**
     * Updates the tokens for the last message in the conversation (which should be by Gemini).
     */
    recordMessageTokens(respUsageMetadata) {
        if (!this.conversationFile)
            return;
        try {
            const tokens = {
                input: respUsageMetadata.promptTokenCount ?? 0,
                output: respUsageMetadata.candidatesTokenCount ?? 0,
                cached: respUsageMetadata.cachedContentTokenCount ?? 0,
                thoughts: respUsageMetadata.thoughtsTokenCount ?? 0,
                tool: respUsageMetadata.toolUsePromptTokenCount ?? 0,
                total: respUsageMetadata.totalTokenCount ?? 0,
            };
            const conversation = this.readConversation();
            const lastMsg = this.getLastMessage(conversation);
            // If the last message already has token info, it's because this new token info is for a
            // new message that hasn't been recorded yet.
            if (lastMsg && lastMsg.type === 'gemini' && !lastMsg.tokens) {
                lastMsg.tokens = tokens;
                this.queuedTokens = null;
                this.writeConversation(conversation);
            }
            else {
                // Only queue tokens in memory; no disk I/O needed since the
                // conversation record itself hasn't changed.
                this.queuedTokens = tokens;
            }
        }
        catch (error) {
            debugLogger.error('Error updating message tokens in chat history.', error);
            throw error;
        }
    }
    /**
     * Adds tool calls to the last message in the conversation (which should be by Gemini).
     * This method enriches tool calls with metadata from the ToolRegistry.
     */
    recordToolCalls(model, toolCalls) {
        if (!this.conversationFile)
            return;
        // Enrich tool calls with metadata from the ToolRegistry
        const toolRegistry = this.context.toolRegistry;
        const enrichedToolCalls = toolCalls.map((toolCall) => {
            const toolInstance = toolRegistry.getTool(toolCall.name);
            return {
                ...toolCall,
                displayName: toolInstance?.displayName || toolCall.name,
                description: toolCall.description?.trim() || toolInstance?.description || '',
                renderOutputAsMarkdown: toolInstance?.isOutputMarkdown || false,
            };
        });
        try {
            this.updateConversation((conversation) => {
                const lastMsg = this.getLastMessage(conversation);
                // If a tool call was made, but the last message isn't from Gemini, it's because Gemini is
                // calling tools without starting the message with text.  So the user submits a prompt, and
                // Gemini immediately calls a tool (maybe with some thinking first).  In that case, create
                // a new empty Gemini message.
                // Also if there are any queued thoughts, it means this tool call(s) is from a new Gemini
                // message--because it's thought some more since we last, if ever, created a new Gemini
                // message from tool calls, when we dequeued the thoughts.
                if (!lastMsg ||
                    lastMsg.type !== 'gemini' ||
                    this.queuedThoughts.length > 0) {
                    const newMsg = {
                        ...this.newMessage('gemini', ''),
                        // This isn't strictly necessary, but TypeScript apparently can't
                        // tell that the first parameter to newMessage() becomes the
                        // resulting message's type, and so it thinks that toolCalls may
                        // not be present.  Confirming the type here satisfies it.
                        type: 'gemini',
                        toolCalls: enrichedToolCalls,
                        thoughts: this.queuedThoughts,
                        model,
                    };
                    // If there are any queued thoughts join them to this message.
                    if (this.queuedThoughts.length > 0) {
                        newMsg.thoughts = this.queuedThoughts;
                        this.queuedThoughts = [];
                    }
                    // If there's any queued tokens info join it to this message.
                    if (this.queuedTokens) {
                        newMsg.tokens = this.queuedTokens;
                        this.queuedTokens = null;
                    }
                    conversation.messages.push(newMsg);
                }
                else {
                    // The last message is an existing Gemini message that we need to update.
                    // Update any existing tool call entries.
                    if (!lastMsg.toolCalls) {
                        lastMsg.toolCalls = [];
                    }
                    lastMsg.toolCalls = lastMsg.toolCalls.map((toolCall) => {
                        // If there are multiple tool calls with the same ID, this will take the first one.
                        const incomingToolCall = toolCalls.find((tc) => tc.id === toolCall.id);
                        if (incomingToolCall) {
                            // Merge in the new data to keep preserve thoughts, etc., that were assigned to older
                            // versions of the tool call.
                            return { ...toolCall, ...incomingToolCall };
                        }
                        else {
                            return toolCall;
                        }
                    });
                    // Add any new tools calls that aren't in the message yet.
                    for (const toolCall of enrichedToolCalls) {
                        const existingToolCall = lastMsg.toolCalls.find((tc) => tc.id === toolCall.id);
                        if (!existingToolCall) {
                            lastMsg.toolCalls.push(toolCall);
                        }
                    }
                }
            });
        }
        catch (error) {
            debugLogger.error('Error adding tool call to message in chat history.', error);
            throw error;
        }
    }
    /**
     * Loads up the conversation record from disk.
     *
     * NOTE: The returned object is the live in-memory cache reference.
     * Any mutations to it will be visible to all subsequent reads.
     * Callers that mutate the result MUST call writeConversation() to
     * persist the changes to disk.
     */
    readConversation() {
        if (this.cachedConversation) {
            return this.cachedConversation;
        }
        try {
            this.cachedLastConvData = fs.readFileSync(this.conversationFile, 'utf8');
            // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
            this.cachedConversation = JSON.parse(this.cachedLastConvData);
            if (!this.cachedConversation) {
                // File is corrupt or contains "null". Fallback to an empty conversation.
                this.cachedConversation = {
                    sessionId: this.sessionId,
                    projectHash: this.projectHash,
                    startTime: new Date().toISOString(),
                    lastUpdated: new Date().toISOString(),
                    messages: [],
                    kind: this.kind,
                };
            }
            return this.cachedConversation;
        }
        catch (error) {
            // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
            if (error.code !== 'ENOENT') {
                debugLogger.error('Error reading conversation file.', error);
                throw error;
            }
            // Placeholder empty conversation if file doesn't exist.
            this.cachedConversation = {
                sessionId: this.sessionId,
                projectHash: this.projectHash,
                startTime: new Date().toISOString(),
                lastUpdated: new Date().toISOString(),
                messages: [],
                kind: this.kind,
            };
            return this.cachedConversation;
        }
    }
    /**
     * Saves the conversation record; overwrites the file.
     */
    writeConversation(conversation, { allowEmpty = false } = {}) {
        try {
            if (!this.conversationFile)
                return;
            // Don't write the file yet until there's at least one message.
            if (conversation.messages.length === 0 && !allowEmpty)
                return;
            const newContent = JSON.stringify(conversation, null, 2);
            // Skip the disk write if nothing actually changed (e.g.
            // updateMessagesFromHistory found no matching tool calls to update).
            // Compare before updating lastUpdated so the timestamp doesn't
            // cause a false diff.
            if (this.cachedLastConvData === newContent)
                return;
            this.cachedConversation = conversation;
            conversation.lastUpdated = new Date().toISOString();
            const contentToWrite = JSON.stringify(conversation, null, 2);
            this.cachedLastConvData = contentToWrite;
            // Ensure directory exists before writing (handles cases where temp dir was cleaned)
            fs.mkdirSync(path.dirname(this.conversationFile), { recursive: true });
            fs.writeFileSync(this.conversationFile, contentToWrite);
        }
        catch (error) {
            // Handle disk full (ENOSPC) gracefully - disable recording but allow conversation to continue
            if (error instanceof Error &&
                'code' in error &&
                // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
                error.code === 'ENOSPC') {
                this.conversationFile = null;
                this.cachedConversation = null;
                debugLogger.warn(ENOSPC_WARNING_MESSAGE);
                return; // Don't throw - allow the conversation to continue
            }
            debugLogger.error('Error writing conversation file.', error);
            throw error;
        }
    }
    /**
     * Convenient helper for updating the conversation without file reading and writing and time
     * updating boilerplate.
     */
    updateConversation(updateFn) {
        const conversation = this.readConversation();
        updateFn(conversation);
        this.writeConversation(conversation);
    }
    /**
     * Saves a summary for the current session.
     */
    saveSummary(summary) {
        if (!this.conversationFile)
            return;
        try {
            this.updateConversation((conversation) => {
                conversation.summary = summary;
            });
        }
        catch (error) {
            debugLogger.error('Error saving summary to chat history.', error);
            // Don't throw - we want graceful degradation
        }
    }
    /**
     * Records workspace directories to the session file.
     * Called when directories are added via /dir add.
     */
    recordDirectories(directories) {
        if (!this.conversationFile)
            return;
        try {
            this.updateConversation((conversation) => {
                conversation.directories = [...directories];
            });
        }
        catch (error) {
            debugLogger.error('Error saving directories to chat history.', error);
            // Don't throw - we want graceful degradation
        }
    }
    /**
     * Gets the current conversation data (for summary generation).
     */
    getConversation() {
        if (!this.conversationFile)
            return null;
        try {
            return this.readConversation();
        }
        catch (error) {
            debugLogger.error('Error reading conversation for summary.', error);
            return null;
        }
    }
    /**
     * Gets the path to the current conversation file.
     * Returns null if the service hasn't been initialized yet or recording is disabled.
     */
    getConversationFilePath() {
        return this.conversationFile;
    }
    /**
     * Deletes a session file by sessionId, filename, or basename.
     * Derives an 8-character shortId to find and delete all associated files
     * (parent and subagents).
     *
     * @throws {Error} If shortId validation fails.
     */
    deleteSession(sessionIdOrBasename) {
        try {
            const tempDir = this.context.config.storage.getProjectTempDir();
            const chatsDir = path.join(tempDir, 'chats');
            const shortId = this.deriveShortId(sessionIdOrBasename);
            if (!fs.existsSync(chatsDir)) {
                return; // Nothing to delete
            }
            const matchingFiles = this.getMatchingSessionFiles(chatsDir, shortId);
            for (const file of matchingFiles) {
                this.deleteSessionAndArtifacts(chatsDir, file, tempDir);
            }
        }
        catch (error) {
            debugLogger.error('Error deleting session file.', error);
            throw error;
        }
    }
    /**
     * Derives an 8-character shortId from a sessionId, filename, or basename.
     */
    deriveShortId(sessionIdOrBasename) {
        let shortId = sessionIdOrBasename;
        if (sessionIdOrBasename.startsWith(SESSION_FILE_PREFIX)) {
            const withoutExt = sessionIdOrBasename.replace('.json', '');
            const parts = withoutExt.split('-');
            shortId = parts[parts.length - 1];
        }
        else if (sessionIdOrBasename.length >= 8) {
            shortId = sessionIdOrBasename.slice(0, 8);
        }
        else {
            throw new Error('Invalid sessionId or basename provided for deletion');
        }
        if (shortId.length !== 8) {
            throw new Error('Derived shortId must be exactly 8 characters');
        }
        return shortId;
    }
    /**
     * Finds all session files matching the pattern session-*-<shortId>.json
     */
    getMatchingSessionFiles(chatsDir, shortId) {
        const files = fs.readdirSync(chatsDir);
        return files.filter((f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith(`-${shortId}.json`));
    }
    /**
     * Deletes a single session file and its associated logs, tool-outputs, and directory.
     */
    deleteSessionAndArtifacts(chatsDir, file, tempDir) {
        const filePath = path.join(chatsDir, file);
        try {
            const fileContent = fs.readFileSync(filePath, 'utf8');
            const content = JSON.parse(fileContent);
            let fullSessionId;
            if (content && typeof content === 'object' && 'sessionId' in content) {
                const id = content['sessionId'];
                if (typeof id === 'string') {
                    fullSessionId = id;
                }
            }
            // Delete the session file
            fs.unlinkSync(filePath);
            if (fullSessionId) {
                this.deleteSessionLogs(fullSessionId, tempDir);
                this.deleteSessionToolOutputs(fullSessionId, tempDir);
                this.deleteSessionDirectory(fullSessionId, tempDir);
            }
        }
        catch (error) {
            debugLogger.error(`Error deleting associated file ${file}:`, error);
        }
    }
    /**
     * Cleans up activity logs for a session.
     */
    deleteSessionLogs(sessionId, tempDir) {
        const logsDir = path.join(tempDir, 'logs');
        const safeSessionId = sanitizeFilenamePart(sessionId);
        const logPath = path.join(logsDir, `session-${safeSessionId}.jsonl`);
        if (fs.existsSync(logPath) && logPath.startsWith(logsDir)) {
            fs.unlinkSync(logPath);
        }
    }
    /**
     * Cleans up tool outputs for a session.
     */
    deleteSessionToolOutputs(sessionId, tempDir) {
        const safeSessionId = sanitizeFilenamePart(sessionId);
        const toolOutputDir = path.join(tempDir, 'tool-outputs', `session-${safeSessionId}`);
        const toolOutputsBase = path.join(tempDir, 'tool-outputs');
        if (fs.existsSync(toolOutputDir) &&
            toolOutputDir.startsWith(toolOutputsBase)) {
            fs.rmSync(toolOutputDir, { recursive: true, force: true });
        }
    }
    /**
     * Cleans up the session-specific directory.
     */
    deleteSessionDirectory(sessionId, tempDir) {
        const safeSessionId = sanitizeFilenamePart(sessionId);
        const sessionDir = path.join(tempDir, safeSessionId);
        if (fs.existsSync(sessionDir) && sessionDir.startsWith(tempDir)) {
            fs.rmSync(sessionDir, { recursive: true, force: true });
        }
    }
    /**
     * Rewinds the conversation to the state just before the specified message ID.
     * All messages from (and including) the specified ID onwards are removed.
     */
    rewindTo(messageId) {
        if (!this.conversationFile) {
            return null;
        }
        const conversation = this.readConversation();
        const messageIndex = conversation.messages.findIndex((m) => m.id === messageId);
        if (messageIndex === -1) {
            debugLogger.error('Message to rewind to not found in conversation history');
            return conversation;
        }
        conversation.messages = conversation.messages.slice(0, messageIndex);
        this.writeConversation(conversation, { allowEmpty: true });
        return conversation;
    }
    /**
     * Updates the conversation history based on the provided API Content array.
     * This is used to persist changes made to the history (like masking) back to disk.
     */
    updateMessagesFromHistory(history) {
        if (!this.conversationFile)
            return;
        try {
            this.updateConversation((conversation) => {
                // Create a map of tool results from the API history for quick lookup by call ID.
                // We store the full list of parts associated with each tool call ID to preserve
                // multi-modal data and proper trajectory structure.
                const partsMap = new Map();
                for (const content of history) {
                    if (content.role === 'user' && content.parts) {
                        // Find all unique call IDs in this message
                        const callIds = content.parts
                            .map((p) => p.functionResponse?.id)
                            .filter((id) => !!id);
                        if (callIds.length === 0)
                            continue;
                        // Use the first ID as a seed to capture any "leading" non-ID parts
                        // in this specific content block.
                        let currentCallId = callIds[0];
                        for (const part of content.parts) {
                            if (part.functionResponse?.id) {
                                currentCallId = part.functionResponse.id;
                            }
                            if (!partsMap.has(currentCallId)) {
                                partsMap.set(currentCallId, []);
                            }
                            partsMap.get(currentCallId).push(part);
                        }
                    }
                }
                // Update the conversation records tool results if they've changed.
                for (const message of conversation.messages) {
                    if (message.type === 'gemini' && message.toolCalls) {
                        for (const toolCall of message.toolCalls) {
                            const newParts = partsMap.get(toolCall.id);
                            if (newParts !== undefined) {
                                // Store the results as proper Parts (including functionResponse)
                                // instead of stringifying them as text parts. This ensures the
                                // tool trajectory is correctly reconstructed upon session resumption.
                                toolCall.result = newParts;
                            }
                        }
                    }
                }
            });
        }
        catch (error) {
            debugLogger.error('Error updating conversation history from memory.', error);
            throw error;
        }
    }
}
//# sourceMappingURL=chatRecordingService.js.map