/**
 * Shared Startup Validation
 *
 * Validates startup dependencies and applies environment-driven config.
 * Used by both src/index.ts (stdio) and src/bin-http.ts (unified Docker entry).
 *
 * CRITICAL: NEVER use console.log() - stdout may be reserved for JSON-RPC protocol.
 *
 * @module server/startup
 */
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
import { join } from 'path';
import { generateKeyPairSync, randomBytes } from 'crypto';
import { fork } from 'child_process';
import Database from 'better-sqlite3';
import { updateConfig } from './state.js';
import { RegistryService } from '../services/storage/registry/index.js';
import { DEFAULT_STORAGE_PATH } from '../services/storage/database/helpers.js';
import { killAllVLMWorkers } from '../services/vlm/chandra.js';
import { killAllOCRWorkers } from '../services/ocr/marker.js';
import { killAllEmbeddingWorkers } from '../services/embedding/nomic.js';
import { initLicenseGate, getLicenseGate } from '../services/license/index.js';
let _licenseServerProcess = null;
/**
 * Check if a URL is reachable via HTTP GET within a timeout.
 */
async function isReachable(url, timeoutMs = 3000) {
    try {
        const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
        return res.ok;
    }
    catch {
        return false;
    }
}
/**
 * Generate Ed25519 keypair and return hex-encoded private seed + public key.
 */
function generateEd25519Hex() {
    const { publicKey, privateKey } = generateKeyPairSync('ed25519');
    const privDer = privateKey.export({ format: 'der', type: 'pkcs8' });
    const pubDer = publicKey.export({ format: 'der', type: 'spki' });
    return {
        privateHex: Buffer.from(privDer).subarray(16).toString('hex'),
        publicHex: Buffer.from(pubDer).subarray(12).toString('hex'),
    };
}
/**
 * Auto-start the license server as a child process when running locally (not Docker).
 * Generates secrets if not already set, persists them to a local secrets file,
 * and spawns the license server on port 3000.
 *
 * This handles the case where the MCP server runs via stdio/HTTP without Docker
 * (e.g., development, or Claude Code direct connection).
 */
