import * as fs from 'fs';
import * as fsPromises from 'fs/promises';
import * as path from 'path';
import { emitProviderWarning } from '../runtime/emitters/index.js';
import { OpenAICompatProvider } from './openai-compat.js';
import { AnthropicCompatProvider } from './anthropic-compat.js';
import { summarizeError } from '../utils/error-display.js';
import { LocalContextIngestionService, resolveContextWindow, } from './local-context-ingestion.js';
/** Debounce delay for file watcher (ms). */
const WATCH_DEBOUNCE_MS = 300;
/**
 * Resolve API key from config.
 * Priority: explicit apiKey > env var (apiKeyEnv) > empty string.
 */
function resolveApiKey(config) {
    if (config.apiKey)
        return config.apiKey;
    if (config.apiKeyEnv) {
        const val = process.env[config.apiKeyEnv];
        if (val)
            return val;
    }
    return '';
}
/**
 * Validate a single parsed JSON object against the CustomProviderConfig schema.
 * Returns { valid, errors } — errors is empty when valid.
 */
function validateCustomProvider(data) {
    const errors = [];
    if (typeof data !== 'object' || data === null || Array.isArray(data)) {
        return { valid: false, errors: ['Root value must be a JSON object'] };
    }
    const obj = data;
    if (typeof obj['name'] !== 'string' || obj['name'].trim() === '') {
        errors.push('"name" must be a non-empty string');
    }
    if (typeof obj['displayName'] !== 'string' || obj['displayName'].trim() === '') {
        errors.push('"displayName" must be a non-empty string');
    }
    if (obj['type'] !== 'openai-compat' && obj['type'] !== 'anthropic-compat') {
        errors.push('"type" must be "openai-compat" or "anthropic-compat"');
    }
    if (typeof obj['baseURL'] !== 'string' || obj['baseURL'].trim() === '') {
        errors.push('"baseURL" must be a non-empty string');
    }
    if (obj['reasoningFormat'] !== undefined) {
        if (!['mercury', 'openrouter', 'llamacpp', 'none'].includes(obj['reasoningFormat'])) {
            errors.push('"reasoningFormat" must be "mercury", "openrouter", "llamacpp", or "none"');
        }
    }
    if (!Array.isArray(obj['models']) || obj['models'].length === 0) {
        errors.push('"models" must be a non-empty array');
    }
    else {
        const models = obj['models'];
        models.forEach((m, i) => {
            if (typeof m !== 'object' || m === null || Array.isArray(m)) {
                errors.push(`models[${i}]: must be an object`);
                return;
            }
            const model = m;
            if (typeof model['id'] !== 'string' || model['id'].trim() === '') {
                errors.push(`models[${i}]: "id" must be a non-empty string`);
            }
            if (typeof model['displayName'] !== 'string' || model['displayName'].trim() === '') {
                errors.push(`models[${i}]: "displayName" must be a non-empty string`);
            }
            if (typeof model['contextWindow'] !== 'number' || model['contextWindow'] <= 0) {
                errors.push(`models[${i}]: "contextWindow" must be a positive number`);
            }
            if (typeof model['capabilities'] !== 'object' || model['capabilities'] === null) {
                errors.push(`models[${i}]: "capabilities" must be an object`);
            }
            else {
                const caps = model['capabilities'];
                for (const cap of ['toolCalling', 'codeEditing', 'reasoning', 'multimodal']) {
                    if (typeof caps[cap] !== 'boolean') {
                        errors.push(`models[${i}]: capabilities.${cap} must be a boolean`);
                    }
                }
            }
        });
    }
    return { valid: errors.length === 0, errors };
}
/**
 * Ensure the providers directory exists, creating it if necessary.
 */
