/**
 * HTTP Upload Handler for OCR Provenance MCP System
 *
 * Handles multipart file uploads via busboy, stages files to /data/staged/,
 * validates them (filename, extension, magic bytes, size), computes SHA-256
 * hashes, and creates document + provenance records in the database.
 *
 * CRITICAL: NEVER use console.log() — stdout is JSON-RPC. Use console.error() for ALL logging.
 * CRITICAL: NEVER fallback/degrade in catch blocks — throw with context.
 *
 * @module services/upload/handler
 */
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import Busboy from 'busboy';
import { SUPPORTED_FILE_TYPES } from '../../models/document.js';
import { ProvenanceType } from '../../models/provenance.js';
import { ValidationError } from '../../utils/validation.js';
// =============================================================================
// CONSTANTS (internal)
// =============================================================================
/** Maximum size per file: 500 MB */
const MAX_FILE_SIZE = 500 * 1024 * 1024;
/** Maximum total upload size: 2 GB */
const MAX_TOTAL_SIZE = 2 * 1024 * 1024 * 1024;
/** Maximum files per request */
const MAX_FILES = 100;
/** Staging directory base path */
const STAGED_DIR = process.env.OCR_PROVENANCE_DATABASES_PATH
    ? path.join(process.env.OCR_PROVENANCE_DATABASES_PATH, 'staged')
    : '/data/staged';
/** Maximum total staging area size: 10 GB */
const STAGED_MAX_SIZE = 10 * 1024 * 1024 * 1024;
// =============================================================================
// EXPORTED UTILITY FUNCTIONS
// =============================================================================
/**
 * Sanitize a filename to prevent path traversal and injection attacks.
 *
 * - Rejects null bytes (throws ValidationError)
 * - Strips directory components via path.basename (handles both / and \)
 * - Rejects empty/whitespace-only results (throws ValidationError)
 * - Truncates to 255 characters
 * - Replaces control characters (0x00-0x1F except tab/newline) with underscore
 *
 * @param name - Raw filename from upload
 * @returns Sanitized filename (basename only)
 * @throws ValidationError on null bytes, empty result
 */
export function sanitizeFilename(name) {
    // Reject null bytes immediately
    if (name.includes('\x00')) {
        throw new ValidationError('Invalid filename: contains null bytes');
    }
    // Strip directory components (handles both / and \ separators)
    let sanitized = path.basename(name);
    // Also handle Windows-style backslash paths on Linux where path.basename
    // might not strip backslash separators
    const lastBackslash = sanitized.lastIndexOf('\\');
    if (lastBackslash !== -1) {
        sanitized = sanitized.substring(lastBackslash + 1);
    }
    // Reject empty or whitespace-only
    if (!sanitized || sanitized.trim().length === 0) {
        throw new ValidationError('Invalid filename: empty or whitespace-only after sanitization');
    }
    // Truncate to 255 characters (ext4 filesystem limit).
    // CRITICAL: Preserve the file extension — it is required for type validation
    // and provenance. Truncate only the base name portion, never the extension.
    if (sanitized.length > 255) {
        const ext = path.extname(sanitized);
        const maxBase = 255 - ext.length;
        if (maxBase <= 0) {
            // Extension alone exceeds 255 chars — pathological, reject
            throw new ValidationError(`Invalid filename: extension too long (${ext.length} chars)`);
        }
        sanitized = sanitized.substring(0, maxBase) + ext;
    }
    // Replace control characters (0x01-0x1F, excluding 0x09=tab, 0x0A=newline) with underscore
    // Note: 0x00 was already rejected above
    sanitized = sanitized.replace(/[\x01-\x08\x0B-\x0C\x0E-\x1F]/g, '_');
    return sanitized;
}
/**
 * Validate file content by checking magic bytes against expected extension.
 *
 * - For text types (txt, csv, md): always returns true (no magic bytes)
 * - For PDF: checks for %PDF header
 * - For PNG: checks for \x89PNG header
 * - For JPEG: checks for \xFF\xD8\xFF header
 * - For DOCX/XLSX/PPTX: checks for PK\x03\x04 (ZIP/Office) header
 * - For other types: returns true (allow through)
 * - For empty buffer: returns false
 * - Always rejects ELF (\x7FELF) and MZ (Windows exe) headers
 *
 * @param buffer - First bytes of the file
 * @param extension - File extension (without dot, lowercase)
 * @returns true if magic bytes match (or type has no magic check), false otherwise
 */
