import { withRetry } from '../utils/retry.js';
import { instrumentedLlmCall } from '../runtime/llm-observability.js';
import { ProviderError } from '../types/errors.js';
import { OpenAICompatProvider } from './openai-compat.js';
import { toOpenAITools } from './tool-formats.js';
import { summarizeError, toProviderError } from '../utils/error-display.js';
import { mapOllamaStopReason } from './stop-reason-maps.js';
import { instrumentedFetch } from '../utils/fetch-with-timeout.js';
export class OllamaProvider {
    name;
    models;
    capabilities;
    baseURL;
    defaultModel;
    nativeChatUrl;
    nativeEmbedUrl;
    nativeFetch;
    compatProvider;
    embeddingModel = 'embeddinggemma';
    constructor(opts) {
        this.name = opts.name;
        this.models = opts.models;
        this.capabilities = opts.capabilities;
        this.baseURL = opts.baseURL;
        this.defaultModel = opts.defaultModel;
        this.nativeChatUrl = deriveOllamaChatUrl(opts.baseURL);
        this.nativeEmbedUrl = deriveOllamaEmbedUrl(opts.baseURL);
        this.nativeFetch = opts.nativeFetch ?? instrumentedFetch;
        this.compatProvider = opts.compatProvider ?? new OpenAICompatProvider(opts);
    }
    isConfigured() {
        return this.compatProvider.isConfigured?.() ?? true;
    }
    async chat(params) {
        return (await instrumentedLlmCall(() => withRetry(async () => {
            const model = params.model || this.defaultModel;
            if (canUseNativeOllamaChat(params.messages)) {
                try {
                    return await this.chatViaNativeOllama(params, model);
                }
                catch (err) {
                    if (!shouldFallbackFromNative(err)) {
                        throw normalizeProviderError(err, this.name, 'chat', 'request');
                    }
                }
            }
            return this.compatProvider.chat(params);
        }), { provider: this.name, model: params.model || this.defaultModel })).result;
    }
    async embed(request) {
        try {
            const response = await this.nativeFetch(this.nativeEmbedUrl, {
                method: 'POST',
                headers: {
                    accept: 'application/json',
                    'content-type': 'application/json',
                },
                body: JSON.stringify({
                    model: request.model ?? this.embeddingModel,
                    input: request.text,
                    ...(request.dimensions ? { dimensions: request.dimensions } : {}),
                }),
                ...(request.signal !== undefined ? { signal: request.signal } : {}),
            });
            if (!response.ok) {
                throw await buildHttpError('Ollama native embeddings', response, this.name, 'embed', 'request');
            }
            const body = await response.json();
            const vector = body.embedding ?? body.embeddings?.[0] ?? [];
            return {
                vector: Float32Array.from(vector),
                dimensions: vector.length,
                modelId: body.model ?? request.model ?? this.embeddingModel,
                metadata: {
                    usage: request.usage,
                    provider: this.name,
                },
            };
        }
        catch (err) {
            if (this.compatProvider.embed) {
                return this.compatProvider.embed(request);
            }
            throw normalizeProviderError(err, this.name, 'embed', 'request');
        }
    }
    async describeRuntime(deps) {
        const local = !/^https?:\/\/ollama\.com\b/i.test(this.baseURL);
        const { buildStandardProviderAuthRoutes } = await import('./runtime-metadata.js');
        const authRoutes = await buildStandardProviderAuthRoutes({
            providerId: this.name,
            apiKeyEnvVars: ['OLLAMA_API_KEY', 'OLLAMA_CLOUD_API_KEY'],
            secretKeys: ['OLLAMA_API_KEY', 'OLLAMA_CLOUD_API_KEY'],
            serviceNames: ['ollama-cloud'],
            allowAnonymous: local,
            anonymousConfigured: local,
            anonymousDetail: 'Local Ollama endpoints can be used without an API key.',
        }, deps);
        return {
            auth: {
                mode: local ? 'anonymous' : 'api-key',
                configured: local || Boolean(process.env.OLLAMA_API_KEY || process.env.OLLAMA_CLOUD_API_KEY),
                detail: local
                    ? 'Local Ollama endpoint does not require an API key'
                    : 'Ollama Cloud API key is required',
                envVars: ['OLLAMA_API_KEY', 'OLLAMA_CLOUD_API_KEY'],
                routes: authRoutes,
            },
            models: {
                models: this.models,
                embeddingModel: this.embeddingModel,
                embeddingDimensions: 384,
            },
            usage: {
                streaming: true,
                toolCalling: true,
                parallelTools: false,
                notes: ['Native Ollama embeddings prefer the /api/embed endpoint.'],
            },
            policy: {
                local,
                streamProtocol: 'ollama-ndjson',
                reasoningMode: 'native-think-toggle',
                supportedReasoningEfforts: ['instant', 'low', 'medium', 'high'],
                cacheStrategy: 'provider-managed',
                notes: local ? ['Local-first embedding generation.'] : ['Remote Ollama Cloud usage is subject to service policy.'],
            },
        };
    }
    async chatViaNativeOllama(params, model) {
        const messages = toOllamaMessages(params.messages, params.systemPrompt);
        const think = mapOllamaThinking(params.reasoningEffort);
        const body = {
            model,
            messages,
            stream: true,
            ...(params.tools && params.tools.length > 0 ? { tools: toOpenAITools(params.tools) } : {}),
            ...(think !== undefined ? { think } : {}),
            ...(params.maxTokens ? { options: { num_predict: params.maxTokens } } : {}),
        };
        let response;
        try {
            response = await this.nativeFetch(this.nativeChatUrl, {
                method: 'POST',
                headers: {
                    accept: 'application/x-ndjson, application/json',
                    'content-type': 'application/json',
                },
                body: JSON.stringify(body),
                ...(params.signal !== undefined ? { signal: params.signal } : {}),
            });
        }
        catch (err) {
            throw normalizeProviderError(err, this.name, 'chat', 'request');
        }
        if (!response.ok) {
            throw await buildHttpError('Ollama native chat', response, this.name, 'chat', 'request');
        }
        if (!response.body) {
            throw new ProviderError('Ollama native chat returned no response body.', {
                statusCode: 502,
                provider: this.name,
                operation: 'chat',
                phase: 'response',
            });
        }
        let responseText = '';
        let inputTokens = 0;
        let outputTokens = 0;
        let doneReason = '';
        let finalToolCalls = [];
        const emittedToolCalls = new Set();
        await consumeNDJSON(response.body, (chunk) => {
            const message = chunk.message;
            const content = typeof message?.content === 'string' ? message.content : '';
            if (content) {
                responseText += content;
                params.onDelta?.({ content });
            }
            const thinking = typeof message?.thinking === 'string' ? message.thinking : '';
            if (thinking) {
                params.onDelta?.({ reasoning: thinking });
            }
            if (Array.isArray(message?.tool_calls) && message.tool_calls.length > 0) {
                const normalized = normalizeOllamaToolCalls(message.tool_calls);
                finalToolCalls = normalized;
                normalized.forEach((call, index) => {
                    if (emittedToolCalls.has(call.id))
                        return;
                    emittedToolCalls.add(call.id);
                    const argumentsText = JSON.stringify(call.arguments);
                    const partial = {
                        index,
                        id: call.id,
                        name: call.name,
                        arguments: argumentsText,
                    };
                    params.onDelta?.({ toolCalls: [partial] });
                });
            }
            if (chunk.done) {
                doneReason = typeof chunk.done_reason === 'string' ? chunk.done_reason : doneReason;
                inputTokens = typeof chunk.prompt_eval_count === 'number' ? chunk.prompt_eval_count : inputTokens;
                outputTokens = typeof chunk.eval_count === 'number' ? chunk.eval_count : outputTokens;
            }
        });
        const stopReason = mapOllamaStopReason(doneReason, finalToolCalls.length > 0);
        return {
            content: responseText,
            toolCalls: finalToolCalls,
            usage: {
                inputTokens,
                outputTokens,
            },
            stopReason,
            ...(doneReason ? { providerStopReason: doneReason } : {}),
        };
    }
}
function deriveOllamaChatUrl(baseURL) {
    const trimmed = baseURL.replace(/\/+$/, '');
    const origin = trimmed.endsWith('/v1') ? trimmed.slice(0, -3) : trimmed;
    return `${origin}/api/chat`;
}
function deriveOllamaEmbedUrl(baseURL) {
    const trimmed = baseURL.replace(/\/+$/, '');
    const origin = trimmed.endsWith('/v1') ? trimmed.slice(0, -3) : trimmed;
    return `${origin}/api/embed`;
}
function canUseNativeOllamaChat(messages) {
    if (messages.length === 0)
        return false;
    return !messages.some((message) => (message.role === 'tool'
        || (message.role === 'assistant' && (message.toolCalls?.length ?? 0) > 0)));
}
function toOllamaMessages(messages, systemPrompt) {
    const result = [];
    if (systemPrompt?.trim()) {
        result.push({ role: 'system', content: systemPrompt.trim() });
    }
    for (const message of messages) {
        if (message.role === 'user') {
            if (Array.isArray(message.content)) {
                result.push(contentPartsToOllamaMessage('user', message.content));
            }
            else {
                result.push({ role: 'user', content: message.content });
            }
            continue;
        }
        if (message.role === 'assistant') {
            result.push({ role: 'assistant', content: message.content });
            continue;
        }
    }
    return result;
}
function contentPartsToOllamaMessage(role, parts) {
    const text = parts
        .filter((part) => part.type === 'text')
        .map((part) => part.text)
        .join('\n');
    const images = parts
        .filter((part) => part.type === 'image')
        .map((part) => part.data);
    return {
        role,
        content: text,
        ...(images.length > 0 ? { images } : {}),
    };
}
function normalizeOllamaToolCalls(calls) {
    return calls.flatMap((call, index) => {
        const fn = call.function;
        if (!fn || typeof fn.name !== 'string' || fn.name.length === 0)
            return [];
        return [{
                id: `ollama_call_${index}`,
                name: fn.name,
                arguments: fn.arguments && typeof fn.arguments === 'object' ? fn.arguments : {},
            }];
    });
}
function mapOllamaThinking(reasoningEffort) {
    switch (reasoningEffort) {
        case 'instant':
            return false;
        case 'low':
        case 'medium':
        case 'high':
            return reasoningEffort;
        default:
            return undefined;
    }
}
async function consumeNDJSON(body, onChunk) {
    const decoder = new TextDecoder();
    let buffer = '';
    const reader = body.getReader();
    while (true) {
        const { done, value } = await reader.read();
        if (done)
            break;
        if (!value)
            continue;
        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split(/\r?\n/);
        buffer = lines.pop() ?? '';
        for (const line of lines) {
            const trimmed = line.trim();
            if (!trimmed)
                continue;
            onChunk(JSON.parse(trimmed));
        }
    }
    buffer += decoder.decode();
    const trailing = buffer.trim();
    if (trailing) {
        onChunk(JSON.parse(trailing));
    }
}
async function buildHttpError(prefix, response, provider, operation, phase) {
    const text = await response.text();
    try {
        const parsed = JSON.parse(text);
        const error = toRecord(parsed['error']);
        if (Object.keys(error).length > 0) {
            const code = typeof error['code'] === 'string' ? `${error['code']}: ` : '';
            const message = typeof error['message'] === 'string' ? error['message'] : text;
            return new ProviderError(`${prefix} error ${response.status}: ${code}${message}`, {
                statusCode: response.status,
                provider,
                operation,
                phase,
            });
        }
    }
    catch {
        // fall through
    }
    return new ProviderError(`${prefix} error ${response.status}: ${text || response.statusText}`, {
        statusCode: response.status,
        provider,
        operation,
        phase,
    });
}
function shouldFallbackFromNative(err) {
    const status = getErrorStatus(err);
    const message = getErrorMessage(err);
    if (status === 404 || status === 405 || status === 501)
        return true;
    if (status === 400 && /tool|messages|unsupported/i.test(message))
        return true;
    return /not implemented|unsupported|unknown endpoint/i.test(message);
}
function getErrorStatus(err) {
    if (err && typeof err === 'object') {
        const record = err;
        if (typeof record.status === 'number')
            return record.status;
        if (typeof record.statusCode === 'number')
            return record.statusCode;
    }
    return undefined;
}
function getErrorMessage(err) {
    return summarizeError(err);
}
function normalizeProviderError(err, provider, operation, phase = 'request') {
    const status = getErrorStatus(err);
    return toProviderError(err, {
        ...(status !== undefined ? { statusCode: status } : {}),
        provider,
        operation,
        phase,
    });
}
function toRecord(value) {
    return value && typeof value === 'object' ? value : {};
}