async function ensureProvidersDir(providersDir) {
    try {
        await fsPromises.mkdir(providersDir, { recursive: true });
    }
    catch (err) {
        throw new Error(`[custom-loader] Failed to create providers directory '${providersDir}': ${summarizeError(err)}`);
    }
}
/**
 * Load all custom providers from an owned providers directory.
 * Auto-creates the directory if it does not exist.
 * Invalid files are skipped with a warning rather than failing the whole load.
 *
 * When `options.ingestContextWindows` is true, each provider's /v1/models
 * endpoint is queried concurrently (via Promise.allSettled) to resolve
 * `max_context_length` with `provider_api` provenance.
 */
export async function loadCustomProviders(options) {
    const warnings = [];
    const providers = [];
    const models = [];
    const { providersDir } = options;
    await ensureProvidersDir(providersDir);
    let entries;
    try {
        const dirents = await fsPromises.readdir(providersDir, { withFileTypes: true });
        entries = dirents
            .filter((d) => d.isFile() && d.name.endsWith('.json'))
            .map((d) => d.name);
    }
    catch (err) {
        warnings.push(`[custom-loader] Could not read providers directory: ${summarizeError(err)}`);
        return { providers, models, warnings };
    }
    // Phase 1: Parse and validate all provider files, instantiate provider objects.
    // Collect valid entries; skip invalid ones with warnings.
    const validConfigs = [];
    for (const filename of entries) {
        const filepath = path.join(providersDir, filename);
        let raw;
        try {
            raw = await fsPromises.readFile(filepath, 'utf-8');
        }
        catch (err) {
            warnings.push(`[custom-loader] Could not read '${filename}': ${summarizeError(err)}`);
            continue;
        }
        let parsed;
        try {
            parsed = JSON.parse(raw);
        }
        catch (err) {
            warnings.push(`[custom-loader] Invalid JSON in '${filename}': ${summarizeError(err)}`);
            continue;
        }
        const { valid, errors } = validateCustomProvider(parsed);
        if (!valid) {
            warnings.push(`[custom-loader] Skipping '${filename}' — validation errors:\n  ${errors.join('\n  ')}`);
            continue;
        }
        const cfg = parsed;
        const apiKey = resolveApiKey(cfg);
        const modelIds = cfg.models.map((m) => m.id);
        let provider;
        try {
            if (cfg.type === 'anthropic-compat') {
                provider = new AnthropicCompatProvider({
                    name: cfg.name,
                    baseURL: cfg.baseURL,
                    apiKey,
                    defaultModel: modelIds[0],
                    models: modelIds,
                    ...(cfg.defaultHeaders ? { defaultHeaders: cfg.defaultHeaders } : {}),
                });
            }
            else {
                provider = new OpenAICompatProvider({
                    name: cfg.name,
                    baseURL: cfg.baseURL,
                    apiKey,
                    defaultModel: modelIds[0],
                    models: modelIds,
                    ...(cfg.defaultHeaders ? { defaultHeaders: cfg.defaultHeaders } : {}),
                    reasoningFormat: cfg.reasoningFormat ?? 'none',
                });
            }
        }
        catch (err) {
            warnings.push(`[custom-loader] Failed to instantiate provider from '${filename}': ${summarizeError(err)}`);
            continue;
        }
        validConfigs.push({ cfg, provider, apiKey });
    }
    // Phase 2: Ingest context windows concurrently for all valid providers.
    // Runs only when options.ingestContextWindows is true.
    const ingestionResults = validConfigs.map(() => null);
    if (options.ingestContextWindows && options.contextIngestion && validConfigs.length > 0) {
        const ingestionPromises = validConfigs.map(({ cfg, apiKey }) => options.contextIngestion?.ingestProviderContextWindows(cfg.name, cfg.baseURL, apiKey || undefined));
        const settled = await Promise.allSettled(ingestionPromises);
        for (let i = 0; i < settled.length; i++) {
            const result = settled[i];
            const providerName = validConfigs[i]?.cfg.name ?? '<unknown>';
            if (result.status === 'fulfilled') {
                ingestionResults[i] = result.value ?? null;
                if (result.value === null) {
                    warnings.push(`[custom-loader] No context window data discovered for '${providerName}'; using configured model context windows.`);
                }
            }
            else {
                ingestionResults[i] = null;
                warnings.push(`[custom-loader] Context window ingestion failed for '${providerName}': ${summarizeError(result.reason)}`);
            }
        }
    }
    // Phase 3: Build model definitions and populate output arrays.
    for (let i = 0; i < validConfigs.length; i++) {
        const { cfg, provider } = validConfigs[i];
        const apiContextMap = ingestionResults[i] ?? null;
        const modelDefs = cfg.models.map((m) => {
            const apiContextLength = apiContextMap?.get(m.id) ?? null;
            const resolved = resolveContextWindow(m.id, apiContextLength, m.contextWindow);
            return {
                id: m.id,
                provider: cfg.name,
                registryKey: `${cfg.name}:${m.id}`,
                displayName: m.displayName,
                description: m.description ?? '',
                contextWindow: resolved.tokens,
                contextWindowProvenance: resolved.provenance,
                selectable: m.selectable ?? true,
                capabilities: {
                    toolCalling: m.capabilities.toolCalling,
                    codeEditing: m.capabilities.codeEditing,
                    reasoning: m.capabilities.reasoning,
                    multimodal: m.capabilities.multimodal,
                },
                ...(m.reasoningEffort ? { reasoningEffort: m.reasoningEffort } : {}),
                ...(m.tier ? { tier: m.tier } : {}),
            };
        });
        providers.push({ config: cfg, provider });
        models.push(...modelDefs);
    }
    return { providers, models, warnings };
}
/**
 * Start watching an owned providers directory for file changes.
 * Debounces rapid events by 300ms before invoking the onChange callback.
 * Emits typed provider warnings if the watcher cannot be started.
 * Returns a handle with a `close()` method to stop watching.
 */