export function validateMagicBytes(buffer, extension) {
    // Empty buffer is always invalid
    if (!buffer || buffer.length === 0) {
        return false;
    }
    // Always reject executables regardless of extension
    // ELF: \x7F E L F
    if (buffer.length >= 4 && buffer[0] === 0x7f && buffer[1] === 0x45 && buffer[2] === 0x4c && buffer[3] === 0x46) {
        return false;
    }
    // MZ: Windows PE executable
    if (buffer.length >= 2 && buffer[0] === 0x4d && buffer[1] === 0x5a) {
        return false;
    }
    const ext = extension.toLowerCase();
    // Text types have no magic bytes — always pass
    if (ext === 'txt' || ext === 'csv' || ext === 'md') {
        return true;
    }
    // PDF: %PDF
    if (ext === 'pdf') {
        if (buffer.length < 4)
            return false;
        return buffer[0] === 0x25 && buffer[1] === 0x50 && buffer[2] === 0x44 && buffer[3] === 0x46;
    }
    // PNG: \x89 P N G
    if (ext === 'png') {
        if (buffer.length < 4)
            return false;
        return buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47;
    }
    // JPEG: \xFF\xD8\xFF
    if (ext === 'jpg' || ext === 'jpeg') {
        if (buffer.length < 3)
            return false;
        return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
    }
    // Office (DOCX, XLSX, PPTX) = ZIP format: PK\x03\x04
    if (ext === 'docx' || ext === 'xlsx' || ext === 'pptx') {
        if (buffer.length < 4)
            return false;
        return buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x03 && buffer[3] === 0x04;
    }
    // TIFF: II\x2A\x00 (little-endian) or MM\x00\x2A (big-endian)
    if (ext === 'tiff' || ext === 'tif') {
        if (buffer.length < 4)
            return false;
        const isLE = buffer[0] === 0x49 && buffer[1] === 0x49 && buffer[2] === 0x2a && buffer[3] === 0x00;
        const isBE = buffer[0] === 0x4d && buffer[1] === 0x4d && buffer[2] === 0x00 && buffer[3] === 0x2a;
        return isLE || isBE;
    }
    // GIF: GIF87a or GIF89a
    if (ext === 'gif') {
        if (buffer.length < 4)
            return false;
        return buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46;
    }
    // BMP: BM
    if (ext === 'bmp') {
        if (buffer.length < 2)
            return false;
        return buffer[0] === 0x42 && buffer[1] === 0x4d;
    }
    // WebP: RIFF....WEBP
    if (ext === 'webp') {
        if (buffer.length < 12)
            return false;
        return buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
            buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50;
    }
    // DOC/XLS/PPT (legacy Office): D0 CF 11 E0 (OLE Compound Document)
    if (ext === 'doc' || ext === 'xls' || ext === 'ppt') {
        if (buffer.length < 4)
            return false;
        return buffer[0] === 0xd0 && buffer[1] === 0xcf && buffer[2] === 0x11 && buffer[3] === 0xe0;
    }
    // Other types: allow through
    return true;
}
/**
 * Detect the actual file format from magic bytes, regardless of extension.
 * Returns a human-readable format name and suggested extension.
 * Returns null if format cannot be determined.
 */
