/**
 * OllamaAdapter -- Ollama self-hosted LLM transport adapter.
 *
 * Implements LlmProviderAdapter for the Ollama REST API.
 *
 * Wire format:
 *   POST <baseUrl>/api/chat    -- chat (non-streaming and streaming)
 *
 * Streaming: NDJSON -- each line is a JSON object with `message.content`
 * and a `done` boolean on the final line.
 *
 * Tool use: Ollama 0.3.x tool-use format (tools array in request,
 * tool_calls array in response).
 *
 * SSRF: Ollama runs at loopback (127.0.0.1:11434 by default).
 * The adapter registers a session-scoped loopback allowance with
 * ssrf-guard.ts on initialize() and revokes it on teardown.
 *
 * BAA: None. Emits WARN at initialize() if a HIPAA-active compliance
 * pack is detected in the runtime context.
 *
 * Spec: P3-SUB-01 AC 16-19 (OllamaAdapter)
 *
 * @connexum/ai-governance
 */
import * as https from 'https';
import * as http from 'http';
import * as crypto from 'crypto';
import { URL } from 'url';
import { BaseProviderAdapter, registerProviderCompliance } from './base.js';
import { LlmProviderError, LlmAuthError, LlmModelNotFoundError, } from './index.js';
import { coerceTextContent, detectUnexpectedShape, normalizeToolUseRequest, normalizeToolUseResponse, } from './shared.js';
import { registerLoopbackSession, revokeLoopbackSession, } from '../security/ssrf-guard.js';
// ---------------------------------------------------------------------------
// Constants (AC 26 -- version pinning)
// ---------------------------------------------------------------------------
/**
 * Ollama API version this adapter was tested against.
 * Ollama does not publish a formal API version header. We pin to the
 * minimum version that introduced tool use (0.3.x).
 * TODO (Vera/Legal, Kai): Verify against https://ollama.com/blog/tool-support
 * before enabling in production. Flag DEFERRED-OLLAMA-API-VERSION.
 */
