import { logger } from '../logger.js';
import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from '../../_shared/index.js';
/**
 * Resolve LLM configuration from env vars, saved config, and optional overrides.
 * Priority: overrides (CLI flags) > env vars > ~/.gitnexus/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 savedProvider = overrides?.provider ?? savedConfig.provider;
    const savedLocalModel = savedProvider === 'cursor'
        ? savedConfig.cursorModel
        : savedProvider === 'claude'
            ? savedConfig.claudeModel
            : savedProvider === 'codex'
                ? savedConfig.codexModel
                : undefined;
    const localProvider = savedProvider === 'cursor' || savedProvider === 'claude' || savedProvider === 'codex';
    const apiKey = overrides?.apiKey ||
        process.env.GITNEXUS_API_KEY ||
        process.env.OPENAI_API_KEY ||
        savedConfig.apiKey ||
        '';
    return {
        apiKey,
        baseUrl: overrides?.baseUrl ||
            process.env.GITNEXUS_LLM_BASE_URL ||
            savedConfig.baseUrl ||
            'https://openrouter.ai/api/v1',
        model: overrides?.model ||
            (localProvider ? undefined : process.env.GITNEXUS_MODEL) ||
            savedLocalModel ||
            (localProvider ? '' : savedConfig.model || 'minimax/minimax-m2.5'),
        maxTokens: overrides?.maxTokens ?? 16_384,
        temperature: overrides?.temperature ?? 0,
        provider: savedProvider ?? 'openai',
        apiVersion: overrides?.apiVersion || process.env.GITNEXUS_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);
}
function formatTimeoutDuration(timeoutMs) {
    if (timeoutMs >= 1000 && timeoutMs % 1000 === 0) {
        return `${timeoutMs / 1000}s`;
    }
    return `${timeoutMs}ms`;
}
function isTimeoutLikeError(err) {
    if (!(err instanceof Error))
        return false;
    if (err.name === 'TimeoutError' || err.name === 'AbortError')
        return true;
    return /time(d)?\s*out|timeout/i.test(err.message);
}
/**
 * Validate that a base URL supplied for LLM API calls is a safe HTTP/HTTPS
 * endpoint (CWE-918 / CodeQL js/http-to-file-access).
 *
 * Allowed:
 *  - https:// with any hostname (public LLM APIs, Azure, OpenRouter, …)
 *  - http:// restricted to localhost / 127.0.0.1 (local servers: Ollama, LiteLLM, …)
 *
 * Rejected:
 *  - file://, data:, javascript:, and any other non-HTTP scheme
 *  - http:// aimed at non-loopback hosts (avoids SSRF against internal networks)
 *
 * Throws with a descriptive message on validation failure so callers surface a
 * clear error rather than an opaque network error.
 */
export function validateLLMBaseUrl(baseUrl) {
    let parsed;
    try {
        parsed = new URL(baseUrl);
    }
    catch {
        // Do not include the raw input in the message — it may contain credentials.
        throw new Error('Invalid LLM base URL: must be a well-formed http:// or https:// URL');
    }
    if (!['https:', 'http:'].includes(parsed.protocol)) {
        // Use parsed.protocol only (scheme), not the full URL, to avoid leaking credentials.
        throw new Error(`LLM base URL must use http:// or https:// (got ${parsed.protocol})`);
    }
    if (parsed.protocol === 'http:') {
        // Node's URL parser preserves IPv6 brackets in hostname (e.g. "[::1]"),
        // so strip them before comparing to bare address literals.
        const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
        if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1') {
            // Use parsed.origin (scheme+host+port, no credentials) instead of the full URL.
            throw new Error(`Insecure http:// LLM base URLs are only allowed for localhost/127.0.0.1. ` +
                `Use https:// for remote endpoints (got ${parsed.origin})`);
        }
    }
}
/**
 * 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 {
        // Malformed URL — refuse to call this Azure rather than fall back to a
        // substring check, which is bypassable by `https://evil.com/?u=.openai.azure.com`
        // (CodeQL js/incomplete-url-substring-sanitization).
        return false;
    }
}
/**
 * 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) {
    // Validate base URL before any fetch (CodeQL js/http-to-file-access)
    validateLLMBaseUrl(config.baseUrl);
    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/')) {
        logger.warn('[gitnexus] 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}` };
    // Network resilience (bounded retries with exponential-backoff jitter,
    // 5xx + 429 + Retry-After handling, in-process circuit breaker on the
    // LLM endpoint) is delegated to resilientFetch. Provider-specific
    // error parsing (Azure content filter, empty-content checks) stays
    // here since it requires response-body inspection.
    let response;
    try {
        response = await resilientFetch(url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                ...authHeaders,
            },
            body: JSON.stringify(body),
            // Request timeout is opt-in for wiki generation. Large local
            // model runs can legitimately take well over a minute, so the
            // default runtime path must not impose a hidden 60s ceiling.
            signal: config.requestTimeoutMs !== undefined
                ? AbortSignal.timeout(config.requestTimeoutMs)
                : undefined,
        }, {
            breakerKey: `wiki-llm-${new URL(url).host}`,
            retry: { maxAttempts: config.maxAttempts ?? 3, baseDelayMs: 2_000, capDelayMs: 30_000 },
        });
    }
    catch (err) {
        if (err instanceof CircuitOpenError) {
            throw new Error(`LLM endpoint circuit open: retry in ${Math.ceil(err.retryAfterMs / 1000)}s. ${err.message}`);
        }
        if (err instanceof ResilientFetchExhaustedError) {
            const errorText = await err.response.text().catch(() => 'unknown error');
            throw new Error(`LLM API error (${err.response.status} after retries): ${errorText.slice(0, 500)}`);
        }
        if (config.requestTimeoutMs !== undefined && isTimeoutLikeError(err)) {
            throw new Error(`LLM request timed out after ${formatTimeoutDuration(config.requestTimeoutMs)}. ` +
                'Increase --timeout or omit it to disable the request timeout.');
        }
        throw err;
    }
    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)}`);
        }
        // Any other non-OK response here is a terminal 4xx — resilientFetch
        // already retried 5xx/429 to exhaustion and would have thrown above.
        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,
    };
}
/**
 * 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 };
}
