/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return, @typescript-eslint/restrict-template-expressions, @typescript-eslint/unbound-method -- File-level disable: interacts with external SDK / JSON / Obsidian internals where untyped 'any' values are unavoidable. Inputs are validated at boundaries via type guards or schema checks where security-relevant. */
/**
 * OpenAiProvider - LLM provider for OpenAI-compatible APIs
 *
 * Adapted from Kilo Code's src/api/providers/openai.ts + base-provider.ts
 *
 * Covers: OpenAI, Mistral, Ollama (port 11434), custom OpenAI-compatible endpoints.
 */

import OpenAI from 'openai';
import type { LLMProvider } from '../../types/settings';
import type { ApiHandler, ApiStream, ApiStreamChunk, MessageParam, ModelInfo } from '../types';
import { truncatedToolInputError } from '../types';
import type { ToolDefinition } from '../../core/tools/types';
import type { IncomingMessage } from 'http';
import { getModelContextWindow, resolveOutputBudget, estimatePromptTokens, modelSupportsTemperature } from '../../types/model-registry';
import { logCacheStat } from '../logCacheStat';

// ---------------------------------------------------------------------------
// OpenAI REST API types (subset we need)
// ---------------------------------------------------------------------------

interface OpenAIMessage {
    role: 'system' | 'user' | 'assistant' | 'tool';
    content: string | null;
    tool_calls?: OpenAIToolCall[];
    tool_call_id?: string;
    name?: string;
}

interface OpenAIToolCall {
    id: string;
    type: 'function';
    function: {
        name: string;
        arguments: string;
    };
}

interface OpenAITool {
    type: 'function';
    function: {
        name: string;
        description: string;
        parameters: Record<string, unknown>;
    };
}

// ---------------------------------------------------------------------------
// Tool call accumulator for streaming
// ---------------------------------------------------------------------------

interface ToolCallAccumulator {
    id: string;
    name: string;
    argumentsJson: string;
}

// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// Node.js fetch wrapper — bypasses CORS in Electron renderer (ADR-064)
// ---------------------------------------------------------------------------

/**
 * Creates a fetch-compatible function using Node.js http(s) module.
 * Used for providers where Electron's CORS enforcement blocks window.fetch
 * (e.g. Google's generativelanguage.googleapis.com, chatgpt.com/backend-api,
 * and FIX-04-03-03 custom OpenAI-compatible servers like opencode go on
 * localhost).
 *
 * Picks the http or https module based on the URL protocol so plain-HTTP
 * local dev servers also work, not just HTTPS endpoints.
 */
export function createNodeFetch(): typeof window.fetch {
    return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
        const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
        const parsed = new URL(url);
        const isHttps = parsed.protocol === 'https:';

        // Node.js http(s) only available via dynamic require in Electron renderer
        const httpModule = isHttps
            // eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic require for Electron renderer
            ? (require('https') as typeof import('https'))
            // eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic require for Electron renderer
            : (require('http') as unknown as typeof import('https'));

        return new Promise<Response>((resolve, reject) => {
            const headers: Record<string, string> = {};
            if (init?.headers) {
                if (init.headers instanceof Headers) {
                    init.headers.forEach((v, k) => { headers[k] = v; });
                } else if (Array.isArray(init.headers)) {
                    for (const [k, v] of init.headers) headers[k] = v;
                } else {
                    Object.assign(headers, init.headers);
                }
            }

            const defaultPort = isHttps ? 443 : 80;
            const req = httpModule.request({
                hostname: parsed.hostname,
                port: parsed.port || defaultPort,
                path: parsed.pathname + parsed.search,
                method: init?.method ?? 'GET',
                headers,
            }, (res: IncomingMessage) => {
                // AUDIT-023 L-1: clear the connection-level idle timeout once
                // the server actually starts responding; the stream itself is
                // driven by res.on('data'/'end'/'error').
                req.setTimeout(0);
                // Convert Node.js IncomingMessage to a Web ReadableStream
                const body = new ReadableStream<Uint8Array>({
                    start(controller) {
                        res.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
                        res.on('end', () => controller.close());
                        res.on('error', (err) => controller.error(err));
                    },
                    cancel() { res.destroy(); },
                });

                const responseHeaders = new Headers();
                for (const [key, value] of Object.entries(res.headers)) {
                    if (value) responseHeaders.set(key, Array.isArray(value) ? value.join(', ') : value);
                }

                resolve(new Response(body, {
                    status: res.statusCode ?? 500,
                    statusText: res.statusMessage ?? '',
                    headers: responseHeaders,
                }));
            });

            req.on('error', reject);

            // AUDIT-023 L-1: bound idle-time on the socket so a server that
            // accepts the connection and then never writes does not hang
            // forever. 120 s matches the upstream chat-loop tolerance; the
            // AbortSignal path below still cancels earlier on user action.
            req.setTimeout(120_000, () => {
                req.destroy(new Error('Request timed out after 120s with no response'));
                reject(new Error('Request timed out after 120s with no response'));
            });

            if (init?.signal) {
                init.signal.addEventListener('abort', () => { req.destroy(); reject(new DOMException('Aborted', 'AbortError')); });
            }

            if (init?.body) {
                req.write(typeof init.body === 'string' ? init.body : init.body);
            }
            req.end();
        });
    };
}

