/**
 * Bare-Metal Companion Services Launcher
 *
 * Starts the license server and dashboard as background child processes
 * when running in bare-metal mode (no Docker container).
 *
 * Generates the 5 required secrets locally (same as docker-entrypoint.sh
 * Strategy 3) and persists them to ~/.ocr-provenance-mcp/.license_secrets.
 *
 * @module bare-metal-services
 */
import { spawn, execSync } from 'child_process';
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
import { randomBytes, generateKeyPairSync } from 'crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BARE_METAL_DIR = join(homedir(), '.ocr-provenance-mcp');
const DASHBOARD_DIR = join(BARE_METAL_DIR, 'dashboard');
const SECRETS_FILE = join(BARE_METAL_DIR, '.license_secrets');
// License server path — check npm package location first, then source repo fallback
const LICENSE_SERVER_DIRS = [
    join(__dirname, '..', 'packages', 'server', 'dist'), // npm package: <pkg>/packages/server/dist/
    join(__dirname, '..', '..', 'packages', 'server', 'dist'), // source repo fallback
];
const activeProcesses = [];
function log(msg) {
    process.stderr.write(`[bare-metal] ${msg}\n`);
}
const MACHINE_ID_FILE = join(BARE_METAL_DIR, '.machine_id');
const WRAPPER_CONFIG_FILE = join(BARE_METAL_DIR, 'wrapper.json');
/**
 * Get or create a stable machine ID (persists across restarts).
 * Same machine_id = same Cloudflare secrets = same license key = same balance.
 *
 * Stored in 3 locations for redundancy (survives accidental deletion):
 *   1. ~/.ocr-provenance-mcp/.machine_id (primary file)
 *   2. ~/.ocr-provenance-mcp/wrapper.json → machineId field
 *   3. macOS Keychain (via `security` command) — OS-level, survives file wipes
 */
