/**
 * @fileoverview File browser and streaming routes.
 * Provides directory listing, file content preview, raw file serving, and tail streaming.
 */
import { basename as pathBasename, join } from 'node:path';
import { createReadStream, realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { fileStreamManager } from '../../file-stream-manager.js';
import { AttachmentRegistrationError, attachmentRecordToEvent, attachmentRegistry, buildFileThumbnailRoute, isSupportedAttachmentExtension, registerExternalAttachment, } from '../../attachment-registry.js';
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
import { isSensitivePath } from '../sensitive-path.js';
import { SseEvent } from '../sse-events.js';
const MIME_TYPES = {
    png: 'image/png',
    jpg: 'image/jpeg',
    jpeg: 'image/jpeg',
    gif: 'image/gif',
    webp: 'image/webp',
    ico: 'image/x-icon',
    bmp: 'image/bmp',
    pdf: 'application/pdf',
    docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
    json: 'application/json',
    md: 'text/markdown',
    txt: 'text/plain',
};
function sanitizeDownloadName(fileName) {
    return fileName.replace(/["\\\r\n]/g, '_');
}
function sendRawStream(reply, content) {
    const headers = reply.getHeaders();
    reply.hijack();
    for (const [name, value] of Object.entries(headers)) {
        if (value !== undefined) {
            reply.raw.setHeader(name, value);
        }
    }
    content.on('error', (err) => {
        if (reply.raw.headersSent) {
            reply.raw.destroy(err);
            return;
        }
        reply.raw.statusCode = 500;
        reply.raw.end('Failed to read file');
    });
    content.pipe(reply.raw);
}
async function serveRawFile(reply, resolvedPath, fileName, extension, download) {
    const stat = await fs.stat(resolvedPath);
    const MAX_RAW_ATTACHMENT_SIZE = 50 * 1024 * 1024; // 50MB, matching file-raw / download
    if (stat.size > MAX_RAW_ATTACHMENT_SIZE) {
        reply
            .code(413)
            .send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_ATTACHMENT_SIZE / 1024 / 1024}MB limit)`));
        return;
    }
    const content = createReadStream(resolvedPath);
    const safeName = sanitizeDownloadName(fileName);
    if (download || extension === 'svg') {
        reply.header('Content-Type', extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream');
        reply.header('Content-Disposition', `attachment; filename="${safeName}"`);
        reply.header('Content-Length', stat.size);
        reply.header('X-Content-Type-Options', 'nosniff');
        sendRawStream(reply, content);
        return;
    }
    reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
    reply.header('Content-Disposition', `inline; filename="${safeName}"`);
    reply.header('Content-Length', stat.size);
    reply.header('X-Content-Type-Options', 'nosniff');
    sendRawStream(reply, content);
}
function getAttachmentOr404(reply, sessionId, attachmentId) {
    const record = attachmentRegistry.get(sessionId, attachmentId);
    if (!record) {
        reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Attachment not found'));
        return undefined;
    }
    return record;
}
/**
 * COD-53 defense-in-depth: refuse to stream a record whose underlying path is
 * blocked by the active attachment-guard policy, even though registration
 * already blocks them. Guards against records that predate the guard or were
 * crafted to point at a sensitive file. Resolves symlinks before the check so a
 * record pointing at a symlink that now resolves to a sensitive target is also
 * caught; if the path can't be resolved (deleted/unreadable) the check still
 * runs on the stored path. When workspace confinement is enabled it additionally
 * rejects any record outside the session workspace. Returns true (and sends a
 * 403) when blocked.
 */
async function resolveServableAttachmentPath(reply, record, sessionWorkingDir) {
    let pathToCheck = record.filePath;
    let resolved = false;
    try {
        pathToCheck = realpathSync(record.filePath);
        resolved = true;
    }
    catch {
        // Fall back to the stored (already realpath-resolved at registration) path.
    }
    const guard = await loadAttachmentGuardConfig();
    const blocked = isBlockedAttachmentPath(pathToCheck, guard.blockedTrees) ||
        isBlockedAttachmentPath(record.filePath, guard.blockedTrees) ||
        (guard.confineToWorkspace && (!sessionWorkingDir || !validateSessionFilePath(sessionWorkingDir, pathToCheck)));
    if (blocked) {
        reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
        return null;
    }
    // Serve the freshly-resolved path, not the stored one: if a path component
    // became a symlink after registration, the guard checked the resolved target
    // but streaming record.filePath would follow the symlink to a swapped file.
    return resolved ? pathToCheck : record.filePath;
}
/**
 * Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and
 * stream it inline. PDF/PNG and text formats don't need conversion — callers
 * redirect those to the raw route instead.
 */
async function serveConvertedPreview(reply, resolvedPath, fileName, extension) {
    if (extension !== 'docx' && extension !== 'pptx') {
        reply
            .code(400)
            .send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Preview is not supported for this file type'));
        return;
    }
    try {
        const previewPath = await getOfficePreviewPdfPath(resolvedPath, extension);
        if (!previewPath) {
            reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Document preview conversion failed'));
            return;
        }
        const content = await fs.readFile(previewPath);
        reply.header('Content-Type', 'application/pdf');
        reply.header('Content-Disposition', `inline; filename="${getPreviewPdfDownloadName(fileName, extension)}"`);
        reply.header('Cache-Control', 'no-cache');
        reply.header('Content-Length', content.length);
        reply.header('X-Content-Type-Options', 'nosniff');
        reply.send(content);
    }
    catch (err) {
        reply
            .code(500)
            .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to generate preview: ${getErrorMessage(err)}`));
    }
}
/** Generate and stream a first-page thumbnail (PNG) for a supported attachment. */
async function serveThumbnail(reply, resolvedPath, extension) {
    const thumbnail = await generateFirstPageThumbnail(resolvedPath, extension);
    if (!thumbnail) {
        reply.code(204).send();
        return;
    }
    reply.header('Content-Type', thumbnail.contentType);
    reply.header('Cache-Control', 'no-cache');
    reply.header('X-Content-Type-Options', 'nosniff');
    reply.send(thumbnail.content);
}
/**
 * Resolve a session's working dir from the live session, falling back to the
 * persisted record so preview/thumbnail requests keep working for a session
 * that has since detached. Sends a 404 and returns undefined when unknown.
 */