export function detectFormatFromMagicBytes(buffer) {
    if (!buffer || buffer.length < 4)
        return null;
    // PDF: %PDF
    if (buffer[0] === 0x25 && buffer[1] === 0x50 && buffer[2] === 0x44 && buffer[3] === 0x46) {
        return { format: 'PDF', suggestedExt: 'pdf' };
    }
    // ZIP / Office 2007+ (DOCX/XLSX/PPTX): PK\x03\x04
    if (buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x03 && buffer[3] === 0x04) {
        return { format: 'Office 2007+ (DOCX/XLSX/PPTX)', suggestedExt: 'docx' };
    }
    // OLE2 Compound Document (legacy DOC/XLS/PPT): D0 CF 11 E0
    if (buffer[0] === 0xd0 && buffer[1] === 0xcf && buffer[2] === 0x11 && buffer[3] === 0xe0) {
        return { format: 'Legacy Office (DOC/XLS/PPT)', suggestedExt: 'doc' };
    }
    // PNG: \x89PNG
    if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
        return { format: 'PNG image', suggestedExt: 'png' };
    }
    // JPEG: \xFF\xD8\xFF
    if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
        return { format: 'JPEG image', suggestedExt: 'jpg' };
    }
    // GIF: GIF
    if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
        return { format: 'GIF image', suggestedExt: 'gif' };
    }
    // BMP: BM
    if (buffer[0] === 0x42 && buffer[1] === 0x4d) {
        return { format: 'BMP image', suggestedExt: 'bmp' };
    }
    // TIFF LE: II*\0
    if (buffer[0] === 0x49 && buffer[1] === 0x49 && buffer[2] === 0x2a && buffer[3] === 0x00) {
        return { format: 'TIFF image', suggestedExt: 'tiff' };
    }
    // TIFF BE: MM\0*
    if (buffer[0] === 0x4d && buffer[1] === 0x4d && buffer[2] === 0x00 && buffer[3] === 0x2a) {
        return { format: 'TIFF image', suggestedExt: 'tiff' };
    }
    // WebP: RIFF....WEBP
    if (buffer.length >= 12 &&
        buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
        buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
        return { format: 'WebP image', suggestedExt: 'webp' };
    }
    return null;
}
/**
 * Check whether a file extension is a valid upload type.
 *
 * @param ext - File extension without dot (case-insensitive)
 * @returns true if the extension is in SUPPORTED_FILE_TYPES
 */
export function isValidUploadType(ext) {
    if (!ext)
        return false;
    return SUPPORTED_FILE_TYPES.includes(ext.toLowerCase());
}
// =============================================================================
// MAIN UPLOAD HANDLER
// =============================================================================
/**
 * Handle a multipart file upload HTTP request.
 *
 * Parses multipart/form-data via busboy, stages files to /data/staged/{batchUuid}/,
 * validates them, computes SHA-256 hashes during streaming, checks for duplicates,
 * and creates document + provenance records in the database.
 *
 * @param req - Node.js HTTP IncomingMessage
 * @param res - Node.js HTTP ServerResponse
 * @param getDatabaseConnection - Callback returning a database connection
 * @param databaseName - Optional target database name
 */