const DEFAULT_BASE_URLS: Record<string, string> = {
    openai: 'https://api.openai.com/v1',
    gemini: 'https://generativelanguage.googleapis.com/v1beta/openai',
    ollama: 'http://localhost:11434/v1',
    lmstudio: 'http://localhost:1234/v1',
    openrouter: 'https://openrouter.ai/api/v1',
    custom: 'https://api.openai.com/v1',
};

export class OpenAiProvider implements ApiHandler {
    private config: LLMProvider;
    private client: OpenAI;

    constructor(config: LLMProvider) {
        this.config = config;

        let baseURL = config.baseUrl ?? DEFAULT_BASE_URLS[config.type] ?? DEFAULT_BASE_URLS.openai;
        if (config.type === 'ollama' && !baseURL.match(/\/v\d/)) {
            baseURL = baseURL.replace(/\/+$/, '') + '/v1';
        }

        const defaultHeaders: Record<string, string> = {};
        if (config.type === 'openrouter') {
            defaultHeaders['HTTP-Referer'] = 'https://obsidian.md';
            defaultHeaders['X-Title'] = 'Vault Operator';
        }
        if (config.type === 'azure' && config.apiKey) {
            defaultHeaders['api-key'] = config.apiKey;
        }

        this.client = new OpenAI({
            apiKey: config.type === 'azure' ? '' : (config.apiKey || ''),
            baseURL,
            dangerouslyAllowBrowser: true,
            defaultHeaders,
            // Bypass Electron's CORS enforcement for providers whose endpoints
            // do not set the right Access-Control-Allow-Origin headers. Obsidian
            // renderer enforces CORS on window.fetch; Node.js http(s) is not
            // subject to CORS.
            // - 'gemini' (always blocked by Google):
            // - 'custom' (FIX-04-03-03): generic OpenAI-compatible servers like
            //   opencode go on localhost rarely send CORS headers.
            // - 'ollama' / 'lmstudio' on localhost: same class of local server,
            //   safer to bypass CORS than to rely on the server config.
            ...((['gemini', 'custom', 'ollama', 'lmstudio'] as const).includes(config.type as never)
                ? { fetch: createNodeFetch() }
                : {}),
        });
    }

    getModel(): { id: string; info: ModelInfo } {
        // Get context window from central registry
        const contextWindow = getModelContextWindow(this.config.model);

        return {
            id: this.config.model,
            info: {
                contextWindow,
                supportsTools: true,
                supportsStreaming: true,
            },
        };
    }