export const TESTED_API_VERSION = '0.3';
/** Default Ollama bind address */
const DEFAULT_BASE_URL = 'http://127.0.0.1:11434';
function httpRequest(urlStr, method, headers, body, timeoutMs) {
    return new Promise((resolve, reject) => {
        let url;
        try {
            url = new URL(urlStr);
        }
        catch {
            reject(new Error(`Invalid URL: ${urlStr}`));
            return;
        }
        const isHttps = url.protocol === 'https:';
        const transport = isHttps ? https : http;
        const options = {
            hostname: url.hostname,
            port: url.port || (isHttps ? 443 : 80),
            path: url.pathname + url.search,
            method,
            headers: {
                ...headers,
                'Content-Length': Buffer.byteLength(body).toString(),
            },
        };
        const req = transport.request(options, (res) => {
            const chunks = [];
            res.on('data', (chunk) => chunks.push(chunk));
            res.on('end', () => {
                resolve({
                    statusCode: res.statusCode ?? 0,
                    headers: res.headers,
                    body: Buffer.concat(chunks).toString('utf-8'),
                });
            });
            res.on('error', reject);
        });
        req.setTimeout(timeoutMs, () => {
            req.destroy(new Error(`Request timed out after ${timeoutMs}ms`));
        });
        req.on('error', reject);
        req.write(body);
        req.end();
    });
}
// ---------------------------------------------------------------------------
// Register Ollama compliance metadata on module load (AC 19)
// ---------------------------------------------------------------------------
registerProviderCompliance({
    providerId: 'ollama',
    baa: false,
    gdprDpa: false,
    soc2Type2: false,
    sovereignty: 'local',
    attestationUrl: 'https://ollama.com/privacy',
    // TODO (Vera/Legal, Kai): Verify this date against the attestation URL above.
    // Set to spec implementation date as placeholder -- flag DEFERRED-OLLAMA-ATTEST.
    attestationDate: '2026-04-22',
    reviewCadence: '180d',
});
// ---------------------------------------------------------------------------
// OllamaAdapter
// ---------------------------------------------------------------------------
export class OllamaAdapter extends BaseProviderAdapter {
    adapterId = 'ollama';
    providerId = 'ollama';
    ollamaConfig = null;
    loopbackSessionToken = null;
    // ---------------------------------------------------------------------------
    // Lifecycle
    // ---------------------------------------------------------------------------
    initialize(config) {
        const cfg = config;
        // Validate baseUrl if explicitly provided
        const baseUrl = cfg.baseUrl ?? DEFAULT_BASE_URL;
        try {
            new URL(baseUrl);
        }
        catch {
            throw new LlmAuthError({
                message: `OllamaAdapter: baseUrl '${baseUrl}' is not a valid URL.`,
                providerId: this.providerId,
                adapterId: this.adapterId,
            });
        }
        super.initialize({ ...config, baseUrl });
        this.ollamaConfig = { ...cfg, baseUrl };
        // Register loopback session with SSRF guard (AC 18)
        // This is scoped: only affects checkUrlWithSession(), not checkUrl().
        this.loopbackSessionToken = crypto.randomUUID();
        registerLoopbackSession(this.loopbackSessionToken);
        // AC 17: WARN if HIPAA pack active
        if (cfg.hipaaPackActive) {
            this.emitHipaaWarning(cfg.offlineBaaReference);
        }
    }
    /**
     * Revoke the loopback session when the adapter is no longer needed.
     * Call this when tearing down an OllamaAdapter instance.
     */
    teardown() {
        if (this.loopbackSessionToken) {
            revokeLoopbackSession(this.loopbackSessionToken);
            this.loopbackSessionToken = null;
        }
    }
    /**
     * Returns the session token registered with the SSRF guard.
     * The governance runtime passes this to checkUrlWithSession().
     */
    getLoopbackSessionToken() {
        return this.loopbackSessionToken;
    }
    // ---------------------------------------------------------------------------
    // HIPAA warning (AC 17)
    // ---------------------------------------------------------------------------
    emitHipaaWarning(offlineBaaReference) {
        const detail = 'Ollama is self-hosted. BAA coverage is not available from Ollama Inc. ' +
            'If this deployment satisfies HIPAA via an on-premises BAA with the infrastructure provider, ' +
            'supply the offlineBaaReference config field with the document reference.';
        this.emit({
            type: 'llm:auth-failure',
            severity: 'WARN',
            adapterId: this.adapterId,
            providerId: this.providerId,
            detail,
            timestamp: new Date().toISOString(),
            data: {
                hipaaPackActive: true,
                offlineBaaReference: offlineBaaReference ?? null,
            },
        });
    }
    // ---------------------------------------------------------------------------
    // URL builder
    // ---------------------------------------------------------------------------
    buildUrl(path) {
        const base = (this.ollamaConfig?.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '');
        return `${base}${path}`;
    }
    // ---------------------------------------------------------------------------
    // Message conversion: LlmMessage[] -> OllamaMessage[]
    // ---------------------------------------------------------------------------
    convertMessages(request) {
        const messages = [];
        if ('systemPrompt' in request && request.systemPrompt) {
            messages.push({ role: 'system', content: request.systemPrompt });
        }
        for (const msg of request.messages) {
            const role = msg.role;
            // F-NEW-GEMMA-VISION 2026-05-09: ollama supports vision models
            // (llava etc.) but this adapter has not been extended for the
            // multimodal images[] array yet. Reject image_url parts at the
            // boundary rather than silently drop.
            const text = coerceTextContent(msg, {
                providerId: this.providerId,
                adapterId: this.adapterId,
            });
            messages.push({ role, content: text });
        }
        return messages;
    }
    // ---------------------------------------------------------------------------
    // Validate model ID
    // ---------------------------------------------------------------------------
    validateModelId(modelId) {
        if (!modelId || typeof modelId !== 'string') {
            throw new LlmModelNotFoundError({
                message: `OllamaAdapter: invalid modelId '${modelId}'. Provide a non-empty string (e.g. 'llama3', 'mistral').`,
                providerId: this.providerId,
                adapterId: this.adapterId,
                requestedModelId: String(modelId ?? ''),
            });
        }
    }
    // ---------------------------------------------------------------------------
    // chat (/api/chat -- non-streaming)
    // ---------------------------------------------------------------------------
    async chat(request) {
        this.ensureInitialized();
        this.validateModelId(request.modelId);
        return this.withRetry(async () => this.chatOnce(request));
    }
    async chatOnce(request) {
        const messages = this.convertMessages(request);
        const payload = {
            model: request.modelId,
            messages,
            stream: false,
            options: {
                num_predict: request.maxTokens,
                temperature: request.temperature,
            },
        };
        if (request.tools && request.tools.length > 0) {
            payload.tools = normalizeToolUseRequest(request.tools, 'bedrock').map((t) => {
                const spec = t.toolSpec;
                return {
                    type: 'function',
                    function: {
                        name: spec.name,
                        description: spec.description,
                        parameters: spec.inputSchema.json,
                    },
                };
            });
        }
        const url = this.buildUrl('/api/chat');
        const rawBody = JSON.stringify(payload);
        const headers = { 'Content-Type': 'application/json' };
        const timeoutMs = this.ollamaConfig?.timeoutMs ?? 30000;
        const response = await httpRequest(url, 'POST', headers, rawBody, timeoutMs);
        this.handleHttpErrors(response, request.modelId);
        let parsed;
        try {
            // Ollama non-streaming returns a single JSON object (the final chunk)
            // or may return NDJSON where the last line has done=true.
            // We take the last non-empty line.
            const lines = response.body.split('\n').filter((l) => l.trim().length > 0);
            const lastLine = lines[lines.length - 1] ?? '{}';
            parsed = JSON.parse(lastLine);
        }
        catch {
            throw new LlmProviderError({
                message: 'OllamaAdapter: failed to parse response as JSON',
                code: 'UNEXPECTED_RESPONSE_SHAPE',
                providerId: this.providerId,
                adapterId: this.adapterId,
                raw: response.body.slice(0, 200),
            });
        }
        detectUnexpectedShape(parsed, ['message'], this.providerId, this.adapterId);
        const content = parsed.message?.content ?? '';
        // Extract tool calls (Ollama 0.3.x format)
        const rawToolCalls = parsed.message?.tool_calls ?? [];
        const toolCalls = rawToolCalls.length > 0
            ? rawToolCalls.map((tc, idx) => ({
                id: `ollama_tool_${idx}`,
                name: tc.function.name,
                arguments: tc.function.arguments,
            }))
            : undefined;
        const usage = parsed.prompt_eval_count != null || parsed.eval_count != null
            ? {
                inputTokens: parsed.prompt_eval_count ?? 0,
                outputTokens: parsed.eval_count ?? 0,
                totalTokens: (parsed.prompt_eval_count ?? 0) + (parsed.eval_count ?? 0),
            }
            : undefined;
        // Emit offlineBaaReference in audit chain if present (AC 17)
        const offlineBaaRef = this.ollamaConfig?.offlineBaaReference;
        if (offlineBaaRef) {
            this.emit({
                type: 'llm:auth-failure',
                severity: 'INFO',
                adapterId: this.adapterId,
                providerId: this.providerId,
                detail: `OllamaAdapter: offlineBaaReference is set: ${offlineBaaRef}`,
                timestamp: new Date().toISOString(),
                data: { offlineBaaReference: offlineBaaRef },
            });
        }
        return {
            modelId: parsed.model ?? request.modelId,
            content,
            finishReason: this.mapDoneReason(parsed.done_reason),
            toolCalls,
            usage,
            _raw: parsed,
        };
    }
    // ---------------------------------------------------------------------------
    // chatStream (/api/chat -- NDJSON streaming)
    // ---------------------------------------------------------------------------
    async *chatStream(request) {
        this.ensureInitialized();
        this.validateModelId(request.modelId);
        const messages = this.convertMessages(request);
        const payload = {
            model: request.modelId,
            messages,
            stream: true,
            options: {
                num_predict: request.maxTokens,
                temperature: request.temperature,
            },
        };
        const url = this.buildUrl('/api/chat');
        const rawBody = JSON.stringify(payload);
        const headers = { 'Content-Type': 'application/json' };
        const timeoutMs = this.ollamaConfig?.timeoutMs ?? 60000;
        const response = await httpRequest(url, 'POST', headers, rawBody, timeoutMs);
        this.handleHttpErrors(response, request.modelId);
        // Parse NDJSON: each line is a complete JSON object
        const lines = response.body.split('\n');
        for (const line of lines) {
            const trimmed = line.trim();
            if (!trimmed)
                continue;
            let chunk;
            try {
                chunk = JSON.parse(trimmed);
            }
            catch {
                continue;
            }
            const delta = chunk.message?.content ?? '';
            if (chunk.done) {
                const usage = chunk.prompt_eval_count != null || chunk.eval_count != null
                    ? {
                        inputTokens: chunk.prompt_eval_count ?? 0,
                        outputTokens: chunk.eval_count ?? 0,
                        totalTokens: (chunk.prompt_eval_count ?? 0) + (chunk.eval_count ?? 0),
                    }
                    : undefined;
                yield {
                    delta,
                    finishReason: this.mapDoneReason(chunk.done_reason),
                    usage,
                };
            }
            else {
                if (delta) {
                    yield { delta };
                }
            }
        }
    }
    // ---------------------------------------------------------------------------
    // invokeToolUse (/api/chat with tools)
    // ---------------------------------------------------------------------------
    async invokeToolUse(request) {
        this.ensureInitialized();
        this.validateModelId(request.modelId);
        return this.withRetry(async () => this.invokeToolUseOnce(request));
    }
    async invokeToolUseOnce(request) {
        if (!request.tools || request.tools.length === 0) {
            throw new LlmProviderError({
                message: 'OllamaAdapter.invokeToolUse: at least one tool definition is required.',
                code: 'TOOL_USE_FAILURE',
                providerId: this.providerId,
                adapterId: this.adapterId,
            });
        }
        const messages = this.convertMessages(request);
        const ollamaTools = request.tools.map((t) => ({
            type: 'function',
            function: {
                name: t.name,
                description: t.description,
                parameters: t.parameters,
            },
        }));
        const payload = {
            model: request.modelId,
            messages,
            stream: false,
            tools: ollamaTools,
            options: {
                num_predict: request.maxTokens,
                temperature: request.temperature,
            },
        };
        const url = this.buildUrl('/api/chat');
        const rawBody = JSON.stringify(payload);
        const headers = { 'Content-Type': 'application/json' };
        const timeoutMs = this.ollamaConfig?.timeoutMs ?? 30000;
        const response = await httpRequest(url, 'POST', headers, rawBody, timeoutMs);
        this.handleHttpErrors(response, request.modelId);
        let parsed;
        try {
            const lines = response.body.split('\n').filter((l) => l.trim().length > 0);
            const lastLine = lines[lines.length - 1] ?? '{}';
            parsed = JSON.parse(lastLine);
        }
        catch {
            throw new LlmProviderError({
                message: 'OllamaAdapter: failed to parse tool-use response as JSON',
                code: 'UNEXPECTED_RESPONSE_SHAPE',
                providerId: this.providerId,
                adapterId: this.adapterId,
                raw: response.body.slice(0, 200),
            });
        }
        detectUnexpectedShape(parsed, ['message'], this.providerId, this.adapterId);
        const rawToolCalls = parsed.message?.tool_calls ?? [];
        const toolCalls = normalizeToolUseResponse(rawToolCalls.map((tc, idx) => ({
            toolUse: {
                toolUseId: `ollama_tool_${idx}`,
                name: tc.function.name,
                input: tc.function.arguments,
            },
        })), 'bedrock');
        const content = parsed.message?.content ?? undefined;
        const usage = parsed.prompt_eval_count != null || parsed.eval_count != null
            ? {
                inputTokens: parsed.prompt_eval_count ?? 0,
                outputTokens: parsed.eval_count ?? 0,
                totalTokens: (parsed.prompt_eval_count ?? 0) + (parsed.eval_count ?? 0),
            }
            : undefined;
        // Emit offlineBaaReference in audit chain if present
        const offlineBaaRef = this.ollamaConfig?.offlineBaaReference;
        if (offlineBaaRef) {
            this.emit({
                type: 'llm:auth-failure',
                severity: 'INFO',
                adapterId: this.adapterId,
                providerId: this.providerId,
                detail: `OllamaAdapter: offlineBaaReference is set: ${offlineBaaRef}`,
                timestamp: new Date().toISOString(),
                data: { offlineBaaReference: offlineBaaRef },
            });
        }
        return {
            modelId: parsed.model ?? request.modelId,
            toolCalls,
            content: content || undefined,
            finishReason: this.mapDoneReason(parsed.done_reason),
            usage,
            _raw: parsed,
        };
    }
    // ---------------------------------------------------------------------------
    // enumerateModels (/api/tags)
    // ---------------------------------------------------------------------------
    async enumerateModels() {
        this.ensureInitialized();
        // DEFERRED-OLLAMA-LIST-MODELS: Implement /api/tags call to enumerate
        // locally available models. Return configured default for now.
        const cfg = this.ollamaConfig;
        const modelId = cfg.defaultModelId ?? 'llama3';
        return [
            {
                modelId,
                family: 'llama',
                displayName: `Ollama: ${modelId}`,
                deprecated: false,
            },
        ];
    }
    // ---------------------------------------------------------------------------
    // translateError
    // ---------------------------------------------------------------------------
    translateError(raw) {
        if (raw instanceof LlmProviderError)
            return raw;
        const isObj = (v) => typeof v === 'object' && v !== null;
        let httpStatus;
        let errorMessage;
        if (isObj(raw)) {
            httpStatus = typeof raw['status'] === 'number' ? raw['status'] : undefined;
            errorMessage = typeof raw['error'] === 'string' ? raw['error'] : undefined;
        }
        else if (raw instanceof Error) {
            errorMessage = raw.message;
        }
        else {
            errorMessage = String(raw);
        }
        const rawStr = isObj(raw) ? JSON.stringify(raw).slice(0, 500) : String(raw).slice(0, 500);
        if (httpStatus === 404 || (errorMessage && errorMessage.toLowerCase().includes('not found'))) {
            const requestedModelId = this.ollamaConfig?.defaultModelId ?? 'unknown';
            return new LlmModelNotFoundError({
                message: `Ollama model not found: ${errorMessage ?? 'not found'}`,
                providerId: this.providerId,
                adapterId: this.adapterId,
                httpStatus,
                requestedModelId,
                raw: rawStr,
            });
        }
        return new LlmProviderError({
            message: errorMessage ?? `Ollama error (HTTP ${httpStatus ?? 'unknown'})`,
            code: 'PROVIDER_ERROR',
            providerId: this.providerId,
            adapterId: this.adapterId,
            httpStatus,
            raw: rawStr,
        });
    }
    // ---------------------------------------------------------------------------
    // getCapabilities
    // ---------------------------------------------------------------------------
    getCapabilities() {
        return {
            chat: true,
            streaming: true,
            toolUse: true,
            modelEnumeration: true, // /api/tags
            baa: false,
        };
    }
    // ---------------------------------------------------------------------------
    // Private helpers
    // ---------------------------------------------------------------------------
    handleHttpErrors(response, requestedModelId) {
        if (response.statusCode >= 200 && response.statusCode < 300)
            return;
        let errorBody = null;
        try {
            errorBody = JSON.parse(response.body);
        }
        catch {
            // body may not be JSON
        }
        const isObj = (v) => typeof v === 'object' && v !== null;
        // Ollama error format: { "error": "message string" }
        const errorMessage = isObj(errorBody) && typeof errorBody['error'] === 'string'
            ? errorBody['error']
            : undefined;
        const raw = response.body.slice(0, 500);
        if (response.statusCode === 404 ||
            (errorMessage && errorMessage.toLowerCase().includes('model') && errorMessage.toLowerCase().includes('not found'))) {
            throw new LlmModelNotFoundError({
                message: `Ollama model not found: ${requestedModelId}. ${errorMessage ?? ''}`.trim(),
                providerId: this.providerId,
                adapterId: this.adapterId,
                httpStatus: response.statusCode,
                requestedModelId,
                raw,
            });
        }
        throw new LlmProviderError({
            message: errorMessage ?? `Ollama error (HTTP ${response.statusCode})`,
            code: 'PROVIDER_ERROR',
            providerId: this.providerId,
            adapterId: this.adapterId,
            httpStatus: response.statusCode,
            raw,
        });
    }
    mapDoneReason(reason) {
        switch (reason) {
            case 'stop': return 'stop';
            case 'length': return 'length';
            case 'tool_calls': return 'tool_calls';
            case 'load': return 'unknown';
            default: return reason ? 'stop' : 'unknown';
        }
    }
}
//# sourceMappingURL=ollama.js.map