/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import { getDirectoryContextString, getInitialChatHistory, } from '../utils/environmentContext.js';
import { CompressionStatus } from './turn.js';
import { Turn, GeminiEventType } from './turn.js';
import { getCoreSystemPrompt } from './prompts.js';
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
import { reportError } from '../utils/errorReporting.js';
import { GeminiChat } from './geminiChat.js';
import { retryWithBackoff } from '../utils/retry.js';
import { getErrorMessage } from '../utils/errors.js';
import { tokenLimit } from './tokenLimits.js';
import { DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_THINKING_MODE, getEffectiveModel, } from '../config/models.js';
import { LoopDetectionService } from '../services/loopDetectionService.js';
import { ChatCompressionService } from '../services/chatCompressionService.js';
import { ideContextStore } from '../ide/ideContext.js';
import { logContentRetryFailure, logNextSpeakerCheck, } from '../telemetry/loggers.js';
import { ContentRetryFailureEvent, NextSpeakerCheckEvent, } from '../telemetry/types.js';
import { handleFallback } from '../fallback/handler.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
import { debugLogger } from '../utils/debugLogger.js';
export function isThinkingSupported(model) {
    return model.startsWith('gemini-2.5') || model === DEFAULT_GEMINI_MODEL_AUTO;
}
export function isThinkingDefault(model) {
    if (model.startsWith('gemini-2.5-flash-lite')) {
        return false;
    }
    return model.startsWith('gemini-2.5') || model === DEFAULT_GEMINI_MODEL_AUTO;
}
const MAX_TURNS = 100;
export class GeminiClient {
    config;
    chat;
    generateContentConfig = {
        temperature: 0,
        topP: 1,
    };
    sessionTurnCount = 0;
    loopDetector;
    compressionService;
    lastPromptId;
    currentSequenceModel = null;
    lastSentIdeContext;
    forceFullIdeContext = true;
    /**
     * At any point in this conversation, was compression triggered without
     * being forced and did it fail?
     */
    hasFailedCompressionAttempt = false;
    constructor(config) {
        this.config = config;
        this.loopDetector = new LoopDetectionService(config);
        this.compressionService = new ChatCompressionService();
        this.lastPromptId = this.config.getSessionId();
    }
    async initialize() {
        this.chat = await this.startChat();
    }
    getContentGeneratorOrFail() {
        if (!this.config.getContentGenerator()) {
            throw new Error('Content generator not initialized');
        }
        return this.config.getContentGenerator();
    }
    async addHistory(content) {
        this.getChat().addHistory(content);
    }
    getChat() {
        if (!this.chat) {
            throw new Error('Chat not initialized');
        }
        return this.chat;
    }
    isInitialized() {
        return this.chat !== undefined;
    }
    getHistory() {
        return this.getChat().getHistory();
    }
    stripThoughtsFromHistory() {
        this.getChat().stripThoughtsFromHistory();
    }
    setHistory(history) {
        this.getChat().setHistory(history);
        this.forceFullIdeContext = true;
    }
    async setTools() {
        const toolRegistry = this.config.getToolRegistry();
        const toolDeclarations = toolRegistry.getFunctionDeclarations();
        const tools = [{ functionDeclarations: toolDeclarations }];
        this.getChat().setTools(tools);
    }
    async resetChat() {
        this.chat = await this.startChat();
    }
    getChatRecordingService() {
        return this.chat?.getChatRecordingService();
    }
    getLoopDetectionService() {
        return this.loopDetector;
    }
    getCurrentSequenceModel() {
        return this.currentSequenceModel;
    }
    async addDirectoryContext() {
        if (!this.chat) {
            return;
        }
        this.getChat().addHistory({
            role: 'user',
            parts: [{ text: await getDirectoryContextString(this.config) }],
        });
    }
    async startChat(extraHistory) {
        this.forceFullIdeContext = true;
        this.hasFailedCompressionAttempt = false;
        const toolRegistry = this.config.getToolRegistry();
        const toolDeclarations = toolRegistry.getFunctionDeclarations();
        const tools = [{ functionDeclarations: toolDeclarations }];
        const history = await getInitialChatHistory(this.config, extraHistory);
        try {
            const userMemory = this.config.getUserMemory();
            const systemInstruction = getCoreSystemPrompt(this.config, userMemory);
            const model = this.config.getModel();
            const config = { ...this.generateContentConfig };
            if (isThinkingSupported(model)) {
                config.thinkingConfig = {
                    includeThoughts: true,
                    thinkingBudget: DEFAULT_THINKING_MODE,
                };
            }
            return new GeminiChat(this.config, {
                systemInstruction,
                ...config,
                tools,
            }, history);
        }
        catch (error) {
            await reportError(error, 'Error initializing Gemini chat session.', history, 'startChat');
            throw new Error(`Failed to initialize chat: ${getErrorMessage(error)}`);
        }
    }
    getIdeContextParts(forceFullContext) {
        const currentIdeContext = ideContextStore.get();
        if (!currentIdeContext) {
            return { contextParts: [], newIdeContext: undefined };
        }
        if (forceFullContext || !this.lastSentIdeContext) {
            // Send full context as JSON
            const openFiles = currentIdeContext.workspaceState?.openFiles || [];
            const activeFile = openFiles.find((f) => f.isActive);
            const otherOpenFiles = openFiles
                .filter((f) => !f.isActive)
                .map((f) => f.path);
            const contextData = {};
            if (activeFile) {
                contextData['activeFile'] = {
                    path: activeFile.path,
                    cursor: activeFile.cursor
                        ? {
                            line: activeFile.cursor.line,
                            character: activeFile.cursor.character,
                        }
                        : undefined,
                    selectedText: activeFile.selectedText || undefined,
                };
            }
            if (otherOpenFiles.length > 0) {
                contextData['otherOpenFiles'] = otherOpenFiles;
            }
            if (Object.keys(contextData).length === 0) {
                return { contextParts: [], newIdeContext: currentIdeContext };
            }
            const jsonString = JSON.stringify(contextData, null, 2);
            const contextParts = [
                "Here is the user's editor context as a JSON object. This is for your information only.",
                '```json',
                jsonString,
                '```',
            ];
            if (this.config.getDebugMode()) {
                debugLogger.log(contextParts.join('\n'));
            }
            return {
                contextParts,
                newIdeContext: currentIdeContext,
            };
        }
        else {
            // Calculate and send delta as JSON
            const delta = {};
            const changes = {};
            const lastFiles = new Map((this.lastSentIdeContext.workspaceState?.openFiles || []).map((f) => [f.path, f]));
            const currentFiles = new Map((currentIdeContext.workspaceState?.openFiles || []).map((f) => [
                f.path,
                f,
            ]));
            const openedFiles = [];
            for (const [path] of currentFiles.entries()) {
                if (!lastFiles.has(path)) {
                    openedFiles.push(path);
                }
            }
            if (openedFiles.length > 0) {
                changes['filesOpened'] = openedFiles;
            }
            const closedFiles = [];
            for (const [path] of lastFiles.entries()) {
                if (!currentFiles.has(path)) {
                    closedFiles.push(path);
                }
            }
            if (closedFiles.length > 0) {
                changes['filesClosed'] = closedFiles;
            }
            const lastActiveFile = (this.lastSentIdeContext.workspaceState?.openFiles || []).find((f) => f.isActive);
            const currentActiveFile = (currentIdeContext.workspaceState?.openFiles || []).find((f) => f.isActive);
            if (currentActiveFile) {
                if (!lastActiveFile || lastActiveFile.path !== currentActiveFile.path) {
                    changes['activeFileChanged'] = {
                        path: currentActiveFile.path,
                        cursor: currentActiveFile.cursor
                            ? {
                                line: currentActiveFile.cursor.line,
                                character: currentActiveFile.cursor.character,
                            }
                            : undefined,
                        selectedText: currentActiveFile.selectedText || undefined,
                    };
                }
                else {
                    const lastCursor = lastActiveFile.cursor;
                    const currentCursor = currentActiveFile.cursor;
                    if (currentCursor &&
                        (!lastCursor ||
                            lastCursor.line !== currentCursor.line ||
                            lastCursor.character !== currentCursor.character)) {
                        changes['cursorMoved'] = {
                            path: currentActiveFile.path,
                            cursor: {
                                line: currentCursor.line,
                                character: currentCursor.character,
                            },
                        };
                    }
                    const lastSelectedText = lastActiveFile.selectedText || '';
                    const currentSelectedText = currentActiveFile.selectedText || '';
                    if (lastSelectedText !== currentSelectedText) {
                        changes['selectionChanged'] = {
                            path: currentActiveFile.path,
                            selectedText: currentSelectedText,
                        };
                    }
                }
            }
            else if (lastActiveFile) {
                changes['activeFileChanged'] = {
                    path: null,
                    previousPath: lastActiveFile.path,
                };
            }
            if (Object.keys(changes).length === 0) {
                return { contextParts: [], newIdeContext: currentIdeContext };
            }
            delta['changes'] = changes;
            const jsonString = JSON.stringify(delta, null, 2);
            const contextParts = [
                "Here is a summary of changes in the user's editor context, in JSON format. This is for your information only.",
                '```json',
                jsonString,
                '```',
            ];
            if (this.config.getDebugMode()) {
                debugLogger.log(contextParts.join('\n'));
            }
            return {
                contextParts,
                newIdeContext: currentIdeContext,
            };
        }
    }
    _getEffectiveModelForCurrentTurn() {
        if (this.currentSequenceModel) {
            return this.currentSequenceModel;
        }
        const configModel = this.config.getModel();
        const model = configModel === DEFAULT_GEMINI_MODEL_AUTO
            ? DEFAULT_GEMINI_MODEL
            : configModel;
        return getEffectiveModel(this.config.isInFallbackMode(), model);
    }
    async *sendMessageStream(request, signal, prompt_id, turns = MAX_TURNS, isInvalidStreamRetry = false) {
        if (this.lastPromptId !== prompt_id) {
            this.loopDetector.reset(prompt_id);
            this.lastPromptId = prompt_id;
            this.currentSequenceModel = null;
        }
        this.sessionTurnCount++;
        if (this.config.getMaxSessionTurns() > 0 &&
            this.sessionTurnCount > this.config.getMaxSessionTurns()) {
            yield { type: GeminiEventType.MaxSessionTurns };
            return new Turn(this.getChat(), prompt_id);
        }
        // Ensure turns never exceeds MAX_TURNS to prevent infinite loops
        const boundedTurns = Math.min(turns, MAX_TURNS);
        if (!boundedTurns) {
            return new Turn(this.getChat(), prompt_id);
        }
        // Check for context window overflow
        const modelForLimitCheck = this._getEffectiveModelForCurrentTurn();
        const estimatedRequestTokenCount = Math.floor(JSON.stringify(request).length / 4);
        const remainingTokenCount = tokenLimit(modelForLimitCheck) -
            uiTelemetryService.getLastPromptTokenCount();
        if (estimatedRequestTokenCount > remainingTokenCount * 0.95) {
            yield {
                type: GeminiEventType.ContextWindowWillOverflow,
                value: { estimatedRequestTokenCount, remainingTokenCount },
            };
            return new Turn(this.getChat(), prompt_id);
        }
        const compressed = await this.tryCompressChat(prompt_id, false);
        if (compressed.compressionStatus === CompressionStatus.COMPRESSED) {
            yield { type: GeminiEventType.ChatCompressed, value: compressed };
        }
        // Prevent context updates from being sent while a tool call is
        // waiting for a response. The Gemini API requires that a functionResponse
        // part from the user immediately follows a functionCall part from the model
        // in the conversation history . The IDE context is not discarded; it will
        // be included in the next regular message sent to the model.
        const history = this.getHistory();
        const lastMessage = history.length > 0 ? history[history.length - 1] : undefined;
        const hasPendingToolCall = !!lastMessage &&
            lastMessage.role === 'model' &&
            (lastMessage.parts?.some((p) => 'functionCall' in p) || false);
        if (this.config.getIdeMode() && !hasPendingToolCall) {
            const { contextParts, newIdeContext } = this.getIdeContextParts(this.forceFullIdeContext || history.length === 0);
            if (contextParts.length > 0) {
                this.getChat().addHistory({
                    role: 'user',
                    parts: [{ text: contextParts.join('\n') }],
                });
            }
            this.lastSentIdeContext = newIdeContext;
            this.forceFullIdeContext = false;
        }
        const turn = new Turn(this.getChat(), prompt_id);
        const controller = new AbortController();
        const linkedSignal = AbortSignal.any([signal, controller.signal]);
        const loopDetected = await this.loopDetector.turnStarted(signal);
        if (loopDetected) {
            yield { type: GeminiEventType.LoopDetected };
            return turn;
        }
        const routingContext = {
            history: this.getChat().getHistory(/*curated=*/ true),
            request,
            signal,
        };
        let modelToUse;
        // Determine Model (Stickiness vs. Routing)
        if (this.currentSequenceModel) {
            modelToUse = this.currentSequenceModel;
        }
        else {
            const router = await this.config.getModelRouterService();
            const decision = await router.route(routingContext);
            modelToUse = decision.model;
            // Lock the model for the rest of the sequence
            this.currentSequenceModel = modelToUse;
        }
        const resultStream = turn.run(modelToUse, request, linkedSignal);
        for await (const event of resultStream) {
            if (this.loopDetector.addAndCheck(event)) {
                yield { type: GeminiEventType.LoopDetected };
                controller.abort();
                return turn;
            }
            yield event;
            if (event.type === GeminiEventType.InvalidStream) {
                if (this.config.getContinueOnFailedApiCall()) {
                    if (isInvalidStreamRetry) {
                        // We already retried once, so stop here.
                        logContentRetryFailure(this.config, new ContentRetryFailureEvent(4, // 2 initial + 2 after injections
                        'FAILED_AFTER_PROMPT_INJECTION', modelToUse));
                        return turn;
                    }
                    const nextRequest = [{ text: 'System: Please continue.' }];
                    yield* this.sendMessageStream(nextRequest, signal, prompt_id, boundedTurns - 1, true);
                    return turn;
                }
            }
            if (event.type === GeminiEventType.Error) {
                return turn;
            }
        }
        if (!turn.pendingToolCalls.length && signal && !signal.aborted) {
            // Check if next speaker check is needed
            if (this.config.getQuotaErrorOccurred()) {
                return turn;
            }
            if (this.config.getSkipNextSpeakerCheck()) {
                return turn;
            }
            const nextSpeakerCheck = await checkNextSpeaker(this.getChat(), this.config.getBaseLlmClient(), signal, prompt_id);
            logNextSpeakerCheck(this.config, new NextSpeakerCheckEvent(prompt_id, turn.finishReason?.toString() || '', nextSpeakerCheck?.next_speaker || ''));
            if (nextSpeakerCheck?.next_speaker === 'model') {
                const nextRequest = [{ text: 'Please continue.' }];
                // This recursive call's events will be yielded out, but the final
                // turn object will be from the top-level call.
                yield* this.sendMessageStream(nextRequest, signal, prompt_id, boundedTurns - 1);
            }
        }
        return turn;
    }
    async generateContent(contents, generationConfig, abortSignal, model) {
        let currentAttemptModel = model;
        const configToUse = {
            ...this.generateContentConfig,
            ...generationConfig,
        };
        try {
            const userMemory = this.config.getUserMemory();
            const systemInstruction = getCoreSystemPrompt(this.config, userMemory);
            const requestConfig = {
                abortSignal,
                ...configToUse,
                systemInstruction,
            };
            const apiCall = () => {
                const modelToUse = this.config.isInFallbackMode()
                    ? DEFAULT_GEMINI_FLASH_MODEL
                    : model;
                currentAttemptModel = modelToUse;
                return this.getContentGeneratorOrFail().generateContent({
                    model: modelToUse,
                    config: requestConfig,
                    contents,
                }, this.lastPromptId);
            };
            const onPersistent429Callback = async (authType, error) => 
            // Pass the captured model to the centralized handler.
            await handleFallback(this.config, currentAttemptModel, authType, error);
            const result = await retryWithBackoff(apiCall, {
                onPersistent429: onPersistent429Callback,
                authType: this.config.getContentGeneratorConfig()?.authType,
            });
            return result;
        }
        catch (error) {
            if (abortSignal.aborted) {
                throw error;
            }
            await reportError(error, `Error generating content via API with model ${currentAttemptModel}.`, {
                requestContents: contents,
                requestConfig: configToUse,
            }, 'generateContent-api');
            throw new Error(`Failed to generate content with model ${currentAttemptModel}: ${getErrorMessage(error)}`);
        }
    }
    async tryCompressChat(prompt_id, force = false) {
        // If the model is 'auto', we will use a placeholder model to check.
        // Compression occurs before we choose a model, so calling `count_tokens`
        // before the model is chosen would result in an error.
        const model = this._getEffectiveModelForCurrentTurn();
        const { newHistory, info } = await this.compressionService.compress(this.getChat(), prompt_id, force, model, this.config, this.hasFailedCompressionAttempt);
        if (info.compressionStatus ===
            CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT) {
            this.hasFailedCompressionAttempt = !force && true;
        }
        else if (info.compressionStatus === CompressionStatus.COMPRESSED) {
            if (newHistory) {
                this.chat = await this.startChat(newHistory);
                this.forceFullIdeContext = true;
            }
        }
        return info;
    }
}
//# sourceMappingURL=client.js.map