async function ensureLicenseServer() {
    const serverUrl = (process.env.OCR_LICENSE_SERVER || 'http://localhost:3000').replace(/\/v1\/?$/, '');
    const healthUrl = `${serverUrl}/health`;
    // Check if already running
    if (await isReachable(healthUrl)) {
        process.env.OCR_LICENSE_SERVER = serverUrl;
        console.error(`[startup] License server reachable at ${serverUrl}`);
        return;
    }
    // Don't auto-start inside Docker — the entrypoint handles it
    if (existsSync('/.dockerenv')) {
        console.error(`[startup] License server unreachable in Docker mode — entrypoint should have started it`);
        throw new Error(`License server unreachable at ${serverUrl}. Check Docker entrypoint logs.`);
    }
    // Find the license server binary
    const serverDistPaths = [
        join(process.cwd(), 'packages', 'server', 'dist', 'index.js'),
        join(process.cwd(), '..', 'packages', 'server', 'dist', 'index.js'),
    ];
    const serverBin = serverDistPaths.find(p => existsSync(p));
    if (!serverBin) {
        console.error(`[startup] License server not reachable and binary not found at: ${serverDistPaths.join(', ')}`);
        console.error(`[startup] Build it with: cd packages/server && npm run build`);
        throw new Error(`License server unreachable at ${serverUrl} and no local binary found to auto-start.`);
    }
    console.error(`[startup] License server not reachable at ${serverUrl}. Auto-starting from ${serverBin}...`);
    // Load or generate secrets from a persistent local location.
    // Local mode: ~/.ocr-provenance-mcp/.license_secrets
    // Docker mode: /data/.license_secrets (handled by docker-entrypoint.sh, NOT this code)
    // Using a persistent file ensures the signing key never changes → license keys stay valid forever.
    const secretsDir = join(DEFAULT_STORAGE_PATH, '..');
    const secretsFile = join(secretsDir, '.license_secrets');
    let secrets = {};
    function loadSecretsFromFile(path) {
        const result = {};
        const content = readFileSync(path, 'utf-8');
        for (const line of content.split('\n')) {
            const eqIdx = line.indexOf('=');
            if (eqIdx > 0) {
                result[line.slice(0, eqIdx).trim()] = line.slice(eqIdx + 1).trim();
            }
        }
        return result;
    }
    if (existsSync(secretsFile)) {
        secrets = loadSecretsFromFile(secretsFile);
        console.error(`[startup] Loaded license secrets from ${secretsFile}`);
    }
    // Generate any missing secrets (first-ever run, or migrating from older version)
    let changed = false;
    if (!secrets.SESSION_SECRET) {
        secrets.SESSION_SECRET = randomBytes(32).toString('hex');
        changed = true;
    }
    // WORKER_AUTH_SECRET and WEBHOOK_SHARED_SECRET are SHARED credentials that authenticate
    // with the centralized Cloudflare Worker (api.ocrprovenance.com). They must be the SAME
    // across all installations. Fetch from the Worker if not already persisted.
    if (!secrets.WORKER_AUTH_SECRET || !secrets.WEBHOOK_SHARED_SECRET) {
        const checkoutApiUrl = process.env.CHECKOUT_API_URL || 'https://api.ocrprovenance.com';
        try {
            const configRes = await fetch(`${checkoutApiUrl}/v1/client-config`, {
                signal: AbortSignal.timeout(10000),
            });
            if (configRes.ok) {
                const config = (await configRes.json());
                if (config.worker_auth_secret && !secrets.WORKER_AUTH_SECRET) {
                    secrets.WORKER_AUTH_SECRET = config.worker_auth_secret;
                    changed = true;
                    console.error('[startup] Fetched WORKER_AUTH_SECRET from checkout API');
                }
                if (config.webhook_shared_secret && !secrets.WEBHOOK_SHARED_SECRET) {
                    secrets.WEBHOOK_SHARED_SECRET = config.webhook_shared_secret;
                    changed = true;
                    console.error('[startup] Fetched WEBHOOK_SHARED_SECRET from checkout API');
                }
            }
            else {
                console.error(`[startup] WARN: Could not fetch client config from ${checkoutApiUrl}: HTTP ${configRes.status}`);
            }
        }
        catch (err) {
            console.error(`[startup] WARN: Could not reach checkout API at ${checkoutApiUrl}: ${err instanceof Error ? err.message : String(err)}`);
        }
        // Fallback: generate random if fetch failed (offline mode)
        if (!secrets.WORKER_AUTH_SECRET) {
            secrets.WORKER_AUTH_SECRET = randomBytes(32).toString('hex');
            changed = true;
        }
        if (!secrets.WEBHOOK_SHARED_SECRET) {
            secrets.WEBHOOK_SHARED_SECRET = randomBytes(32).toString('hex');
            changed = true;
        }
    }
    if (!secrets.LICENSE_SIGNING_KEY || !secrets.LICENSE_PUBLIC_KEY) {
        const kp = generateEd25519Hex();
        secrets.LICENSE_SIGNING_KEY = kp.privateHex;
        secrets.LICENSE_PUBLIC_KEY = kp.publicHex;
        changed = true;
    }
    // Persist secrets atomically (write to temp file, then rename — prevents corruption on crash)
    if (changed || !existsSync(secretsFile)) {
        try {
            mkdirSync(secretsDir, { recursive: true });
        }
        catch { /* exists */ }
        const secretsContent = Object.entries(secrets).map(([k, v]) => `${k}=${v}`).join('\n');
        const tmpFile = secretsFile + '.tmp';
        writeFileSync(tmpFile, secretsContent, { mode: 0o600 });
        renameSync(tmpFile, secretsFile);
        console.error(`[startup] Persisted license secrets to ${secretsFile}`);
    }
    // Set env vars for the license server
    const port = parseInt(new URL(serverUrl).port || '3000', 10);
    const env = {
        ...process.env,
        LICENSE_PORT: String(port),
        LICENSE_DB_PATH: join(DEFAULT_STORAGE_PATH, '..', '_license_server.db'),
        SESSION_SECRET: secrets.SESSION_SECRET,
        LICENSE_SIGNING_KEY: secrets.LICENSE_SIGNING_KEY,
        LICENSE_PUBLIC_KEY: secrets.LICENSE_PUBLIC_KEY,
        WORKER_AUTH_SECRET: secrets.WORKER_AUTH_SECRET,
        WEBHOOK_SHARED_SECRET: secrets.WEBHOOK_SHARED_SECRET,
        CHECKOUT_API_URL: process.env.CHECKOUT_API_URL || 'https://api.ocrprovenance.com',
        DASHBOARD_URL: process.env.DASHBOARD_URL || `http://localhost:${process.env.PORT || '3367'}`,
    };
    // Spawn the license server
    const child = fork(serverBin, [], {
        env,
        stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
        detached: false,
    });
    _licenseServerProcess = child;
    child.stderr?.on('data', (data) => {
        const msg = data.toString().trim();
        if (msg)
            console.error(`[license-server] ${msg}`);
    });
    child.stdout?.on('data', (data) => {
        const msg = data.toString().trim();
        if (msg)
            console.error(`[license-server:stdout] ${msg}`);
    });
    child.on('exit', (code) => {
        console.error(`[startup] License server process exited with code ${code}`);
        _licenseServerProcess = null;
    });
    // Wait for it to be healthy (max 10s)
    const deadline = Date.now() + 10000;
    while (Date.now() < deadline) {
        if (await isReachable(healthUrl, 1000)) {
            // CRITICAL: Set env var so ALL downstream consumers use this server (Bug 1+13 fix)
            process.env.OCR_LICENSE_SERVER = serverUrl;
            console.error(`[startup] License server auto-started on port ${port} (PID=${child.pid})`);
            console.error(`[startup] Set OCR_LICENSE_SERVER=${serverUrl}`);
            return;
        }
        // Check if process died
        if (child.exitCode !== null) {
            throw new Error(`License server process exited with code ${child.exitCode} during startup`);
        }
        await new Promise(r => setTimeout(r, 500));
    }
    throw new Error(`License server failed to start within 10 seconds at ${serverUrl}. ` +
        `Port ${port} may be in use by another process. Check with: lsof -i :${port}`);
}
/**
 * Validate startup dependencies and apply environment-driven config overrides.
 * Warnings only: missing API keys prevent specific features, not the whole server.
 */