export function watchCustomProviders(runtimeBus, onChange, providersDir) {
    let debounceTimer = null;
    let watcher = null;
    let closed = false;
    const emitWarning = (message) => {
        if (runtimeBus) {
            emitProviderWarning(runtimeBus, {
                sessionId: 'system',
                traceId: `providers:warning:${Date.now()}`,
                source: 'custom-provider-loader',
            }, { message });
        }
    };
    const startWatch = () => {
        if (closed)
            return;
        try {
            // Note: fs.watch() may miss atomic renames (e.g. editor save-via-rename) on
            // some Linux filesystems. If that becomes a problem, consider replacing this
            // with the 'chokidar' library which uses inotify directly.
            watcher = fs.watch(providersDir, { persistent: false }, // Don't keep the Node process alive just for this watcher
            (_eventType, _filename) => {
                if (debounceTimer)
                    clearTimeout(debounceTimer);
                debounceTimer = setTimeout(() => {
                    debounceTimer = null;
                    onChange();
                }, WATCH_DEBOUNCE_MS);
                debounceTimer.unref?.();
            });
            watcher.on('error', (err) => {
                if (closed)
                    return;
                emitWarning(`[custom-loader] Watcher error: ${err.message}`);
            });
        }
        catch (err) {
            if (closed)
                return;
            emitWarning(`[custom-loader] Could not watch providers directory: ${summarizeError(err)}`);
        }
    };
    // Ensure the directory exists before starting the watcher
    fsPromises
        .mkdir(providersDir, { recursive: true })
        .then(() => {
        if (closed)
            return;
        startWatch();
    })
        .catch((err) => {
        if (closed)
            return;
        emitWarning(`[custom-loader] Could not create/watch providers directory: ${summarizeError(err)}`);
    });
    return {
        close() {
            closed = true;
            if (debounceTimer) {
                clearTimeout(debounceTimer);
                debounceTimer = null;
            }
            if (watcher) {
                watcher.close();
                watcher = null;
            }
        },
    };
}
