/**
 * LLM Client for Wiki Generation
 *
 * OpenAI-compatible API client using native fetch.
 * Supports OpenAI, Azure, LiteLLM, Ollama, and any OpenAI-compatible endpoint.
 *
 * Config priority: CLI flags > env vars > defaults
 */
/**
 * Resolve LLM configuration from env vars, saved config, and optional overrides.
 * Priority: overrides (CLI flags) > env vars > ~/.codragraph/config.json > error
 *
 * If no API key is found, returns config with empty apiKey (caller should handle).
 */
export async function resolveLLMConfig(overrides) {
    const { loadCLIConfig } = await import('../../storage/repo-manager.js');
    const savedConfig = await loadCLIConfig();
    const apiKey = overrides?.apiKey ||
        process.env.CODRAGRAPH_API_KEY ||
        process.env.OPENAI_API_KEY ||
        savedConfig.apiKey ||
        '';
    return {
        apiKey,
        baseUrl: overrides?.baseUrl ||
            process.env.CODRAGRAPH_LLM_BASE_URL ||
            savedConfig.baseUrl ||
            'https://openrouter.ai/api/v1',
        model: overrides?.model ||
            process.env.CODRAGRAPH_MODEL ||
            (savedConfig.provider === 'cursor' ? savedConfig.cursorModel : undefined) ||
            savedConfig.model ||
            'minimax/minimax-m2.5',
        maxTokens: overrides?.maxTokens ?? 16_384,
        temperature: overrides?.temperature ?? 0,
        provider: overrides?.provider ?? savedConfig.provider ?? 'openai',
        apiVersion: overrides?.apiVersion || process.env.CODRAGRAPH_AZURE_API_VERSION || savedConfig.apiVersion,
        isReasoningModel: overrides?.isReasoningModel ?? savedConfig.isReasoningModel,
    };
}
/**
 * Estimate token count from text (rough heuristic: ~4 chars per token).
 */
export function estimateTokens(text) {
    return Math.ceil(text.length / 4);
}
/**
 * Returns true if the given base URL is an Azure OpenAI endpoint.
 * Uses proper hostname matching to avoid spoofed URLs like
 * "https://myresource.openai.azure.com.evil.com/v1".
 */
export function isAzureProvider(baseUrl) {
    try {
        const { hostname } = new URL(baseUrl);
        return hostname.endsWith('.openai.azure.com') || hostname.endsWith('.services.ai.azure.com');
    }
    catch {
        // If URL is malformed, fall back to substring check
        return baseUrl.includes('.openai.azure.com') || baseUrl.includes('.services.ai.azure.com');
    }
}
/**
 * Returns true if the model name matches a known reasoning model pattern,
 * or if the explicit override is true.
 * Pass override=false to force non-reasoning even for o-series names.
 */
export function isReasoningModel(model, override) {
    if (override !== undefined)
        return override;
    // Match known bare reasoning models (o1, o3) and any o-series with -mini/-preview suffix
    return /^o[1-9]\d*(-mini|-preview)$|^o1$|^o3$/i.test(model);
}
/**
 * Build the full chat completions URL, appending ?api-version when provided.
 */
export function buildRequestUrl(baseUrl, apiVersion) {
    const base = `${baseUrl.replace(/\/+$/, '')}/chat/completions`;
    return apiVersion ? `${base}?api-version=${encodeURIComponent(apiVersion)}` : base;
}
/**
 * Call an OpenAI-compatible LLM API.
 * Uses streaming when onChunk callback is provided for real-time progress.
 * Retries up to 3 times on transient failures (429, 5xx, network errors).
 */