function ensureMachineId() {
    mkdirSync(BARE_METAL_DIR, { recursive: true });
    // Try all 3 sources in priority order
    let id = readMachineIdFromFile()
        || readMachineIdFromConfig()
        || readMachineIdFromKeychain();
    if (id) {
        // Restore to all locations (in case some were deleted)
        writeMachineIdEverywhere(id);
        return id;
    }
    // No machine ID found anywhere — generate new one
    id = randomBytes(16).toString('hex');
    writeMachineIdEverywhere(id);
    log(`Generated new machine ID: ${id.slice(0, 8)}...`);
    return id;
}
function readMachineIdFromFile() {
    try {
        if (existsSync(MACHINE_ID_FILE)) {
            const id = readFileSync(MACHINE_ID_FILE, 'utf-8').trim();
            if (id.length >= 8)
                return id;
        }
    }
    catch { /* file read error */ }
    return '';
}
function readMachineIdFromConfig() {
    try {
        if (existsSync(WRAPPER_CONFIG_FILE)) {
            const config = JSON.parse(readFileSync(WRAPPER_CONFIG_FILE, 'utf-8'));
            const id = config?.machineId;
            if (id && id.length >= 8)
                return id;
        }
    }
    catch { /* config parse error */ }
    return '';
}
function readMachineIdFromKeychain() {
    if (process.platform !== 'darwin')
        return '';
    try {
        const result = execSync('security find-generic-password -s "ocr-provenance-mcp" -a "machine_id" -w 2>/dev/null', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }).trim();
        if (result.length >= 8) {
            log('Recovered machine ID from macOS Keychain');
            return result;
        }
    }
    catch { /* keychain entry doesn't exist */ }
    return '';
}
function writeMachineIdEverywhere(id) {
    // 1. Primary file
    try {
        writeFileSync(MACHINE_ID_FILE, id, 'utf-8');
        chmodSync(MACHINE_ID_FILE, 0o600);
    }
    catch { /* write error */ }
    // 2. wrapper.json config
    try {
        let config = {};
        if (existsSync(WRAPPER_CONFIG_FILE)) {
            config = JSON.parse(readFileSync(WRAPPER_CONFIG_FILE, 'utf-8'));
        }
        config.machineId = id;
        writeFileSync(WRAPPER_CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
    }
    catch { /* config write error */ }
    // 3. macOS Keychain (OS-level persistence, survives file deletion)
    if (process.platform === 'darwin') {
        try {
            // Delete existing entry first (security add fails if it exists)
            execSync('security delete-generic-password -s "ocr-provenance-mcp" -a "machine_id" 2>/dev/null', { stdio: 'pipe', timeout: 5000 });
        }
        catch { /* didn't exist */ }
        try {
            execSync(`security add-generic-password -s "ocr-provenance-mcp" -a "machine_id" -w "${id}" -U`, { stdio: 'pipe', timeout: 5000 });
        }
        catch { /* keychain access denied — non-fatal */ }
    }
}
/**
 * Load or bootstrap the 5 required secrets for the license server.
 *
 * Strategy (mirrors docker-entrypoint.sh):
 *   1. PRIMARY: Fetch from Cloudflare Worker (/v1/secrets/bootstrap)
 *      Same machine_id always gets the same deterministic secrets.
 *   2. FALLBACK: Load from ~/.ocr-provenance-mcp/.license_secrets
 *   3. LAST RESORT: Generate locally (payment won't work until Cloudflare reachable)
 */
function ensureSecrets() {
    const machineId = ensureMachineId();
    process.env.MACHINE_ID = machineId;
    // Strategy 1: Try Cloudflare bootstrap (deterministic secrets from master key)
    const checkoutUrl = process.env.CHECKOUT_API_URL ?? 'https://api.ocrprovenance.com';
    try {
        log(`Bootstrapping secrets from ${checkoutUrl}...`);
        const response = execSync(`curl -sf --max-time 10 -X POST -H "Content-Type: application/json" ` +
            `-d '{"machine_id":"${machineId}"}' "${checkoutUrl}/v1/secrets/bootstrap"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 15000 }).trim();
        if (response) {
            const data = JSON.parse(response);
            if (data.error) {
                log(`Cloudflare bootstrap error: ${data.error}`);
            }
            else if (data.session_secret && data.license_signing_key && data.license_public_key) {
                // Also fetch client-config for worker/webhook secrets
                let workerAuth = data.worker_auth_secret ?? '';
                let webhookSecret = data.webhook_shared_secret ?? '';
                if (!workerAuth || !webhookSecret) {
                    try {
                        const configResp = execSync(`curl -sf --max-time 10 "${checkoutUrl}/v1/client-config"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000 }).trim();
                        if (configResp) {
                            const cfg = JSON.parse(configResp);
                            if (!workerAuth && cfg.worker_auth_secret)
                                workerAuth = cfg.worker_auth_secret;
                            if (!webhookSecret && cfg.webhook_shared_secret)
                                webhookSecret = cfg.webhook_shared_secret;
                        }
                    }
                    catch { /* client-config fetch failed — use bootstrap values */ }
                }
                const secrets = {
                    SESSION_SECRET: data.session_secret,
                    LICENSE_SIGNING_KEY: data.license_signing_key,
                    LICENSE_PUBLIC_KEY: data.license_public_key,
                    WORKER_AUTH_SECRET: workerAuth || randomBytes(32).toString('hex'),
                    WEBHOOK_SHARED_SECRET: webhookSecret || randomBytes(32).toString('hex'),
                };
                // Persist to disk for offline fallback
                writeFileSync(SECRETS_FILE, JSON.stringify(secrets, null, 2), 'utf-8');
                try {
                    chmodSync(SECRETS_FILE, 0o600);
                }
                catch { /* Windows */ }
                log(`Secrets bootstrapped from Cloudflare (${data.created ? 'new' : 'existing'} machine)`);
                return secrets;
            }
        }
    }
    catch {
        log('Cloudflare unreachable — trying cached secrets...');
    }
    // Strategy 2: Load from disk cache
    if (existsSync(SECRETS_FILE)) {
        try {
            const raw = readFileSync(SECRETS_FILE, 'utf-8');
            const secrets = JSON.parse(raw);
            if (secrets.SESSION_SECRET && secrets.LICENSE_SIGNING_KEY &&
                secrets.LICENSE_PUBLIC_KEY && secrets.WORKER_AUTH_SECRET &&
                secrets.WEBHOOK_SHARED_SECRET) {
                log('Loaded secrets from cache');
                return secrets;
            }
        }
        catch {
            // Corrupted — fall through to local generation
        }
    }
    // Strategy 3: Generate locally (payment won't work)
    log('Generating local secrets (payment requires Cloudflare connection)...');
    const { publicKey, privateKey } = generateKeyPairSync('ed25519');
    const privDer = privateKey.export({ format: 'der', type: 'pkcs8' });
    const pubDer = publicKey.export({ format: 'der', type: 'spki' });
    const secrets = {
        SESSION_SECRET: randomBytes(32).toString('hex'),
        LICENSE_SIGNING_KEY: privDer.subarray(16).toString('hex'),
        LICENSE_PUBLIC_KEY: pubDer.subarray(12).toString('hex'),
        WORKER_AUTH_SECRET: randomBytes(32).toString('hex'),
        WEBHOOK_SHARED_SECRET: randomBytes(32).toString('hex'),
    };
    mkdirSync(BARE_METAL_DIR, { recursive: true });
    writeFileSync(SECRETS_FILE, JSON.stringify(secrets, null, 2), 'utf-8');
    try {
        chmodSync(SECRETS_FILE, 0o600);
    }
    catch { /* Windows */ }
    log('Local secrets generated and cached');
    return secrets;
}
// ---------------------------------------------------------------------------
// Service Startup
// ---------------------------------------------------------------------------
/**
 * Start companion services for bare-metal mode.
 * Non-blocking — services start in the background.
 * Failures are logged but do not prevent MCP server startup.
 */