    async *createMessage(
        systemPrompt: string,
        messages: MessageParam[],
        tools: ToolDefinition[],
        abortSignal?: AbortSignal,
    ): ApiStream {
        const openAiMessages = this.convertMessages(systemPrompt, messages);
        const openAiTools = tools.length > 0 ? this.convertTools(tools) : undefined;

        // Temperature handling — four cases:
        // 1. o-series (o1, o3, o4-mini, etc.) enforce temperature=1 API-side -> omit entirely
        // 2. FIX-04-03-02: GPT-5.x and other default-only models reject any
        //    explicit temperature with a 400 -> omit entirely (detected via
        //    shared helper modelSupportsTemperature so the same rule covers
        //    OpenRouter aliases and gateway names too).
        // 3. Explicitly configured temperature -> always respect it
        // 4. No explicit config -> use 0.2 default for deterministic agent behavior,
        //    EXCEPT for Azure where deployment names are opaque (may hide o-series models)
        const isOSeries = /^o[1-9]/.test(this.config.model);
        const supportsTemperature = modelSupportsTemperature(this.config.model);
        let temperature: number | undefined;
        if (isOSeries || !supportsTemperature) {
            temperature = undefined;
        } else if (this.config.temperature !== undefined) {
            temperature = this.config.temperature;
        } else if (this.config.type !== 'azure') {
            temperature = 0.2;
        }

        // OpenRouter extended thinking: when enabled for Anthropic models via OpenRouter,
        // force temperature to 1 and pass reasoning parameter
        const openRouterThinking = this.config.type === 'openrouter'
            && (this.config.thinkingEnabled ?? false);
        if (openRouterThinking) {
            temperature = 1;
        }
        // Clamp the output budget to the model's real ceiling and (for thinking)
        // add the reasoning budget on top of the visible-output budget. budgetTokens
        // is only used in the reasoning passthrough below, which is gated on
        // openRouterThinking, so the 0 returned when thinking is off is harmless.
        const { maxTokens: effectiveMaxTokens, thinkingBudgetTokens: budgetTokens } = resolveOutputBudget(
            this.config.model,
            this.config.maxTokens,
            {
                enabled: openRouterThinking,
                budgetTokens: this.config.thinkingBudgetTokens,
                estimatedInputTokens: estimatePromptTokens(systemPrompt, messages),
            },
        );

        // Build request body
        const requestBody: OpenAI.ChatCompletionCreateParamsStreaming = {
            model: this.config.type !== 'azure' ? this.config.model : this.config.model,
            messages: openAiMessages as OpenAI.ChatCompletionMessageParam[],
            tools: openAiTools,
            temperature: temperature !== undefined ? Math.min(temperature, 2.0) : undefined,
            // OpenAI and Azure require max_completion_tokens (max_tokens deprecated / rejected by newer models)
            // Other providers (ollama, lmstudio, custom) still need max_tokens
            max_tokens: (this.config.type !== 'azure' && this.config.type !== 'openai')
                ? effectiveMaxTokens
                : undefined,
            stream: true,
            stream_options: (this.config.type === 'openai' || this.config.type === 'openrouter')
                ? { include_usage: true }
                : undefined,
            // OpenRouter reasoning passthrough for Anthropic models
            ...(openRouterThinking
                ? { reasoning: { max_tokens: budgetTokens } } as Record<string, unknown>
                : {}),
            // OpenRouter: disable automatic model fallback to prevent silent model switches.
            // Without this, OpenRouter can route to a completely different model (e.g. Gemini)
            // when the configured model is rate-limited or under high load.
            ...(this.config.type === 'openrouter'
                ? { provider: { allow_fallbacks: false } } as Record<string, unknown>
                : {}),
        };

        // OpenAI and Azure use max_completion_tokens (newer models reject max_tokens with 400)
        if (this.config.type === 'openai' || this.config.type === 'azure') {
            (requestBody as unknown as Record<string, unknown>).max_completion_tokens = effectiveMaxTokens;
        }

        if (openAiTools && openAiTools.length > 0) {
            requestBody.tool_choice = 'auto';
        }

        // Azure deployment-based routing: use a custom path
        const requestOptions: OpenAI.RequestOptions = { signal: abortSignal ?? null };
        if (this.config.type === 'azure') {
            const apiVersion = this.config.apiVersion ?? '2024-10-21';
            requestOptions.path = `/deployments/${this.config.model}/chat/completions?api-version=${apiVersion}`;
        }

        const stream = await this.client.chat.completions.create(requestBody, requestOptions);

        // Accumulate tool calls across chunks (keyed by index)
        const toolCallAccumulators = new Map<number, ToolCallAccumulator>();

        for await (const chunk of stream) {
            // Usage (sent at end with stream_options)
            if (chunk.usage) {
                const cachedIn = (chunk.usage as { prompt_tokens_details?: { cached_tokens?: number } })
                    .prompt_tokens_details?.cached_tokens ?? 0;
                logCacheStat({
                    provider: 'openai',
                    model: this.config.model,
                    caching: 'auto', // OpenAI-compatible APIs cache automatically, no toggle
                    nonCachedInputTokens: Math.max(0, chunk.usage.prompt_tokens - cachedIn),
                    cacheReadTokens: cachedIn,
                    outputTokens: chunk.usage.completion_tokens,
                });
                yield {
                    type: 'usage',
                    // IMP-18-01-02: prompt_tokens is the TOTAL (cached + non-cached).
                    // Report the non-cached part as inputTokens and the cached part
                    // separately, matching the Anthropic convention, so the cost calc
                    // bills the cached prefix at the cache-read rate instead of full price.
                    inputTokens: Math.max(0, chunk.usage.prompt_tokens - cachedIn),
                    outputTokens: chunk.usage.completion_tokens,
                    cacheReadTokens: cachedIn > 0 ? cachedIn : undefined,
                } satisfies ApiStreamChunk;
            }

            const choice = chunk.choices?.[0];
            if (!choice) continue;

            const delta = choice.delta;

            // OpenRouter reasoning content (extended thinking passthrough)
            const reasoning = (delta as Record<string, unknown>)?.reasoning_content
                ?? (delta as Record<string, unknown>)?.reasoning;
            if (typeof reasoning === 'string' && reasoning) {
                yield { type: 'thinking', text: reasoning } satisfies ApiStreamChunk;
            }

            // Text content
            if (delta?.content) {
                yield { type: 'text', text: delta.content } satisfies ApiStreamChunk;
            }

            // Tool call deltas -- accumulate until finish_reason = 'tool_calls'
            if (delta?.tool_calls) {
                for (const tc of delta.tool_calls) {
                    const idx = tc.index;
                    if (!toolCallAccumulators.has(idx)) {
                        toolCallAccumulators.set(idx, { id: '', name: '', argumentsJson: '' });
                    }
                    const acc = toolCallAccumulators.get(idx)!;
                    if (tc.id) acc.id = tc.id;
                    if (tc.function?.name) acc.name += tc.function.name;
                    if (tc.function?.arguments) acc.argumentsJson += tc.function.arguments;
                }
            }

            // When the turn ends with tool_calls, yield complete tool_use chunks
            if (choice.finish_reason === 'tool_calls') {
                yield* this.flushToolCallAccumulators(toolCallAccumulators);
            }
        }

        // BUG-013 / FEATURE-0409: Some OpenAI-compatible providers (OpenRouter
        // gpt-oss-120b, Groq, certain local backends) stream tool_calls deltas
        // but emit finish_reason="stop" or "length" instead of "tool_calls".
        // Without this post-loop flush the accumulated tool calls are silently
        // dropped and the agent treats the response as text only.
        // If finish_reason==="tool_calls" already flushed the map, this is a no-op.
        if (toolCallAccumulators.size > 0) {
            yield* this.flushToolCallAccumulators(toolCallAccumulators);
        }
    }