export async function handleUpload(req, res, getDatabaseConnection, databaseName) {
    const batchId = uuidv4();
    const batchDir = path.join(STAGED_DIR, batchId);
    let batchDirCreated = false;
    // Track state for cleanup on error
    const cleanup = () => {
        if (batchDirCreated) {
            try {
                fs.rmSync(batchDir, { recursive: true, force: true });
            }
            catch (e) {
                console.error(`[upload] Failed to cleanup batch dir ${batchDir}: ${e instanceof Error ? e.message : String(e)}`);
            }
        }
    };
    try {
        // Validate Content-Type is multipart/form-data
        const contentType = req.headers['content-type'] || '';
        if (!contentType.includes('multipart/form-data')) {
            sendErrorResponse(res, 400, 'VALIDATION_ERROR', "Content-Type must be multipart/form-data. Use field name 'files' for file uploads.");
            return;
        }
        // Create batch staging directory
        fs.mkdirSync(batchDir, { recursive: true });
        batchDirCreated = true;
        // Parse the multipart upload
        const parseResult = await parseMultipartUpload(req, batchDir);
        // If parsing itself set an error, respond and bail
        if (parseResult.error) {
            cleanup();
            sendErrorResponse(res, parseResult.errorStatus || 400, 'VALIDATION_ERROR', parseResult.error);
            return;
        }
        // No files received
        if (parseResult.files.length === 0) {
            cleanup();
            sendErrorResponse(res, 400, 'VALIDATION_ERROR', "No files provided. Use field name 'files' with multipart/form-data.");
            return;
        }
        // Validate total size
        const totalBytes = parseResult.files.reduce((sum, f) => sum + f.size, 0);
        if (totalBytes > MAX_TOTAL_SIZE) {
            cleanup();
            sendErrorResponse(res, 400, 'VALIDATION_ERROR', `Total upload size exceeds 2 GB limit (got ${formatBytes(totalBytes)}).`);
            return;
        }
        // Get database connection
        const resolvedDbName = parseResult.fields.database || databaseName;
        let db;
        try {
            db = getDatabaseConnection(resolvedDbName);
        }
        catch (err) {
            cleanup();
            const msg = err instanceof Error ? err.message : String(err);
            if (msg.toLowerCase().includes('not found') || msg.toLowerCase().includes('does not exist')) {
                sendErrorResponse(res, 404, 'NOT_FOUND', `Database '${resolvedDbName}' not found.`);
            }
            else {
                throw err;
            }
            return;
        }
        // Process each staged file: duplicate check, document + provenance creation
        const uploadedFiles = [];
        const skippedFiles = [];
        for (const staged of parseResult.files) {
            try {
                // Read first 16 bytes for magic byte validation
                const headerBuffer = readFileHeader(staged.path, 16);
                const ext = path.extname(staged.originalName).slice(1).toLowerCase();
                if (!validateMagicBytes(headerBuffer, ext)) {
                    // Detect actual format to provide actionable error message
                    const detected = detectFormatFromMagicBytes(headerBuffer);
                    let reason;
                    if (detected) {
                        // Suggest the modern equivalent of the claimed extension
                        // e.g., .doc → .docx, .ppt → .pptx, .xls → .xlsx
                        const MODERN_EXT = {
                            doc: 'docx', ppt: 'pptx', xls: 'xlsx',
                        };
                        const suggestedExt = MODERN_EXT[ext] || detected.suggestedExt;
                        reason = `File "${staged.originalName}" has .${ext} extension but contains ${detected.format} data. ` +
                            `Rename to .${suggestedExt} and try again.`;
                    }
                    else {
                        reason = `File content does not match expected type for .${ext} (magic byte mismatch)`;
                    }
                    // Remove the staged file and record as skip
                    fs.unlinkSync(staged.path);
                    skippedFiles.push({
                        original_name: staged.originalName,
                        reason,
                        existing_document_id: '',
                    });
                    continue;
                }
                // Check for duplicate by hash
                const existingDoc = db.getDocumentByHash(staged.hash);
                if (existingDoc) {
                    // Remove staged copy of duplicate
                    fs.unlinkSync(staged.path);
                    skippedFiles.push({
                        original_name: staged.originalName,
                        reason: `Duplicate content (${staged.hash} already exists as ${existingDoc.file_name})`,
                        existing_document_id: existingDoc.id,
                    });
                    continue;
                }
                // Create document and provenance records
                const documentId = uuidv4();
                const provenanceId = uuidv4();
                const now = new Date().toISOString();
                // Insert provenance first (document FK references provenance)
                db.insertProvenance({
                    id: provenanceId,
                    type: ProvenanceType.DOCUMENT,
                    created_at: now,
                    processed_at: now,
                    source_file_created_at: null,
                    source_file_modified_at: null,
                    source_type: 'FILE',
                    source_path: staged.path,
                    source_id: null,
                    root_document_id: provenanceId,
                    location: null,
                    content_hash: staged.hash,
                    input_hash: null,
                    file_hash: staged.hash,
                    processor: 'http-upload',
                    processor_version: '1.0.0',
                    processing_params: {
                        upload_batch_id: batchId,
                        source_type: 'HTTP_UPLOAD',
                        original_filename: staged.rawOriginalName,
                    },
                    processing_duration_ms: null,
                    processing_quality_score: null,
                    parent_id: null,
                    parent_ids: '[]',
                    chain_depth: 0,
                    chain_path: '["DOCUMENT"]',
                });
                // Insert document record
                db.insertDocument({
                    id: documentId,
                    file_path: staged.path,
                    file_name: staged.originalName,
                    file_hash: staged.hash,
                    file_size: staged.size,
                    file_type: ext,
                    status: 'pending',
                    page_count: null,
                    provenance_id: provenanceId,
                    error_message: null,
                    modified_at: null,
                    ocr_completed_at: null,
                    doc_title: null,
                    doc_author: null,
                    doc_subject: null,
                    datalab_file_id: null,
                });
                uploadedFiles.push({
                    original_name: staged.originalName,
                    raw_original_name: staged.rawOriginalName,
                    staged_path: staged.path,
                    document_id: documentId,
                    file_hash: staged.hash,
                    file_size: staged.size,
                    status: 'pending',
                });
            }
            catch (err) {
                const msg = err instanceof Error ? err.message : String(err);
                console.error(`[upload] Failed to process staged file ${staged.originalName}: ${msg}`);
                throw new Error(`Failed to create document record for '${staged.originalName}': ${msg}`);
            }
        }
        // Build the response
        const autoProcess = parseResult.fields.auto_process === 'true';
        const result = {
            uploaded: uploadedFiles.length,
            skipped: skippedFiles.length,
            failed: 0,
            total_bytes: uploadedFiles.reduce((sum, f) => sum + f.file_size, 0),
            database: resolvedDbName || 'default',
            auto_process: autoProcess,
            files: uploadedFiles,
            skipped_files: skippedFiles,
        };
        // Send success response
        res.writeHead(201, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ success: true, data: result }));
        console.error(`[upload] Batch ${batchId}: ${uploadedFiles.length} uploaded, ${skippedFiles.length} skipped`);
        // Enforce staging quota (fire-and-forget, errors logged but not thrown to client)
        try {
            enforceStageQuota();
        }
        catch (e) {
            console.error(`[upload] Quota enforcement failed: ${e instanceof Error ? e.message : String(e)}`);
        }
    }
    catch (err) {
        cleanup();
        const msg = err instanceof Error ? err.message : String(err);
        console.error(`[upload] Upload failed for batch ${batchId}: ${msg}`);
        // Don't send response if headers already sent
        if (!res.headersSent) {
            const status = err instanceof ValidationError ? 400 : 500;
            const category = err instanceof ValidationError ? 'VALIDATION_ERROR' : 'SYSTEM_ERROR';
            sendErrorResponse(res, status, category, msg);
        }
    }
}
/**
 * Parse multipart upload using busboy.
 *
 * Streams files to disk, computes SHA-256 during streaming, enforces per-file
 * and per-request limits.
 */