export async function callLLM(prompt, config, systemPrompt, options) {
    const messages = [];
    if (systemPrompt) {
        messages.push({ role: 'system', content: systemPrompt });
    }
    messages.push({ role: 'user', content: prompt });
    // Detect Azure endpoint (by provider field or URL pattern)
    const azure = config.provider === 'azure' || isAzureProvider(config.baseUrl);
    // Warn when using Azure legacy deployment URL without api-version
    if (azure && !config.apiVersion && config.baseUrl.includes('/deployments/')) {
        console.warn('[codragraph] Warning: Azure legacy deployment URL detected but no api-version set. Add --api-version 2024-10-21 or use the v1 API format.');
    }
    // Detect reasoning model (o1, o3, o4-mini etc.) or explicit override
    const reasoning = isReasoningModel(config.model, config.isReasoningModel);
    const url = buildRequestUrl(config.baseUrl, azure ? config.apiVersion : undefined);
    const useStream = !!options?.onChunk;
    // Build request body — reasoning models reject temperature and use max_completion_tokens
    const body = {
        model: config.model,
        messages,
    };
    // max_tokens is deprecated; use max_completion_tokens for all models
    body.max_completion_tokens = config.maxTokens;
    // Only send temperature for non-Azure providers — some Azure models reject non-default values
    if (!reasoning && !azure && config.temperature !== undefined) {
        body.temperature = config.temperature;
    }
    if (useStream)
        body.stream = true;
    // Build auth headers — Azure uses api-key header, everyone else uses Authorization: Bearer
    const authHeaders = azure
        ? { 'api-key': config.apiKey }
        : { Authorization: `Bearer ${config.apiKey}` };
    const MAX_RETRIES = 3;
    let lastError = null;
    for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
        try {
            const response = await fetch(url, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    ...authHeaders,
                },
                body: JSON.stringify(body),
            });
            if (!response.ok) {
                const errorText = await response.text().catch(() => 'unknown error');
                // Azure content filter — surface a clear message instead of a generic API error
                if (azure &&
                    response.status === 400 &&
                    (errorText.includes('content_filter') ||
                        errorText.includes('ResponsibleAIPolicyViolation'))) {
                    throw new Error(`Azure content filter blocked this request. The prompt triggered content policy. Details: ${errorText.slice(0, 300)}`);
                }
                // Rate limit — wait with exponential backoff and retry
                if (response.status === 429 && attempt < MAX_RETRIES - 1) {
                    const retryAfter = parseInt(response.headers.get('retry-after') || '0', 10);
                    const delay = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 3000;
                    await sleep(delay);
                    continue;
                }
                // Server error — retry with backoff
                if (response.status >= 500 && attempt < MAX_RETRIES - 1) {
                    await sleep((attempt + 1) * 2000);
                    continue;
                }
                throw new Error(`LLM API error (${response.status}): ${errorText.slice(0, 500)}`);
            }
            // Streaming path
            if (useStream && response.body) {
                return await readSSEStream(response.body, options.onChunk);
            }
            // Non-streaming path
            const json = (await response.json());
            const choice = json.choices?.[0];
            if (!choice?.message?.content) {
                throw new Error('LLM returned empty response');
            }
            return {
                content: choice.message.content,
                promptTokens: json.usage?.prompt_tokens,
                completionTokens: json.usage?.completion_tokens,
            };
        }
        catch (err) {
            lastError = err;
            // Network error — retry with backoff
            if (attempt < MAX_RETRIES - 1 &&
                (err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT' || err.message?.includes('fetch'))) {
                await sleep((attempt + 1) * 3000);
                continue;
            }
            throw err;
        }
    }
    throw lastError || new Error('LLM call failed after retries');
}
/**
 * Read an SSE stream from an OpenAI-compatible streaming response.
 */
async function readSSEStream(body, onChunk) {
    const decoder = new TextDecoder();
    const reader = body.getReader();
    let content = '';
    let buffer = '';
    let contentFilterTriggered = false;
    while (true) {
        const { done, value } = await reader.read();
        if (done)
            break;
        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n');
        buffer = lines.pop() || '';
        for (const line of lines) {
            const trimmed = line.trim();
            if (!trimmed || !trimmed.startsWith('data: '))
                continue;
            const data = trimmed.slice(6);
            if (data === '[DONE]')
                continue;
            try {
                const parsed = JSON.parse(data);
                const choice = parsed.choices?.[0];
                // Detect content filter finish reason — skip delta from this chunk
                if (choice?.finish_reason === 'content_filter') {
                    contentFilterTriggered = true;
                    continue;
                }
                const delta = choice?.delta?.content;
                if (delta) {
                    content += delta;
                    onChunk(content.length);
                }
            }
            catch {
                // Skip malformed SSE chunks
            }
        }
    }
    if (contentFilterTriggered) {
        throw new Error('content filter triggered mid-stream. The generated content was blocked by content policy. Adjust your prompt and retry.');
    }
    if (!content) {
        throw new Error('LLM returned empty streaming response');
    }
    return { content };
}
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