export function startBareMetalServices() {
    // Clean up child processes on exit
    const cleanup = () => {
        for (const proc of activeProcesses) {
            try {
                proc.kill();
            }
            catch { /* already dead */ }
        }
    };
    process.on('exit', cleanup);
    process.on('SIGTERM', () => { cleanup(); process.exit(0); });
    process.on('SIGINT', () => { cleanup(); process.exit(0); });
    // Set up Python venv PATH so OCR/VLM/embedding workers find PyTorch.
    // This MUST happen before any service starts, because the MCP server
    // spawns Python workers via child_process.spawn('python3', ...) which
    // needs the venv's python3 on PATH.
    const venvBin = join(BARE_METAL_DIR, 'venv', 'bin');
    if (existsSync(venvBin)) {
        process.env.PATH = venvBin + ':' + (process.env.PATH ?? '');
        log(`Python venv: ${venvBin}`);
    }
    // Set GPU device for bare-metal
    if (!process.env.TORCH_DEVICE || process.env.TORCH_DEVICE === 'auto') {
        // Detect platform: MPS for macOS, auto for Linux (gpu_utils.py resolves)
        if (process.platform === 'darwin') {
            process.env.TORCH_DEVICE = 'mps';
            log('GPU device: MPS (Apple Silicon)');
        }
        else {
            process.env.TORCH_DEVICE = 'auto';
        }
    }
    // Set database path for bare-metal
    const dbPath = join(BARE_METAL_DIR, 'databases');
    mkdirSync(dbPath, { recursive: true });
    process.env.OCR_PROVENANCE_DATABASES_PATH = process.env.OCR_PROVENANCE_DATABASES_PATH ?? dbPath;
    // Generate/load secrets BEFORE starting any service
    const secrets = ensureSecrets();
    // Export secrets to process.env so they're available to child processes
    // and to the MCP server itself (for license client auth)
    process.env.SESSION_SECRET = secrets.SESSION_SECRET;
    process.env.LICENSE_SIGNING_KEY = secrets.LICENSE_SIGNING_KEY;
    process.env.LICENSE_PUBLIC_KEY = secrets.LICENSE_PUBLIC_KEY;
    process.env.WORKER_AUTH_SECRET = secrets.WORKER_AUTH_SECRET;
    process.env.WEBHOOK_SHARED_SECRET = secrets.WEBHOOK_SHARED_SECRET;
    process.env.CHECKOUT_API_URL = process.env.CHECKOUT_API_URL ?? 'https://api.ocrprovenance.com';
    process.env.DASHBOARD_URL = process.env.DASHBOARD_URL ?? 'http://127.0.0.1:3367';
    startLicenseServer(secrets);
    // Wait for license server to be ready, then fetch the auto-provisioned key.
    // The key is needed by the dashboard for X-License-Key auth and by the MCP server.
    const licenseKey = waitForLicenseKey();
    if (licenseKey) {
        process.env.OCR_LICENSE_KEY = licenseKey;
        // Write key to file where MCP server and dashboard expect it
        const keyFile = join(BARE_METAL_DIR, '.license_key');
        writeFileSync(keyFile, licenseKey, 'utf-8');
        try {
            chmodSync(keyFile, 0o600);
        }
        catch { /* Windows */ }
        log(`License key: ${licenseKey.slice(0, 8)}...`);
    }
    // Start MCP HTTP server on port 3366 for dashboard proxy.
    // The dashboard proxies API calls through /api/mcp/* → localhost:3366/api/*.
    // Without this, the dashboard's MCP features don't work.
    startMcpHttpServer();
    startDashboard(secrets, licenseKey);
}
function startLicenseServer(secrets) {
    let entry = null;
    for (const dir of LICENSE_SERVER_DIRS) {
        const candidate = join(dir, 'index.js');
        if (existsSync(candidate)) {
            entry = candidate;
            break;
        }
    }
    if (!entry) {
        log('License server not found — billing features unavailable.');
        log('  OCR processing works without billing. To add funds, use Docker mode.');
        return;
    }
    const port = process.env.LICENSE_PORT ?? '3000';
    log(`Starting license server on port ${port}...`);
    const proc = spawn('node', [entry], {
        env: {
            ...process.env,
            // License server required env vars
            LICENSE_PORT: port,
            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://127.0.0.1:3367`,
            // Prevent PORT from leaking (PORT is for dashboard/Next.js)
            PORT: undefined,
        },
        stdio: ['pipe', 'pipe', 'pipe'],
        detached: false,
    });
    activeProcesses.push(proc);
    proc.stderr?.on('data', (chunk) => {
        for (const line of chunk.toString().split('\n')) {
            if (line.trim())
                process.stderr.write(`[license] ${line}\n`);
        }
    });
    proc.on('exit', (code) => {
        if (code !== 0 && code !== null) {
            log(`License server exited with code ${code}`);
        }
    });
}
/**
 * Wait for the license server to be ready and return the auto-provisioned key.
 * The license server auto-provisions a key on first request via /v1/provision.
 */