function parseMultipartUpload(req, batchDir) {
    return new Promise((resolve) => {
        const files = [];
        const fields = {};
        let fileCount = 0;
        let totalBytes = 0;
        let earlyError = null;
        let earlyErrorStatus = 400;
        // Track pending write streams so we can wait for them to flush before resolving
        const pendingWrites = [];
        let busboy;
        try {
            busboy = Busboy({
                headers: req.headers,
                limits: {
                    fileSize: MAX_FILE_SIZE,
                    files: MAX_FILES + 1, // Allow one extra to detect over-limit
                },
            });
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            resolve({
                files: [],
                fields: {},
                error: `Failed to parse multipart request: ${msg}`,
                errorStatus: 400,
            });
            return;
        }
        busboy.on('field', (name, value) => {
            if (name === 'database') {
                fields.database = value;
            }
            else if (name === 'auto_process') {
                fields.auto_process = value;
            }
        });
        busboy.on('file', (name, stream, info) => {
            // Only accept files with field name 'files'
            if (name !== 'files') {
                stream.resume(); // Drain the stream
                if (!earlyError) {
                    earlyError = "Invalid field name for file upload. Use field name 'files' with multipart/form-data.";
                }
                return;
            }
            fileCount++;
            // Check file count limit
            if (fileCount > MAX_FILES) {
                stream.resume();
                if (!earlyError) {
                    earlyError = `Too many files (${fileCount}). Maximum ${MAX_FILES} per request.`;
                }
                return;
            }
            // If we already have an early error, drain remaining streams
            if (earlyError) {
                stream.resume();
                return;
            }
            const rawFilename = info.filename;
            // Validate filename
            let safeName;
            try {
                safeName = sanitizeFilename(rawFilename);
            }
            catch (err) {
                stream.resume();
                const msg = err instanceof Error ? err.message : String(err);
                earlyError = msg;
                return;
            }
            // Validate extension
            const ext = path.extname(safeName).slice(1).toLowerCase();
            if (!isValidUploadType(ext)) {
                stream.resume();
                earlyError = `Unsupported file type: .${ext || '(none)'}. Supported: ${SUPPORTED_FILE_TYPES.join(', ')}`;
                return;
            }
            // Handle duplicate filenames within the same batch
            let targetName = safeName;
            let counter = 1;
            while (fs.existsSync(path.join(batchDir, targetName))) {
                const extPart = path.extname(safeName);
                const basePart = path.basename(safeName, extPart);
                targetName = `${basePart}_${counter}${extPart}`;
                counter++;
            }
            const targetPath = path.join(batchDir, targetName);
            const writeStream = fs.createWriteStream(targetPath);
            const hash = crypto.createHash('sha256');
            let fileBytes = 0;
            let fileTruncated = false;
            stream.on('data', (chunk) => {
                fileBytes += chunk.length;
                totalBytes += chunk.length;
                hash.update(chunk);
                writeStream.write(chunk);
            });
            stream.on('limit', () => {
                fileTruncated = true;
                earlyError = `File '${rawFilename}' exceeds ${formatBytes(MAX_FILE_SIZE)} limit.`;
            });
            // Track the write completion promise so busboy 'close' waits for disk flush
            const writeComplete = new Promise((writeResolve) => {
                stream.on('end', () => {
                    writeStream.end(() => {
                        // Callback fires AFTER data is flushed to the OS buffer
                        if (fileTruncated) {
                            try {
                                fs.unlinkSync(targetPath);
                            }
                            catch { /* ignore */ }
                            writeResolve();
                            return;
                        }
                        if (fileBytes === 0) {
                            try {
                                fs.unlinkSync(targetPath);
                            }
                            catch { /* ignore */ }
                            if (!earlyError) {
                                earlyError = `File '${rawFilename}' is empty (0 bytes).`;
                            }
                            writeResolve();
                            return;
                        }
                        const fileHash = 'sha256:' + hash.digest('hex');
                        files.push({
                            originalName: safeName,
                            rawOriginalName: rawFilename,
                            path: targetPath,
                            hash: fileHash,
                            size: fileBytes,
                        });
                        writeResolve();
                    });
                });
                stream.on('error', (err) => {
                    writeStream.destroy();
                    try {
                        fs.unlinkSync(targetPath);
                    }
                    catch { /* ignore */ }
                    if (!earlyError) {
                        earlyError = `Stream error for '${rawFilename}': ${err.message}`;
                        earlyErrorStatus = 500;
                    }
                    writeResolve();
                });
            });
            pendingWrites.push(writeComplete);
            writeStream.on('error', (err) => {
                if (!earlyError) {
                    earlyError = `Failed to write staged file '${rawFilename}': ${err.message}`;
                    earlyErrorStatus = 500;
                }
            });
        });
        busboy.on('close', async () => {
            // Wait for all write streams to flush to disk before resolving
            await Promise.all(pendingWrites);
            if (earlyError) {
                resolve({ files: [], fields, error: earlyError, errorStatus: earlyErrorStatus });
            }
            else {
                resolve({ files, fields });
            }
        });
        busboy.on('error', (err) => {
            if (!earlyError) {
                earlyError = `Multipart parsing error: ${err.message}`;
                earlyErrorStatus = 400;
            }
            resolve({ files: [], fields, error: earlyError, errorStatus: earlyErrorStatus });
        });
        req.pipe(busboy);
    });
}
// =============================================================================
// CLEANUP FUNCTIONS
// =============================================================================
/**
 * Clean up staged files older than a specified threshold.
 *
 * @param olderThanHours - Delete batch directories older than this many hours (default: 24)
 * @param deleteAll - If true, delete all staged files regardless of age (default: false)
 * @returns CleanupResult with count, bytes freed, and human-readable size
 */