function getKnownSessionWorkingDir(ctx, sessionId, reply) {
    const liveSession = ctx.sessions.get(sessionId);
    if (liveSession)
        return liveSession.workingDir;
    const stored = ctx.store.getSession(sessionId);
    if (stored)
        return stored.workingDir;
    reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
    return undefined;
}
function appendDownloadFlag(url) {
    return `${url}${url.includes('?') ? '&' : '?'}download=true`;
}
function getSessionAttachmentHistory(ctx, sessionId) {
    const liveSession = ctx.sessions.get(sessionId);
    if (liveSession) {
        return {
            workingDir: liveSession.workingDir,
            history: liveSession.getAttachmentHistoryForPersist() ?? liveSession.attachmentHistory ?? [],
        };
    }
    const stored = ctx.store.getSession(sessionId);
    if (!stored)
        return undefined;
    return {
        workingDir: stored.workingDir,
        history: stored.__attachmentHistory ?? stored.attachmentHistory ?? [],
    };
}
// History item for a file detected inside the workspace: re-stat for live
// size/mtime and resolve preview/thumbnail/raw routes off the relative path.
async function buildDetectedAttachmentRouteItem(sessionId, workingDir, item) {
    const safe = sanitizeAttachmentHistoryItem(item);
    if (!item.relativePath) {
        return { ...safe, missing: true };
    }
    const validated = validateSessionFilePath(workingDir, item.relativePath);
    if (!validated) {
        return { ...safe, missing: true };
    }
    let size = item.size;
    let mtimeMs = item.mtimeMs;
    try {
        const stat = await fs.stat(validated.resolvedPath);
        size = stat.size;
        mtimeMs = stat.mtimeMs ?? mtimeMs;
    }
    catch {
        return { ...safe, missing: true };
    }
    const encodedPath = encodeURIComponent(item.relativePath);
    const rawUrl = `/api/sessions/${sessionId}/file-raw?path=${encodedPath}`;
    const previewUrl = item.extension === 'docx' || item.extension === 'pptx'
        ? `/api/sessions/${sessionId}/file-preview?path=${encodedPath}`
        : rawUrl;
    const thumbnailUrl = isSupportedAttachmentExtension(item.extension)
        ? buildFileThumbnailRoute(sessionId, item.relativePath)
        : undefined;
    return {
        ...safe,
        size,
        mtimeMs,
        missing: false,
        rawUrl,
        url: rawUrl,
        previewUrl,
        thumbnailUrl,
        downloadUrl: appendDownloadFlag(rawUrl),
    };
}
// History item for an explicitly published external file: re-register it to mint
// a fresh id + by-id routes (the guard runs again), or mark it missing.
async function buildExternalAttachmentRouteItem(sessionId, item, sessionWorkingDir) {
    const safe = sanitizeAttachmentHistoryItem(item);
    if (!item.externalPath) {
        return { ...safe, missing: true };
    }
    try {
        const event = await registerExternalAttachment(sessionId, item.externalPath, { sessionWorkingDir });
        return {
            ...safe,
            fileName: event.fileName,
            extension: event.extension,
            attachmentType: event.attachmentType,
            size: event.size,
            missing: false,
            attachmentId: event.attachmentId,
            rawUrl: event.rawUrl,
            url: event.rawUrl,
            previewUrl: event.previewUrl,
            thumbnailUrl: event.thumbnailUrl,
            downloadUrl: appendDownloadFlag(event.rawUrl),
        };
    }
    catch (err) {
        if (err instanceof AttachmentRegistrationError) {
            return { ...safe, missing: true };
        }
        throw err;
    }
}
export function registerFileRoutes(app, ctx) {
    // File tree listing
    app.get('/api/sessions/:id/files', async (req) => {
        const { id } = req.params;
        const { depth, showHidden } = req.query;
        const session = findSessionOrFail(ctx, id);
        const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
        const includeHidden = showHidden === 'true';
        const workingDir = session.workingDir;
        // Default excludes - large/generated directories
        const excludeDirs = new Set([
            '.git',
            'node_modules',
            'dist',
            'build',
            '__pycache__',
            '.cache',
            '.next',
            '.nuxt',
            'coverage',
            '.venv',
            'venv',
            '.tox',
            'target',
            'vendor',
        ]);
        let totalFiles = 0;
        let totalDirectories = 0;
        let truncated = false;
        const maxFiles = 5000;
        const scanDirectory = async (dirPath, currentDepth) => {
            if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) {
                truncated = true;
                return [];
            }
            try {
                const entries = await fs.readdir(dirPath, { withFileTypes: true });
                const nodes = [];
                // Sort: directories first, then alphabetically
                entries.sort((a, b) => {
                    if (a.isDirectory() && !b.isDirectory())
                        return -1;
                    if (!a.isDirectory() && b.isDirectory())
                        return 1;
                    return a.name.localeCompare(b.name);
                });
                for (const entry of entries) {
                    if (totalFiles + totalDirectories > maxFiles) {
                        truncated = true;
                        break;
                    }
                    // Skip hidden files unless requested
                    if (!includeHidden && entry.name.startsWith('.'))
                        continue;
                    // Skip excluded directories
                    if (entry.isDirectory() && excludeDirs.has(entry.name))
                        continue;
                    const fullPath = join(dirPath, entry.name);
                    const relativePath = fullPath.slice(workingDir.length + 1);
                    if (entry.isDirectory()) {
                        totalDirectories++;
                        const children = await scanDirectory(fullPath, currentDepth + 1);
                        nodes.push({
                            name: entry.name,
                            path: relativePath,
                            type: 'directory',
                            children,
                        });
                    }
                    else {
                        totalFiles++;
                        const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined;
                        let size;
                        try {
                            const stat = await fs.stat(fullPath);
                            size = stat.size;
                        }
                        catch {
                            // Skip if can't stat
                        }
                        nodes.push({
                            name: entry.name,
                            path: relativePath,
                            type: 'file',
                            size,
                            extension: ext,
                        });
                    }
                }
                return nodes;
            }
            catch {
                // Can't read directory (permission denied, etc.)
                return [];
            }
        };
        const tree = await scanDirectory(workingDir, 1);
        return {
            success: true,
            data: {
                root: workingDir,
                tree,
                totalFiles,
                totalDirectories,
                truncated,
            },
        };
    });
    // Get file content for preview (File Browser)
    app.get('/api/sessions/:id/file-content', async (req) => {
        const { id } = req.params;
        const { path: filePath, lines, raw } = req.query;
        const session = findSessionOrFail(ctx, id);
        if (!filePath) {
            return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter');
        }
        // Validate path is within working directory (security: resolve symlinks to prevent traversal)
        const validated = validateSessionFilePath(session.workingDir, filePath);
        if (!validated) {
            return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
        }
        const { resolvedPath } = validated;
        try {
            const stat = await fs.stat(resolvedPath);
            // Classify by extension. Known media types render with a dedicated player;
            // other known-binary types are flagged so the client offers a download
            // affordance instead of trying to decode the bytes as text. Matches the
            // breadth of formats the attachments viewer renders (image/audio/video/pdf)
            // so the file viewer can open the same files.
            const ext = filePath.split('.').pop()?.toLowerCase() || '';
            const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
            const videoExts = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']);
            const audioExts = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']);
            const otherBinaryExts = new Set([
                'pdf',
                'zip',
                'tar',
                'gz',
                'bz2',
                'xz',
                '7z',
                'rar',
                'exe',
                'dll',
                'so',
                'dylib',
                'bin',
                'wasm',
                'class',
                'o',
                'a',
                'woff',
                'woff2',
                'ttf',
                'eot',
                'otf',
                'xlsx',
                'xls',
                'doc',
                'docx',
                'ppt',
                'pptx',
                'odt',
                'ods',
                'odp',
                'avi',
                'mkv',
                'wmv',
                'flv',
            ]);
            const mediaType = imageExts.has(ext)
                ? 'image'
                : videoExts.has(ext)
                    ? 'video'
                    : audioExts.has(ext)
                        ? 'audio'
                        : null;
            const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`;
            if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) {
                // Return metadata for media/binary files (no text body)
                return {
                    success: true,
                    data: {
                        path: filePath,
                        size: stat.size,
                        type: mediaType ?? 'binary',
                        extension: ext,
                        url: fileRawUrl,
                    },
                };
            }
            // Validate file size before reading (DoS protection - prevent memory exhaustion)
            const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024; // 10MB
            if (stat.size > MAX_TEXT_FILE_SIZE) {
                return createErrorResponse(ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit)`);
            }
            // Read as raw bytes so we can sniff for binary content before decoding. An
            // unrecognized extension (none at all, or a format not listed above) that
            // is actually binary would otherwise be dumped to the viewer as UTF-8
            // mojibake; a NUL byte in the first 8KB is a reliable binary signal that
            // (unlike a static extension list) catches arbitrary binary formats.
            const fileBuffer = await fs.readFile(resolvedPath);
            const buf = Buffer.isBuffer(fileBuffer) ? fileBuffer : Buffer.from(String(fileBuffer));
            const sniffLength = Math.min(buf.length, 8192);
            let looksBinary = false;
            for (let i = 0; i < sniffLength; i++) {
                if (buf[i] === 0) {
                    looksBinary = true;
                    break;
                }
            }
            if (looksBinary) {
                return {
                    success: true,
                    data: {
                        path: filePath,
                        size: stat.size,
                        type: 'binary',
                        extension: ext,
                        url: fileRawUrl,
                    },
                };
            }
            // Read text file with line limit (bounded to prevent DoS)
            const MAX_LINES_LIMIT = 10000;
            const maxLines = Math.min(parseInt(lines || '500', 10) || 500, MAX_LINES_LIMIT);
            const content = buf.toString('utf-8');
            const allLines = content.split('\n');
            const truncatedContent = allLines.length > maxLines;
            const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content;
            return {
                success: true,
                data: {
                    path: filePath,
                    content: displayContent,
                    size: stat.size,
                    totalLines: allLines.length,
                    truncated: truncatedContent,
                    extension: ext,
                },
            };
        }
        catch (err) {
            return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`);
        }
    });
    // Serve raw file content (for images/binary files)
    app.get('/api/sessions/:id/file-raw', async (req, reply) => {
        const { id } = req.params;
        const { path: filePath, download } = req.query;
        const session = findSessionOrFail(ctx, id);
        if (!filePath) {
            reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
            return;
        }
        // Validate path is within working directory (security: resolve symlinks to prevent traversal)
        const validated = validateSessionFilePath(session.workingDir, filePath);
        if (!validated) {
            reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
            return;
        }
        const { resolvedPath } = validated;
        try {
            // Validate file size before reading (DoS protection - prevent memory exhaustion)
            const MAX_RAW_FILE_SIZE = 50 * 1024 * 1024; // 50MB for raw files
            const stat = await fs.stat(resolvedPath);
            if (stat.size > MAX_RAW_FILE_SIZE) {
                reply
                    .code(400)
                    .send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_FILE_SIZE / 1024 / 1024}MB limit)`));
                return;
            }
            const ext = filePath.split('.').pop()?.toLowerCase() || '';
            const mimeTypes = {
                png: 'image/png',
                jpg: 'image/jpeg',
                jpeg: 'image/jpeg',
                gif: 'image/gif',
                webp: 'image/webp',
                ico: 'image/x-icon',
                bmp: 'image/bmp',
                mp4: 'video/mp4',
                webm: 'video/webm',
                mov: 'video/quicktime',
                m4v: 'video/mp4',
                ogv: 'video/ogg',
                mp3: 'audio/mpeg',
                wav: 'audio/wav',
                ogg: 'audio/ogg',
                oga: 'audio/ogg',
                opus: 'audio/ogg',
                m4a: 'audio/mp4',
                aac: 'audio/aac',
                flac: 'audio/flac',
                pdf: 'application/pdf',
                json: 'application/json',
            };
            const content = await fs.readFile(resolvedPath);
            const rawBasename = filePath.split('/').pop() || 'download';
            // Sanitize filename for Content-Disposition header (prevent header injection)
            const basename = rawBasename.replace(/["\\\r\n]/g, '_');
            if (download === 'true' || ext === 'svg') {
                reply.raw.writeHead(200, {
                    'Content-Type': ext === 'svg' ? 'application/octet-stream' : mimeTypes[ext] || 'application/octet-stream',
                    'Content-Disposition': `attachment; filename="${basename}"`,
                    'Content-Length': content.length,
                    'X-Content-Type-Options': 'nosniff',
                });
                reply.raw.end(content);
                return;
            }
            reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
            reply.header('X-Content-Type-Options', 'nosniff');
            reply.send(content);
        }
        catch (err) {
            reply
                .code(500)
                .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
        }
    });
    // ===== Live external attachments =====
    // Register an explicit, live external file (absolute host path) as an
    // attachment with a stable id so browser requests never carry arbitrary
    // paths. Registration enforces the COD-53 attachment-guard policy. Serving is
    // by id via the /raw route below; document previews/thumbnails and the
    // attachment-history list are layered on separately.
    app.post('/api/sessions/:id/attachments', async (req, reply) => {
        const { id } = req.params;
        const session = findSessionOrFail(ctx, id);
        const body = (req.body || {});
        if (!body.path || typeof body.path !== 'string') {
            reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path'));
            return;
        }
        try {
            const event = await registerExternalAttachment(id, body.path, { sessionWorkingDir: session.workingDir });
            ctx.broadcast(SseEvent.AttachmentDetected, event);
            return { success: true, data: event };
        }
        catch (err) {
            if (err instanceof AttachmentRegistrationError) {
                reply.code(err.statusCode).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, err.message));
                return;
            }
            return reply
                .code(500)
                .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to register attachment: ${getErrorMessage(err)}`));
        }
    });
    // List a session's attachment history (live session or persisted), resolving
    // each entry to current metadata + routes. External entries are re-registered.
    app.get('/api/sessions/:id/attachments', async (req, reply) => {
        const { id } = req.params;
        const sessionHistory = getSessionAttachmentHistory(ctx, id);
        if (!sessionHistory) {
            reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`));
            return;
        }
        const items = await Promise.all(sessionHistory.history.map((item) => (item.source === 'external'
            ? buildExternalAttachmentRouteItem(id, item, sessionHistory.workingDir)
            : buildDetectedAttachmentRouteItem(id, sessionHistory.workingDir, item)).catch(() => ({ ...sanitizeAttachmentHistoryItem(item), missing: true }))));
        return {
            success: true,
            data: {
                items,
                count: items.length,
            },
        };
    });
    // Metadata poll for a single registered attachment (re-stats for live
    // size/mtime as the underlying file is rewritten).
    app.get('/api/sessions/:id/attachments/:attachmentId', async (req, reply) => {
        const { id, attachmentId } = req.params;
        const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
        if (!workingDir)
            return;
        const record = getAttachmentOr404(reply, id, attachmentId);
        if (!record)
            return;
        if (!(await resolveServableAttachmentPath(reply, record, workingDir)))
            return;
        const event = attachmentRecordToEvent(record);
        let size = record.size;
        let mtimeMs = record.mtimeMs;
        try {
            const stat = await fs.stat(record.filePath);
            size = stat.size;
            mtimeMs = stat.mtimeMs ?? mtimeMs;
        }
        catch {
            // File temporarily unavailable mid-write — keep cached values.
        }
        return {
            success: true,
            data: {
                path: record.fileName,
                size,
                mtimeMs,
                type: record.attachmentType,
                extension: record.extension,
                url: event.rawUrl,
                previewUrl: event.previewUrl,
                thumbnailUrl: event.thumbnailUrl,
                attachmentId: record.attachmentId,
                fileName: record.fileName,
            },
        };
    });
    // Serve the raw bytes of a registered attachment by id. Re-checks the
    // attachment-guard policy on every request (defense-in-depth) before streaming.
    app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
        const { id, attachmentId } = req.params;
        const { download } = req.query;
        const session = findSessionOrFail(ctx, id);
        const record = getAttachmentOr404(reply, id, attachmentId);
        if (!record)
            return;
        const servePath = await resolveServableAttachmentPath(reply, record, session.workingDir);
        if (!servePath)
            return;
        try {
            await serveRawFile(reply, servePath, record.fileName, record.extension, download === 'true');
        }
        catch (err) {
            reply
                .code(500)
                .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
        }
    });
    // Serve a converted PDF preview of a registered attachment by id. Office docs
    // convert server-side; PDF/PNG/text redirect to the raw route.
    app.get('/api/sessions/:id/attachments/:attachmentId/preview', async (req, reply) => {
        const { id, attachmentId } = req.params;
        const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
        if (!workingDir)
            return;
        const record = getAttachmentOr404(reply, id, attachmentId);
        if (!record)
            return;
        const servePath = await resolveServableAttachmentPath(reply, record, workingDir);
        if (!servePath)
            return;
        // Only Office formats need server-side conversion; PDF/PNG and text formats
        // (md/txt) preview directly from their raw bytes.
        if (record.extension !== 'docx' && record.extension !== 'pptx') {
            reply.redirect(`/api/sessions/${id}/attachments/${encodeURIComponent(attachmentId)}/raw`);
            return;
        }
        await serveConvertedPreview(reply, servePath, record.fileName, record.extension);
    });
    // Serve a first-page thumbnail of a registered attachment by id.
    app.get('/api/sessions/:id/attachments/:attachmentId/thumbnail', async (req, reply) => {
        const { id, attachmentId } = req.params;
        const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
        if (!workingDir)
            return;
        const record = getAttachmentOr404(reply, id, attachmentId);
        if (!record)
            return;
        const servePath = await resolveServableAttachmentPath(reply, record, workingDir);
        if (!servePath)
            return;
        await serveThumbnail(reply, servePath, record.extension);
    });
    // Serve converted document previews for a workspace-relative path. DOCX/PPTX
    // are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
    app.get('/api/sessions/:id/file-preview', async (req, reply) => {
        const { id } = req.params;
        const { path: filePath } = req.query;
        const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
        if (!workingDir)
            return;
        if (!filePath) {
            reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
            return;
        }
        const validated = validateSessionFilePath(workingDir, filePath);
        if (!validated) {
            reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
            return;
        }
        const { resolvedPath } = validated;
        const ext = filePath.split('.').pop()?.toLowerCase() || '';
        if (ext !== 'docx' && ext !== 'pptx') {
            reply.redirect(`/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`);
            return;
        }
        await serveConvertedPreview(reply, resolvedPath, filePath, ext);
    });
    // Serve a first-page thumbnail for a workspace-relative path.
    app.get('/api/sessions/:id/file-thumbnail', async (req, reply) => {
        const { id } = req.params;
        const { path: filePath } = req.query;
        const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
        if (!workingDir)
            return;
        if (!filePath) {
            reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
            return;
        }
        const validated = validateSessionFilePath(workingDir, filePath);
        if (!validated) {
            reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
            return;
        }
        const ext = filePath.split('.').pop()?.toLowerCase() || '';
        if (!isSupportedAttachmentExtension(ext)) {
            reply
                .code(400)
                .send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Thumbnail is not supported for this file type'));
            return;
        }
        await serveThumbnail(reply, validated.resolvedPath, ext);
    });
    // Stream file content via tail -f (SSE endpoint)
    app.get('/api/sessions/:id/tail-file', async (req, reply) => {
        const { id } = req.params;
        const { path: filePath, lines } = req.query;
        const session = findSessionOrFail(ctx, id);
        if (!filePath) {
            reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
            return;
        }
        // Set up SSE headers
        reply.raw.writeHead(200, {
            'Content-Type': 'text/event-stream',
            'Cache-Control': 'no-cache',
            Connection: 'keep-alive',
            'X-Accel-Buffering': 'no',
        });
        // Track stream for cleanup
        const streamRef = {};
        // Create the file stream
        const result = await fileStreamManager.createStream({
            sessionId: id,
            filePath,
            workingDir: session.workingDir,
            lines: lines ? parseInt(lines, 10) : undefined,
            onData: (data) => {
                // Send data as SSE event
                reply.raw.write(`data: ${JSON.stringify({ type: 'data', content: data })}\n\n`);
            },
            onEnd: () => {
                reply.raw.write(`data: ${JSON.stringify({ type: 'end' })}\n\n`);
                reply.raw.end();
            },
            onError: (error) => {
                reply.raw.write(`data: ${JSON.stringify({ type: 'error', error })}\n\n`);
            },
        });
        if (!result.success) {
            reply.raw.write(`data: ${JSON.stringify({ type: 'error', error: result.error })}\n\n`);
            reply.raw.end();
            return;
        }
        streamRef.id = result.streamId;
        // Notify client of successful connection
        reply.raw.write(`data: ${JSON.stringify({ type: 'connected', streamId: result.streamId, filePath })}\n\n`);
        // Handle client disconnect
        req.raw.on('close', () => {
            if (streamRef.id) {
                fileStreamManager.closeStream(streamRef.id);
            }
        });
    });
    // Close a file stream. Returns { closed } rather than { success: closed } —
    // a top-level `success` key would collide with the envelope discriminator
    // (the preSerialization hook would pass `{success:false}` through as a
    // malformed error envelope instead of wrapping it).
    app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => {
        const { id, streamId } = req.params;
        findSessionOrFail(ctx, id); // Validates session exists
        const closed = fileStreamManager.closeStream(streamId);
        return { closed };
    });
    // Session-scoped file download.
    // Uses the same realpath-based workspace boundary as file preview/raw routes;
    // the shared sensitive-path blocklist (../sensitive-path.js, also used by the
    // attachment guard) remains defense-in-depth, not the primary boundary.
    app.get('/api/download', async (req, reply) => {
        const { path: filePath, sessionId } = req.query;
        if (!filePath) {
            reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
            return;
        }
        if (!sessionId) {
            reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing sessionId parameter'));
            return;
        }
        const session = findSessionOrFail(ctx, sessionId);
        const validated = validateSessionFilePath(session.workingDir, filePath);
        if (!validated) {
            reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
            return;
        }
        const { resolvedPath } = validated;
        // Check sensitive path blocklist
        if (isSensitivePath(resolvedPath)) {
            reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
            return;
        }
        try {
            const stat = await fs.stat(resolvedPath);
            if (!stat.isFile()) {
                reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is not a file'));
                return;
            }
            // 50MB size limit
            const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024;
            if (stat.size > MAX_DOWNLOAD_SIZE) {
                reply
                    .code(400)
                    .send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > 50MB limit)`));
                return;
            }
            const ext = filePath.split('.').pop()?.toLowerCase() || '';
            const mimeTypes = {
                png: 'image/png',
                jpg: 'image/jpeg',
                jpeg: 'image/jpeg',
                gif: 'image/gif',
                webp: 'image/webp',
                svg: 'image/svg+xml',
                pdf: 'application/pdf',
                json: 'application/json',
                txt: 'text/plain',
                md: 'text/markdown',
                csv: 'text/csv',
                xml: 'application/xml',
                zip: 'application/zip',
                gz: 'application/gzip',
                tar: 'application/x-tar',
            };
            const filename = pathBasename(resolvedPath);
            const content = await fs.readFile(resolvedPath);
            // Bypass Fastify compression — write directly to raw response
            reply.raw.writeHead(200, {
                'Content-Type': mimeTypes[ext] || 'application/octet-stream',
                'Content-Disposition': `attachment; filename="${filename}"`,
                'Content-Length': content.length,
            });
            reply.raw.end(content);
            return;
        }
        catch (err) {
            reply
                .code(500)
                .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
        }
    });
}
//# sourceMappingURL=file-routes.js.map