function waitForLicenseKey() {
    const port = process.env.LICENSE_PORT ?? '3000';
    const maxAttempts = 20;
    // Wait for license server to respond
    for (let i = 0; i < maxAttempts; i++) {
        try {
            const health = execSync(`curl -sf --max-time 2 http://127.0.0.1:${port}/health`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }).trim();
            if (health)
                break;
        }
        catch {
            if (i < maxAttempts - 1) {
                execSync('sleep 0.5', { stdio: 'pipe' });
            }
        }
    }
    // Get or provision the license key via the machine_id
    const machineId = process.env.MACHINE_ID ?? '';
    if (!machineId)
        return '';
    try {
        const resp = execSync(`curl -sf --max-time 10 -X POST -H "Content-Type: application/json" ` +
            `-d '{"machine_id":"${machineId}"}' http://127.0.0.1:${port}/v1/provision`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 15000 }).trim();
        if (resp) {
            const data = JSON.parse(resp);
            const key = data.license_key || data.key;
            if (key) {
                // Sync balance from D1 (source of truth) to local license server.
                // This ensures the local balance reflects any Stripe payments or
                // consolidations that happened on the Cloudflare side.
                try {
                    execSync(`curl -sf --max-time 10 -X POST -H "X-License-Key: ${key}" ` +
                        `http://127.0.0.1:${port}/v1/sync`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 15000 });
                }
                catch {
                    // Sync failure is non-fatal — balance will sync on next charge attempt
                }
                return key;
            }
        }
    }
    catch {
        log('Could not provision license key — billing may not work');
    }
    return '';
}
/**
 * Start the MCP HTTP server on port 3366 for dashboard proxy.
 * The dashboard proxies /api/mcp/* requests to localhost:3366/api/*.
 * Without this, dashboard MCP features (document viewer, etc.) don't work.
 */