export function cleanupStagedFiles(olderThanHours = 24, deleteAll = false) {
    let deletedCount = 0;
    let freedBytes = 0;
    if (!fs.existsSync(STAGED_DIR)) {
        return { deleted_count: 0, freed_bytes: 0, freed_human: '0 B' };
    }
    const entries = fs.readdirSync(STAGED_DIR, { withFileTypes: true });
    const now = Date.now();
    const thresholdMs = olderThanHours * 60 * 60 * 1000;
    for (const entry of entries) {
        if (!entry.isDirectory())
            continue;
        const dirPath = path.join(STAGED_DIR, entry.name);
        if (!deleteAll) {
            try {
                const stat = fs.statSync(dirPath);
                const ageMs = now - stat.mtimeMs;
                if (ageMs < thresholdMs)
                    continue;
            }
            catch {
                continue;
            }
        }
        // Calculate size before deletion
        const dirSize = getDirectorySize(dirPath);
        freedBytes += dirSize;
        try {
            fs.rmSync(dirPath, { recursive: true, force: true });
            deletedCount++;
            console.error(`[upload-cleanup] Deleted staged batch ${entry.name} (${formatBytes(dirSize)})`);
        }
        catch (err) {
            console.error(`[upload-cleanup] Failed to delete ${dirPath}: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    return {
        deleted_count: deletedCount,
        freed_bytes: freedBytes,
        freed_human: formatBytes(freedBytes),
    };
}
/**
 * Enforce the staging area quota.
 *
 * If /data/staged exceeds STAGED_MAX_SIZE (10 GB), delete the oldest batch
 * directories until total is below 80% of the limit (8 GB).
 *
 * Logs cleanup actions via console.error.
 */
export function enforceStageQuota() {
    if (!fs.existsSync(STAGED_DIR))
        return;
    let totalSize = getDirectorySize(STAGED_DIR);
    if (totalSize <= STAGED_MAX_SIZE)
        return;
    console.error(`[upload-quota] Staged area ${formatBytes(totalSize)} exceeds limit ${formatBytes(STAGED_MAX_SIZE)}. Cleaning up...`);
    const targetSize = STAGED_MAX_SIZE * 0.8; // 80% of limit = 8 GB
    // Get all batch directories sorted by mtime ascending (oldest first)
    const entries = fs.readdirSync(STAGED_DIR, { withFileTypes: true })
        .filter(e => e.isDirectory())
        .map(e => {
        const fullPath = path.join(STAGED_DIR, e.name);
        try {
            const stat = fs.statSync(fullPath);
            return { name: e.name, path: fullPath, mtimeMs: stat.mtimeMs };
        }
        catch {
            return null;
        }
    })
        .filter((e) => e !== null)
        .sort((a, b) => a.mtimeMs - b.mtimeMs);
    for (const entry of entries) {
        if (totalSize <= targetSize)
            break;
        const dirSize = getDirectorySize(entry.path);
        try {
            fs.rmSync(entry.path, { recursive: true, force: true });
            totalSize -= dirSize;
            console.error(`[upload-quota] Evicted batch ${entry.name} (${formatBytes(dirSize)}). Remaining: ${formatBytes(totalSize)}`);
        }
        catch (err) {
            console.error(`[upload-quota] Failed to evict ${entry.path}: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
}
// =============================================================================
// INTERNAL HELPERS
// =============================================================================
/**
 * Send a JSON error response.
 */
function sendErrorResponse(res, status, category, message) {
    res.writeHead(status, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({
        success: false,
        error: { category, message },
    }));
}
/**
 * Read the first N bytes of a file for magic byte checking.
 */
function readFileHeader(filePath, bytes) {
    const fd = fs.openSync(filePath, 'r');
    try {
        const buffer = Buffer.alloc(bytes);
        const bytesRead = fs.readSync(fd, buffer, 0, bytes, 0);
        return buffer.subarray(0, bytesRead);
    }
    finally {
        fs.closeSync(fd);
    }
}
/**
 * Calculate total size of a directory recursively.
 */
function getDirectorySize(dirPath) {
    let total = 0;
    try {
        const entries = fs.readdirSync(dirPath, { withFileTypes: true });
        for (const entry of entries) {
            const fullPath = path.join(dirPath, entry.name);
            if (entry.isDirectory()) {
                total += getDirectorySize(fullPath);
            }
            else if (entry.isFile()) {
                try {
                    const stat = fs.statSync(fullPath);
                    total += stat.size;
                }
                catch { /* skip inaccessible files */ }
            }
        }
    }
    catch { /* directory not accessible */ }
    return total;
}
/**
 * Format bytes into a human-readable string.
 */
function formatBytes(bytes) {
    if (bytes === 0)
        return '0 B';
    const units = ['B', 'KB', 'MB', 'GB', 'TB'];
    const k = 1024;
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    const value = bytes / Math.pow(k, i);
    return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
//# sourceMappingURL=handler.js.map