    /**
     * Yield tool_use chunks for every accumulated tool call, then clear the map.
     * Extracted so the streaming loop can flush both mid-stream (on
     * finish_reason==="tool_calls") and post-stream (BUG-013 fallback).
     */
    private *flushToolCallAccumulators(
        accumulators: Map<number, ToolCallAccumulator>,
    ): Generator<ApiStreamChunk> {
        for (const [, acc] of accumulators) {
            // Skip incomplete accumulators (no id or no name -- defensive).
            if (!acc.id || !acc.name) {
                console.warn(
                    `[OpenAi] Skipping incomplete tool_call accumulator: id="${acc.id}", name="${acc.name}"`,
                );
                continue;
            }
            let input: Record<string, unknown> = {};
            try {
                input = acc.argumentsJson.trim() ? JSON.parse(acc.argumentsJson) : {};
            } catch (e) {
                // BUG-032: Emit tool_error so AgentTask increments the mistake
                // counter and breaks after consecutiveMistakeLimit. Text chunks
                // hide the failure from the loop, causing infinite retries.
                yield {
                    type: 'tool_error',
                    id: acc.id,
                    name: acc.name,
                    error: truncatedToolInputError(acc.name, (e as Error).message),
                } satisfies ApiStreamChunk;
                continue;
            }
            yield {
                type: 'tool_use',
                id: acc.id,
                name: acc.name,
                input,
            } satisfies ApiStreamChunk;
        }
        accumulators.clear();
    }