export async function validateStartupDependencies() {
    const warnings = [];
    // Check local model paths
    const chandraModelPath = process.env.CHANDRA_MODEL_PATH;
    if (chandraModelPath && !existsSync(chandraModelPath)) {
        warnings.push(`CHANDRA_MODEL_PATH=${chandraModelPath} does not exist. VLM processing may fail on first use.`);
    }
    // Bridge CHANDRA_MODEL_PATH → MODEL_CHECKPOINT so the Chandra Python library
    // loads from the local path instead of trying to download from HuggingFace Hub.
    // Chandra's pydantic Settings reads MODEL_CHECKPOINT (not CHANDRA_MODEL_PATH).
    // Without this bridge, from_pretrained("datalab-to/chandra-ocr-2") tries to fetch
    // ~10.6GB over the network, hangs, and the 120s daemon startup timeout SIGKILLs it.
    if (chandraModelPath && existsSync(chandraModelPath) && !process.env.MODEL_CHECKPOINT) {
        process.env.MODEL_CHECKPOINT = chandraModelPath;
        console.error(`[startup] MODEL_CHECKPOINT set to ${chandraModelPath} (bridged from CHANDRA_MODEL_PATH)`);
    }
    const torchDevice = process.env.TORCH_DEVICE;
    if (torchDevice === 'cpu') {
        warnings.push('TORCH_DEVICE=cpu — OCR and VLM processing will be significantly slower without GPU acceleration.');
    }
    if (warnings.length > 0) {
        console.error('=== STARTUP WARNINGS ===');
        for (const w of warnings) {
            console.error(`  - ${w}`);
        }
        console.error('========================');
    }
    // Store env var status for health check consumption
    // This avoids health check needing to re-validate every call
    globalThis.__OCR_ENV_STATUS = {
        torch_device: process.env.TORCH_DEVICE ?? 'auto',
        chandra_model_path: process.env.CHANDRA_MODEL_PATH ?? null,
        checked_at: new Date().toISOString(),
    };
    const embeddingDevice = process.env.EMBEDDING_DEVICE;
    if (embeddingDevice) {
        updateConfig({ embeddingDevice });
        console.error(`[Config] EMBEDDING_DEVICE=${embeddingDevice}`);
    }
    // Initialize database registry and reconcile with filesystem
    try {
        console.error(`[startup] Databases path: ${DEFAULT_STORAGE_PATH}`);
        const registry = RegistryService.getInstance();
        registry.reconcile(DEFAULT_STORAGE_PATH);
        const count = registry.getDatabaseCount();
        console.error(`[startup] Registry initialized: ${count} database(s) tracked`);
    }
    catch (error) {
        const msg = error instanceof Error ? error.message : String(error);
        console.error(`[startup] FATAL: Registry initialization failed: ${msg}`);
        console.error(`[startup] OCR_PROVENANCE_DATABASES_PATH=${process.env.OCR_PROVENANCE_DATABASES_PATH || '(not set)'}`);
        throw error;
    }
    // Ensure license server is running (auto-starts locally if not in Docker).
    // This is NOT optional — without the license server, no files can be processed.
    // If auto-start fails, log the error but continue (MCP tools still work for non-processing tasks).
    // The key fix: OCR_LICENSE_SERVER env var is set inside ensureLicenseServer() so
    // initLicenseGate() uses the LOCAL server, never silently falling through to production.
    try {
        await ensureLicenseServer();
    }
    catch (error) {
        const msg = error instanceof Error ? error.message : String(error);
        console.error(`[startup] ERROR: License server auto-start failed: ${msg}`);
        console.error(`[startup] File processing will fail until the license server is reachable.`);
        // Still set to localhost so initLicenseGate doesn't accidentally hit production
        if (!process.env.OCR_LICENSE_SERVER) {
            process.env.OCR_LICENSE_SERVER = 'http://localhost:3000';
        }
    }
    // Initialize license gate (billing) — auto-provisions key if not set
    try {
        const gate = await initLicenseGate(DEFAULT_STORAGE_PATH);
        const status = gate.getStatus();
        if (!gate.hasLicenseKey()) {
            console.error(`[startup] License: No license key configured. Auto-provisioning failed or was skipped.`);
            console.error(`[startup] License: Get a license key at https://ocrprovenance.com/dashboard`);
        }
        else {
            console.error(`[startup] License: $${(status.balance_cents / 100).toFixed(2)} balance, ${status.files_remaining} files remaining, $${(status.cost_per_file_cents / 100).toFixed(2)}/file`);
        }
        // Sync any offline charges from previous session
        gate.syncPending().catch((err) => {
            console.error(`[startup] License sync failed: ${err instanceof Error ? err.message : String(err)}`);
        });
    }
    catch (error) {
        const msg = error instanceof Error ? error.message : String(error);
        const stack = error instanceof Error ? error.stack : '';
        console.error(`[startup] License gate init failed: ${msg}`);
        if (stack)
            console.error(`[startup] License gate error stack: ${stack}`);
        console.error(`[startup] OCR_LICENSE_KEY=${process.env.OCR_LICENSE_KEY ? '(set)' : '(not set)'}`);
        console.error(`[startup] OCR_LICENSE_SERVER=${process.env.OCR_LICENSE_SERVER || 'https://license.ocrprovenance.com'}`);
        console.error(`[startup] Ingestion will fail at charge time until license server is reachable.`);
    }
    // Recovery: Reset documents stuck in 'processing' status from a previous crash.
    // When the server restarts, any document left in 'processing' was interrupted
    // mid-pipeline. Reset them to 'pending' so they can be retried.
    resetStuckDocuments(DEFAULT_STORAGE_PATH);
}
/**
 * Scan all databases and reset documents stuck in 'processing' status to 'pending'.
 * This handles crash recovery — if the server was killed mid-OCR, documents are
 * left in 'processing' forever since the Python worker died with the server.
 *
 * Also resets images stuck in 'processing' VLM status back to 'pending'.
 */
