/**
 * Marker OCR Bridge - Local GPU Processing (Daemon Mode)
 *
 * Spawns python/ocr_worker_local.py in daemon mode (--daemon).
 * Models are loaded ONCE on first use and reused across all documents.
 * Local GPU processing via Marker-pdf v1.10.2.
 * NO cloud API calls, NO fallbacks - real local GPU processing only.
 */
import { spawn, execFileSync } from 'child_process';
import { createInterface } from 'readline';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { OCRError, mapPythonError } from './errors.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
/** Daemon startup timeout: 120s for model loading */
const DAEMON_STARTUP_TIMEOUT_MS = 120_000;
/**
 * Track all active OCR child processes for cleanup on server shutdown.
 * Prevents orphaned Marker workers that survive server restarts.
 */
const _activeOCRProcesses = new Set();
/**
 * Kill all active OCR worker processes.
 * Called on process exit to prevent orphaned GPU-consuming workers.
 */
export function killAllOCRWorkers() {
    for (const proc of _activeOCRProcesses) {
        try {
            proc.kill('SIGKILL');
        }
        catch {
            /* already dead */
        }
    }
    _activeOCRProcesses.clear();
}
const _progressMap = new Map();
export function getProcessingProgress() {
    return _progressMap;
}
export function updatePipelineStep(documentId, step, detail) {
    const existing = _progressMap.get(documentId);
    _progressMap.set(documentId, {
        documentId,
        stage: detail ?? step,
        percent: existing?.pipelineStep === step ? (existing.percent ?? 0) : 0,
        current: existing?.pipelineStep === step ? (existing.current ?? 0) : 0,
        total: existing?.pipelineStep === step ? (existing.total ?? 0) : 0,
        pipelineStep: step,
        updatedAt: Date.now(),
    });
}
export function clearProcessingProgress(documentId) {
    _progressMap.delete(documentId);
}
export class MarkerClient {
    pythonPath;
    workerPath;
    timeout;
    /** Daemon process state */
    _daemonProcess = null;
    _daemonReadline = null;
    _daemonReady = false;
    _daemonStarting = null;
    _pendingResolve = null;
    _pendingReject = null;
    /** Simple promise-based lock for serial access to daemon */
    _lockPromise = Promise.resolve();
    constructor(config = {}) {
        this.pythonPath = config.pythonPath ??
            (process.platform === 'win32' ? 'python' : 'python3');
        this.workerPath = resolve(__dirname, '../../../python/ocr_worker_local.py');
        const parsedTimeout = parseInt(process.env.OCR_TIMEOUT || '3600000');
        this.timeout = config.timeout ?? (Number.isNaN(parsedTimeout) ? 3600000 : parsedTimeout); // 60 min default
        console.error(`[MarkerOCR] Initialized with timeout=${this.timeout}ms (${(this.timeout / 60000).toFixed(1)} min), daemon mode`);
    }
    /**
     * Acquire exclusive access to the daemon for one request.
     * Requests are serialized because the daemon processes one at a time.
     */
    async withLock(fn) {
        let releaseFn;
        const myTurn = new Promise(resolve => { releaseFn = resolve; });
        const previousTurn = this._lockPromise;
        this._lockPromise = myTurn;
        await previousTurn;
        try {
            return await fn();
        }
        finally {
            releaseFn();
        }
    }
    /**
     * Ensure daemon is running and ready. Spawns on first call, restarts on crash.
     */
    async ensureDaemon() {
        if (this._daemonReady && this._daemonProcess && !this._daemonProcess.killed) {
            return;
        }
        // If another caller is already starting the daemon, wait for it
        if (this._daemonStarting) {
            await this._daemonStarting;
            return;
        }
        this._daemonStarting = this._spawnDaemon();
        try {
            await this._daemonStarting;
        }
        finally {
            this._daemonStarting = null;
        }
    }
    /**
     * Spawn the Python daemon process and wait for the ready signal.
     */
    _spawnDaemon() {
        return new Promise((resolve, reject) => {
            // Clean up any previous daemon
            this._cleanupDaemon();
            // Pre-flight: verify Python is available before spawning daemon
            try {
                execFileSync(this.pythonPath, ['--version'], { timeout: 5000, stdio: 'pipe' });
            }
            catch (pythonCheckError) {
                reject(new OCRError(`Python not found at '${this.pythonPath}'. OCR processing requires Python 3.10+ with marker-pdf installed. ` +
                    `Install Python and run: pip install marker-pdf. ` +
                    `Error: ${pythonCheckError instanceof Error ? pythonCheckError.message : String(pythonCheckError)}`, 'OCR_MODEL_ERROR'));
                return;
            }
            const args = [
                '-u', // unbuffered
                this.workerPath,
                '--daemon',
                '--mode', 'balanced',
            ];
            console.error(`[MarkerDaemon] Starting daemon: ${this.pythonPath} ${args.join(' ')}`);
            const proc = spawn(this.pythonPath, args, {
                stdio: ['pipe', 'pipe', 'pipe'],
                env: { ...process.env },
            });
            this._daemonProcess = proc;
            _activeOCRProcesses.add(proc);
            // Forward stderr with prefix, parse PROGRESS_JSON lines for real-time tracking
            proc.stderr?.on('data', (chunk) => {
                const lines = chunk.toString().split('\n');
                for (const line of lines) {
                    if (!line.trim())
                        continue;
                    const progressIdx = line.indexOf('PROGRESS_JSON:');
                    if (progressIdx >= 0) {
                        try {
                            const p = JSON.parse(line.substring(progressIdx + 14));
                            if (p.document_id) {
                                const existing = _progressMap.get(p.document_id);
                                _progressMap.set(p.document_id, {
                                    documentId: p.document_id,
                                    stage: p.stage,
                                    percent: p.percent,
                                    current: p.current,
                                    total: p.total,
                                    pipelineStep: existing?.pipelineStep ?? 'ocr',
                                    updatedAt: Date.now(),
                                });
                            }
                        }
                        catch { /* ignore malformed progress */ }
                    }
                    else {
                        process.stderr.write(`[MarkerDaemon] ${line}\n`);
                    }
                }
            });
            // Set up readline on stdout for line-based JSON reading
            const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
            this._daemonReadline = rl;
            // Handle lines from daemon
            rl.on('line', (line) => {
                const trimmed = line.trim();
                if (!trimmed)
                    return;
                if (this._pendingResolve) {
                    const resolveFn = this._pendingResolve;
                    this._pendingResolve = null;
                    this._pendingReject = null;
                    resolveFn(trimmed);
                }
            });
            // Handle daemon process exit
            proc.on('exit', (code, signal) => {
                console.error(`[MarkerDaemon] Process exited (code=${code}, signal=${signal})`);
                _activeOCRProcesses.delete(proc);
                this._daemonReady = false;
                this._daemonProcess = null;
                // If there is a pending request, reject it
                if (this._pendingReject) {
                    const rejectFn = this._pendingReject;
                    this._pendingResolve = null;
                    this._pendingReject = null;
                    rejectFn(new OCRError(`OCR daemon process exited unexpectedly (code=${code}, signal=${signal})`, 'OCR_MODEL_ERROR'));
                }
            });
            proc.on('error', (err) => {
                console.error(`[MarkerDaemon] Process spawn error: ${err.message}`);
                _activeOCRProcesses.delete(proc);
                this._daemonReady = false;
                this._daemonProcess = null;
                if (this._pendingReject) {
                    const rejectFn = this._pendingReject;
                    this._pendingResolve = null;
                    this._pendingReject = null;
                    rejectFn(new OCRError(`OCR daemon failed to spawn: ${err.message}`, 'OCR_MODEL_ERROR'));
                }
            });
            // Wait for the ready signal with timeout
            const startupTimer = setTimeout(() => {
                this._cleanupDaemon();
                reject(new OCRError(`OCR daemon failed to start within ${DAEMON_STARTUP_TIMEOUT_MS / 1000}s. ` +
                    `Model loading may have timed out or Python/Marker-pdf may not be installed.`, 'OCR_MODEL_ERROR'));
            }, DAEMON_STARTUP_TIMEOUT_MS);
            // Read the first line as the ready signal
            this._pendingResolve = (line) => {
                clearTimeout(startupTimer);
                try {
                    const parsed = JSON.parse(line);
                    if (this._isDaemonReady(parsed)) {
                        this._daemonReady = true;
                        console.error(`[MarkerDaemon] Ready (models loaded in ${parsed.load_time_ms}ms)`);
                        resolve();
                    }
                    else if (this._isErrorResponse(parsed)) {
                        this._cleanupDaemon();
                        reject(mapPythonError(parsed.category, parsed.error, parsed.details));
                    }
                    else {
                        this._cleanupDaemon();
                        reject(new OCRError(`OCR daemon sent unexpected startup response: ${line.substring(0, 200)}`, 'OCR_MODEL_ERROR'));
                    }
                }
                catch {
                    this._cleanupDaemon();
                    reject(new OCRError(`OCR daemon sent non-JSON startup output: ${line.substring(0, 200)}`, 'OCR_MODEL_ERROR'));
                }
            };
            this._pendingReject = (err) => {
                clearTimeout(startupTimer);
                reject(err);
            };
        });
    }
    /**
     * Send a JSON request to the daemon and wait for a JSON response line.
     * Caller must hold the lock.
     */
    _sendRequest(request) {
        return new Promise((resolve, reject) => {
            if (!this._daemonProcess || !this._daemonProcess.stdin || this._daemonProcess.killed) {
                reject(new OCRError('OCR daemon is not running (stdin unavailable)', 'OCR_MODEL_ERROR'));
                return;
            }
            // Set up response handler
            this._pendingResolve = resolve;
            this._pendingReject = reject;
            // Write JSON line to stdin
            const jsonLine = JSON.stringify(request) + '\n';
            this._daemonProcess.stdin.write(jsonLine, (err) => {
                if (err) {
                    this._pendingResolve = null;
                    this._pendingReject = null;
                    reject(new OCRError(`Failed to write to OCR daemon stdin: ${err.message}`, 'OCR_MODEL_ERROR'));
                }
            });
        });
    }
    /**
     * Clean up daemon process and associated resources.
     */
    _cleanupDaemon() {
        this._daemonReady = false;
        // Reject any pending request BEFORE nullifying callbacks.
        // Without this, destroy() called by one document's VLM step would orphan
        // another document's in-flight OCR request, causing a 60-minute timeout hang.
        if (this._pendingReject) {
            const rejectFn = this._pendingReject;
            this._pendingResolve = null;
            this._pendingReject = null;
            rejectFn(new OCRError('OCR daemon was shut down while a request was pending (concurrent pipeline destroy)', 'OCR_MODEL_ERROR'));
        }
        else {
            this._pendingResolve = null;
            this._pendingReject = null;
        }
        if (this._daemonReadline) {
            this._daemonReadline.close();
            this._daemonReadline = null;
        }
        if (this._daemonProcess) {
            _activeOCRProcesses.delete(this._daemonProcess);
            try {
                this._daemonProcess.kill('SIGKILL');
            }
            catch {
                /* already dead */
            }
            this._daemonProcess = null;
        }
    }
    _isDaemonReady(response) {
        return (typeof response === 'object' &&
            response !== null &&
            'status' in response &&
            response.status === 'ready' &&
            'models_loaded' in response);
    }
    _isErrorResponse(response) {
        return (typeof response === 'object' &&
            response !== null &&
            'error' in response &&
            'category' in response);
    }
    /**
     * Process document through Marker OCR (local GPU) via daemon
     *
     * FAIL-FAST: Throws on any error, no fallbacks
     */
    async processDocument(filePath, documentId, provenanceId, mode = 'accurate', ocrOptions) {
        return this.withLock(async () => {
            await this.ensureDaemon();
            const pythonTimeoutSec = Math.floor(this.timeout / 1000);
            // Build daemon request
            const request = {
                action: 'process',
                file_path: filePath,
                document_id: documentId,
                provenance_id: provenanceId,
                mode,
                timeout: pythonTimeoutSec,
            };
            if (ocrOptions?.maxPages)
                request.max_pages = ocrOptions.maxPages;
            if (ocrOptions?.pageRange)
                request.page_range = ocrOptions.pageRange;
            if (ocrOptions?.disableImageExtraction)
                request.disable_image_extraction = true;
            const requestStart = Date.now();
            // Send request with per-document timeout
            let responseLine;
            try {
                responseLine = await this._withTimeout(this._sendRequest(request), this.timeout, filePath);
            }
            catch (error) {
                if (error instanceof OCRError && error.category === 'OCR_TIMEOUT') {
                    // Kill daemon on timeout - it will restart on next call
                    console.error(`[MarkerDaemon] Document timeout after ${Date.now() - requestStart}ms, killing daemon`);
                    this._cleanupDaemon();
                }
                throw error;
            }
            const requestDuration = Date.now() - requestStart;
            console.error(`[MarkerOCR] processDocument completed in ${requestDuration}ms for ${filePath}`);
            // Parse response
            const response = this._parseResponse(responseLine);
            // Check for error response
            if (this._isErrorResponse(response)) {
                throw mapPythonError(response.category, response.error, response.details);
            }
            const ocrResponse = response;
            // Verify required fields exist
            if (!ocrResponse.id || !ocrResponse.content_hash || !ocrResponse.extracted_text) {
                throw new OCRError(`Invalid OCR response: missing required fields. Got: ${JSON.stringify(Object.keys(ocrResponse))}`, 'OCR_MODEL_ERROR');
            }
            return {
                result: this._toOCRResult(ocrResponse),
                pageOffsets: this._toPageOffsets(ocrResponse.page_offsets),
                images: ocrResponse.images ?? {},
                jsonBlocks: ocrResponse.json_blocks ?? null,
                metadata: ocrResponse.metadata ?? null,
                extractionJson: ocrResponse.extraction_json ?? null,
                docTitle: ocrResponse.doc_title ?? null,
                docAuthor: ocrResponse.doc_author ?? null,
                docSubject: ocrResponse.doc_subject ?? null,
            };
        });
    }
    /**
     * Process a file through Marker OCR and return raw results without storing in DB.
     * Used by ocr_convert_raw tool for quick one-off conversions.
     *
     * FAIL-FAST: Throws on any error, no fallbacks
     */
    async processRaw(filePath, mode = 'balanced', ocrOptions) {
        return this.withLock(async () => {
            await this.ensureDaemon();
            // Use dummy IDs since we are not storing in DB
            const dummyDocId = 'raw-convert-' + Date.now();
            const dummyProvId = 'raw-convert-prov-' + Date.now();
            const pythonTimeoutSec = Math.floor(this.timeout / 1000);
            const request = {
                action: 'process',
                file_path: filePath,
                document_id: dummyDocId,
                provenance_id: dummyProvId,
                mode,
                timeout: pythonTimeoutSec,
            };
            if (ocrOptions?.maxPages)
                request.max_pages = ocrOptions.maxPages;
            if (ocrOptions?.pageRange)
                request.page_range = ocrOptions.pageRange;
            const requestStart = Date.now();
            let responseLine;
            try {
                responseLine = await this._withTimeout(this._sendRequest(request), this.timeout, filePath);
            }
            catch (error) {
                if (error instanceof OCRError && error.category === 'OCR_TIMEOUT') {
                    console.error(`[MarkerDaemon] processRaw timeout after ${Date.now() - requestStart}ms, killing daemon`);
                    this._cleanupDaemon();
                }
                throw error;
            }
            const requestDuration = Date.now() - requestStart;
            console.error(`[MarkerOCR] processRaw completed in ${requestDuration}ms for ${filePath}`);
            const response = this._parseResponse(responseLine);
            if (this._isErrorResponse(response)) {
                throw mapPythonError(response.category, response.error, response.details);
            }
            const ocrResponse = response;
            return {
                markdown: ocrResponse.extracted_text ?? '',
                pageCount: ocrResponse.page_count ?? 0,
                qualityScore: ocrResponse.parse_quality_score ?? null,
                costCents: ocrResponse.cost_cents ?? null,
                durationMs: ocrResponse.processing_duration_ms ?? 0,
                metadata: ocrResponse.metadata ?? null,
            };
        });
    }
    /**
     * Wrap a promise with a timeout. On timeout, throws OCRError('OCR_TIMEOUT').
     */
    _withTimeout(promise, timeoutMs, filePath) {
        return new Promise((resolve, reject) => {
            const timer = setTimeout(() => {
                reject(new OCRError(`OCR processing timed out after ${timeoutMs}ms (file: ${filePath}). ` +
                    `To increase timeout, set OCR_TIMEOUT env var (in ms). ` +
                    `Or use page_range to process fewer pages.`, 'OCR_TIMEOUT'));
            }, timeoutMs);
            promise.then((val) => { clearTimeout(timer); resolve(val); }, (err) => { clearTimeout(timer); reject(err); });
        });
    }
    /**
     * Parse a JSON response line from the daemon.
     */
    _parseResponse(line) {
        try {
            return JSON.parse(line);
        }
        catch {
            throw new OCRError(`Failed to parse OCR daemon response as JSON: ${line.substring(0, 200)}`, 'OCR_MODEL_ERROR');
        }
    }
    _toOCRResult(r) {
        // Direct field mapping - Python snake_case matches TS interface
        // Build extras_json from metadata
        const extras = {};
        if (r.metadata)
            extras.metadata = r.metadata;
        let jsonBlocksSerialized = null;
        if (r.json_blocks) {
            jsonBlocksSerialized = JSON.stringify(r.json_blocks);
        }
        else {
            console.error(`[MarkerOCR] json_blocks is null/empty for document ${r.document_id}. ` +
                `Marker did not produce JSON block data for this document.`);
        }
        return {
            id: r.id,
            provenance_id: r.provenance_id,
            document_id: r.document_id,
            extracted_text: r.extracted_text,
            text_length: r.text_length,
            datalab_request_id: r.datalab_request_id,
            datalab_mode: r.datalab_mode,
            parse_quality_score: r.parse_quality_score,
            page_count: r.page_count,
            cost_cents: r.cost_cents,
            content_hash: r.content_hash,
            processing_started_at: r.processing_started_at,
            processing_completed_at: r.processing_completed_at,
            processing_duration_ms: r.processing_duration_ms,
            json_blocks: jsonBlocksSerialized,
            extras_json: Object.keys(extras).length > 0 ? JSON.stringify(extras) : null,
        };
    }
    _toPageOffsets(offsets) {
        // Convert Python snake_case to TS camelCase
        return offsets.map((o) => ({ page: o.page, charStart: o.char_start, charEnd: o.char_end }));
    }
    /**
     * Destroy this client instance and release all resources.
     * Kills the daemon process and WAITS for it to fully exit so CUDA VRAM is
     * released before the caller loads another model (e.g. Chandra VLM).
     *
     * Without waiting, the Marker process still holds VRAM when Chandra starts
     * loading (~10.6GB), causing the Linux OOM killer to SIGKILL Marker. The SIGKILL
     * triggers the pending-request reject handler, which rolls back the entire
     * document even though OCR already succeeded.
     *
     * See: https://docs.pytorch.org/docs/stable/torch_cuda_memory.html
     */
    async destroyAndWait(timeoutMs = 5000) {
        // Acquire the lock so we wait for any in-flight OCR request to complete
        // before destroying the daemon. Without this, a concurrent document's VLM
        // step calling destroyAndWait() kills the daemon while another document's
        // OCR request is pending, causing "concurrent pipeline destroy" errors.
        return this.withLock(async () => {
            const proc = this._daemonProcess;
            this._cleanupDaemon();
            if (proc && !proc.killed) {
                // Wait for process to fully exit (VRAM released on process death)
                await new Promise((resolve) => {
                    const timer = setTimeout(() => {
                        console.error(`[MarkerDaemon] Process did not exit within ${timeoutMs}ms after SIGKILL`);
                        resolve();
                    }, timeoutMs);
                    proc.on('exit', () => {
                        clearTimeout(timer);
                        resolve();
                    });
                    // If already exited (race), resolve immediately
                    if (proc.exitCode !== null || proc.signalCode !== null) {
                        clearTimeout(timer);
                        resolve();
                    }
                });
                // Small delay for CUDA driver to fully reclaim VRAM after process exit
                await new Promise((r) => setTimeout(r, 500));
            }
        });
    }
    /**
     * Synchronous destroy — does NOT wait for VRAM release.
     * Use destroyAndWait() when loading another GPU model after this.
     */
    destroy() {
        this._cleanupDaemon();
    }
}
// Singleton management — ONE daemon for all OCR operations
let _markerClient = null;
/**
 * Get or create MarkerClient singleton.
 * Ensures only ONE Python daemon process runs for OCR,
 * preventing GPU memory contention from multiple model loads.
 */
export function getMarkerClient(config) {
    if (!_markerClient) {
        _markerClient = new MarkerClient(config);
    }
    return _markerClient;
}
/**
 * Reset singleton for testing.
 * Cleans up daemon process to prevent orphaned GPU-consuming workers.
 */
export function resetMarkerClient() {
    if (_markerClient) {
        _markerClient.destroy();
    }
    _markerClient = null;
}
//# sourceMappingURL=marker.js.map