    // ---------------------------------------------------------------------------
    // Format conversion: Anthropic → OpenAI
    // ---------------------------------------------------------------------------

    private convertMessages(systemPrompt: string, messages: MessageParam[]): OpenAIMessage[] {
        const result: OpenAIMessage[] = [
            { role: 'system', content: systemPrompt },
        ];

        for (const msg of messages) {
            if (typeof msg.content === 'string') {
                result.push({ role: msg.role, content: msg.content });
                continue;
            }

            // Array of ContentBlock
            const blocks = msg.content;

            if (msg.role === 'assistant') {
                // Assistant messages may contain text + tool_use blocks
                const textParts = blocks
                    .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
                    .map((b) => b.text)
                    .join('');

                const toolUseParts = blocks.filter(
                    (b): b is { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> } =>
                        b.type === 'tool_use',
                );

                if (toolUseParts.length > 0) {
                    // Message with tool calls
                    result.push({
                        role: 'assistant',
                        content: textParts || null,
                        tool_calls: toolUseParts.map((b) => ({
                            id: b.id,
                            type: 'function',
                            function: {
                                name: b.name,
                                arguments: JSON.stringify(b.input),
                            },
                        })),
                    });
                } else {
                    result.push({ role: 'assistant', content: textParts });
                }
            } else {
                // User messages may contain text + tool_result blocks
                for (const block of blocks) {
                    if (block.type === 'text') {
                        result.push({ role: 'user', content: block.text });
                    } else if (block.type === 'tool_result') {
                        // Tool results become separate 'tool' role messages in OpenAI format.
                        // OpenAI only supports string content — extract text from multimodal arrays.
                        const textContent = typeof block.content === 'string'
                            ? block.content
                            : block.content
                                .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
                                .map((b) => b.text)
                                .join('\n');
                        result.push({
                            role: 'tool',
                            tool_call_id: block.tool_use_id,
                            content: textContent,
                        });
                    }
                }
            }
        }

        return result;
    }

    private convertTools(tools: ToolDefinition[]): OpenAITool[] {
        return tools.map((tool) => ({
            type: 'function',
            function: {
                name: tool.name,
                description: tool.description,
                parameters: tool.input_schema,
            },
        }));
    }

    /**
     * Quick non-streaming classification call (~100 input, ~10 output tokens).
     * Used by skill matching LLM-fallback when regex finds no match.
     */
    async classifyText(prompt: string, abortSignal?: AbortSignal): Promise<string> {
        const classifyBody: OpenAI.ChatCompletionCreateParamsNonStreaming = {
            model: this.config.model,
            max_tokens: (this.config.type !== 'openai' && this.config.type !== 'azure') ? 50 : undefined,
            messages: [{ role: 'user', content: prompt }],
        };
        if (this.config.type === 'openai' || this.config.type === 'azure') {
            (classifyBody as unknown as Record<string, unknown>).max_completion_tokens = 50;
        }
        const response = await this.client.chat.completions.create(classifyBody, {
            signal: abortSignal ?? undefined,
        });

        return response.choices?.[0]?.message?.content?.trim() ?? '';
    }
}

/* eslint-enable -- end of file-level disable for boundary code (SDK/JSON/Obsidian internals) */