function resetStuckDocuments(storagePath) {
    try {
        if (!existsSync(storagePath))
            return;
        const files = readdirSync(storagePath).filter((f) => f.endsWith('.db') && !f.startsWith('_'));
        let totalReset = 0;
        let totalImagesReset = 0;
        for (const file of files) {
            const dbPath = join(storagePath, file);
            let db = null;
            try {
                db = new Database(dbPath, { readonly: false });
                db.pragma('journal_mode = WAL');
                // Check if documents table exists
                const hasTable = db
                    .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='documents'")
                    .get();
                if (!hasTable)
                    continue;
                // Clean derived data for stuck documents before resetting to 'pending'.
                // Without cleanup, reprocessing creates duplicate OCR results and chunks.
                const stuckDocs = db
                    .prepare("SELECT id FROM documents WHERE status = 'processing'")
                    .all();
                if (stuckDocs.length > 0) {
                    for (const { id } of stuckDocs) {
                        try {
                            db.prepare('DELETE FROM vec_embeddings WHERE embedding_id IN (SELECT id FROM embeddings WHERE document_id = ?)').run(id);
                            db.prepare('UPDATE images SET vlm_embedding_id = NULL WHERE document_id = ?').run(id);
                            db.prepare('DELETE FROM embeddings WHERE document_id = ?').run(id);
                            db.prepare('DELETE FROM images WHERE document_id = ?').run(id);
                            db.prepare('DELETE FROM chunks WHERE document_id = ?').run(id);
                            db.prepare('DELETE FROM extractions WHERE document_id = ?').run(id);
                            db.prepare('DELETE FROM ocr_results WHERE document_id = ?').run(id);
                        }
                        catch (cleanErr) {
                            console.error(`[startup] WARN: Failed to clean derived data for ${id}: ${cleanErr instanceof Error ? cleanErr.message : String(cleanErr)}`);
                        }
                    }
                    const docResult = db
                        .prepare("UPDATE documents SET status = 'pending', modified_at = ? WHERE status = 'processing'")
                        .run(new Date().toISOString());
                    totalReset += docResult.changes;
                    console.error(`[startup] Reset ${docResult.changes} stuck document(s) in ${file} from 'processing' to 'pending' (derived data cleaned)`);
                }
                // Reset stuck VLM images
                const hasImages = db
                    .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='images'")
                    .get();
                if (hasImages) {
                    // Check if vlm_error column exists (older schema versions may lack it)
                    const cols = db.prepare("PRAGMA table_info(images)").all();
                    const hasVlmError = cols.some((c) => c.name === 'vlm_error');
                    const resetSql = hasVlmError
                        ? "UPDATE images SET vlm_status = 'pending', vlm_error = NULL WHERE vlm_status = 'processing'"
                        : "UPDATE images SET vlm_status = 'pending' WHERE vlm_status = 'processing'";
                    const imgResult = db
                        .prepare(resetSql)
                        .run();
                    if (imgResult.changes > 0) {
                        totalImagesReset += imgResult.changes;
                        console.error(`[startup] Reset ${imgResult.changes} stuck VLM image(s) in ${file} from 'processing' to 'pending'`);
                    }
                }
            }
            catch (dbError) {
                // Don't let a single corrupt DB block startup
                console.error(`[startup] WARN: Could not check ${file} for stuck documents: ${dbError instanceof Error ? dbError.message : String(dbError)}`);
            }
            finally {
                try {
                    db?.close();
                }
                catch { /* ignore */ }
            }
        }
        if (totalReset > 0 || totalImagesReset > 0) {
            console.error(`[startup] Recovery complete: ${totalReset} document(s) and ${totalImagesReset} image(s) reset from stuck 'processing' state`);
        }
    }
    catch (error) {
        console.error(`[startup] WARN: Stuck document recovery failed: ${error instanceof Error ? error.message : String(error)}`);
    }
}
process.on('exit', () => {
    try {
        RegistryService.close();
    }
    catch { /* ignore */ }
    // Close license gate SQLite connection to checkpoint WAL
    try {
        getLicenseGate().close();
    }
    catch { /* ignore */ }
    // Kill any orphaned Python workers so they don't survive server shutdown
    try {
        killAllVLMWorkers();
    }
    catch { /* ignore */ }
    try {
        killAllOCRWorkers();
    }
    catch { /* ignore */ }
    try {
        killAllEmbeddingWorkers();
    }
    catch { /* ignore */ }
    // Kill auto-started license server
    if (_licenseServerProcess && _licenseServerProcess.exitCode === null) {
        try {
            _licenseServerProcess.kill();
        }
        catch { /* ignore */ }
    }
});
//# sourceMappingURL=startup.js.map