function startMcpHttpServer() {
    const httpBin = join(__dirname, 'bin-http.js');
    if (!existsSync(httpBin)) {
        log('MCP HTTP server not found — dashboard MCP proxy unavailable');
        return;
    }
    const port = process.env.MCP_HTTP_PORT ?? '3366';
    log(`Starting MCP HTTP server on port ${port}...`);
    const proc = spawn('node', ['--max-semi-space-size=64', httpBin], {
        env: {
            ...process.env,
            MCP_TRANSPORT: 'http',
            MCP_HTTP_PORT: port,
        },
        stdio: ['pipe', 'pipe', 'pipe'],
        detached: false,
    });
    activeProcesses.push(proc);
    proc.stderr?.on('data', (chunk) => {
        for (const line of chunk.toString().split('\n')) {
            if (line.trim())
                process.stderr.write(`[mcp-http] ${line}\n`);
        }
    });
    proc.on('exit', (code) => {
        if (code !== 0 && code !== null) {
            log(`MCP HTTP server exited with code ${code}`);
        }
    });
}
function startDashboard(secrets, licenseKey) {
    let dashboardDir = DASHBOARD_DIR;
    let dashboardServer = join(DASHBOARD_DIR, 'server.js');
    if (!existsSync(dashboardServer)) {
        // Auto-extract dashboard from bundled tarball if not installed yet.
        // This handles the case where the user runs the MCP server directly
        // without running `install --mac` first.
        const tarball = join(__dirname, '..', 'packages', 'wrapper', 'dashboard-bundle.tar.gz');
        if (existsSync(tarball)) {
            try {
                log('Extracting dashboard from bundle...');
                mkdirSync(BARE_METAL_DIR, { recursive: true });
                execSync(`tar xzf "${tarball}" -C "${BARE_METAL_DIR}"`, { stdio: 'pipe', timeout: 30000 });
                // Rename extracted dashboard-bundle/ to dashboard/
                const extracted = join(BARE_METAL_DIR, 'dashboard-bundle');
                if (existsSync(extracted)) {
                    execSync(`mv "${extracted}" "${DASHBOARD_DIR}"`, { stdio: 'pipe', timeout: 10000 });
                }
                dashboardServer = join(DASHBOARD_DIR, 'server.js');
            }
            catch (err) {
                log(`Dashboard extraction failed: ${err instanceof Error ? err.message : String(err)}`);
                return;
            }
        }
        if (!existsSync(dashboardServer)) {
            log('Dashboard not available — tarball not found in package.');
            return;
        }
    }
    const port = process.env.DASHBOARD_PORT ?? '3367';
    const licensePort = process.env.LICENSE_PORT ?? '3000';
    const mcpPort = process.env.MCP_HTTP_PORT ?? '3366';
    log(`Starting dashboard on port ${port}...`);
    const proc = spawn('node', [dashboardServer], {
        cwd: dashboardDir,
        env: {
            PATH: process.env.PATH,
            HOME: dashboardDir,
            NODE_ENV: 'production',
            PORT: port,
            HOSTNAME: '127.0.0.1',
            SESSION_SECRET: secrets.SESSION_SECRET,
            OCR_LICENSE_KEY: licenseKey ?? process.env.OCR_LICENSE_KEY ?? '',
            OCR_LICENSE_SERVER: `http://127.0.0.1:${licensePort}`,
            MCP_SERVER_URL: `http://127.0.0.1:${mcpPort}`,
            DASHBOARD_URL: `http://127.0.0.1:${port}`,
        },
        stdio: ['pipe', 'pipe', 'pipe'],
        detached: false,
    });
    activeProcesses.push(proc);
    proc.stderr?.on('data', (chunk) => {
        for (const line of chunk.toString().split('\n')) {
            if (line.trim())
                process.stderr.write(`[dashboard] ${line}\n`);
        }
    });
    proc.stdout?.on('data', (chunk) => {
        const text = chunk.toString();
        if (text.includes('Ready')) {
            log(`Dashboard ready at http://localhost:${port}`);
        }
    });
    proc.on('exit', (code) => {
        if (code !== 0 && code !== null) {
            log(`Dashboard exited with code ${code}`);
        }
    });
}
//# sourceMappingURL=bare-metal-services.js.map