import { optimizeImage, formatBytes } from '../../media/optimize.js';
import { logEvent } from '../../security/audit.js';
import { validateMimeType, checkMagicBytes } from '../../security/upload.js';
import { sanitizeHtml } from '../../security/sanitize.js';
import { getActuateConfig } from '../../config/runtime.js';
import { guardedDb } from '../guarded-db.js';
import { importBlobStorage, json, errorResponse, internalError, clampPageSize, normalizeMediaItem, buildMediaUsedOn, isAllowedStorageUrl, requireAuth, requireMediaScope, WRITE_ROLES, requireRole, uploadLimiter, checkRateLimitAsync, } from '../route-helpers.js';
export function registerMediaRoutes(router) {
    const db = guardedDb;
    // ---------------------------------------------------------------------------
    // Media routes
    // ---------------------------------------------------------------------------
    router.get('/media', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const url = new URL(request.url);
            const page = Number(url.searchParams.get('page')) || 1;
            const pageSize = clampPageSize(url.searchParams.get('pageSize'));
            const skip = (page - 1) * pageSize;
            const folderParam = url.searchParams.get('folderId');
            const where = {};
            if (folderParam === 'none') {
                where.folderId = null;
            }
            else if (folderParam) {
                where.folderId = folderParam;
            }
            const [items, total] = await Promise.all([
                db().media.findMany({
                    where,
                    skip,
                    take: pageSize,
                    orderBy: { createdAt: 'desc' },
                    include: {
                        mediaUsages: {
                            include: {
                                document: { select: { id: true, collection: true, title: true, deletedAt: true } },
                            },
                        },
                    },
                }),
                db().media.count({ where }),
            ]);
            const normalized = items.map((item) => {
                const { mediaUsages, ...rest } = normalizeMediaItem(item);
                return { ...rest, usedOn: buildMediaUsedOn(mediaUsages) };
            });
            return json({
                data: {
                    data: normalized,
                    items: normalized,
                    total,
                    page,
                    pageSize,
                    totalPages: Math.ceil(total / pageSize),
                },
            });
        }
        catch (err) {
            return internalError(err);
        }
    });
    // The /media/presign endpoint returns advisory upload metadata; the actual
    // upload still goes through /media/upload (which performs validation).
    // Returning a presigned URL pointing at our own non-presigned endpoint was
    // misleading, so we deprecated this in favour of just using /media/upload
    // directly. We keep the route for backward compatibility but it now just
    // returns a hint to use the upload endpoint.
    router.post('/media/presign', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const scopeErr = requireMediaScope(auth.session);
            if (scopeErr)
                return scopeErr;
            const body = (await request.json());
            if (!body.filename || !body.contentType) {
                return errorResponse('filename and contentType are required', 400);
            }
            return json({
                data: {
                    uploadUrl: '/api/cms/media/upload',
                    method: 'POST',
                    field: 'file',
                    deprecated: true,
                    message: 'Direct multipart upload to /media/upload is preferred; this endpoint will be removed in a future release.',
                },
            });
        }
        catch (err) {
            return internalError(err);
        }
    });
    const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
    /**
     * Allowlist of accepted upload mime types. Storing a file outside this set
     * would let an attacker upload e.g. `.html`/`.js` and serve it from the same
     * origin as the admin (cookie-leaking XSS), or `.svg` with embedded
     * `<script>` (also XSS). Add to this list deliberately.
     */
    const ALLOWED_UPLOAD_MIME_TYPES = [
        'image/jpeg',
        'image/png',
        'image/gif',
        'image/webp',
        'image/avif',
        'image/svg+xml',
        'application/pdf',
        'video/mp4',
        'video/webm',
        'audio/mpeg',
        'audio/ogg',
        'audio/wav',
    ];
    router.post('/media/upload', async (request) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const scopeErr = requireMediaScope(auth.session);
            if (scopeErr)
                return scopeErr;
            if (!(await checkRateLimitAsync(uploadLimiter, `upload:${auth.session.userId}`))) {
                return errorResponse('Upload rate limit reached (20/hour). Try again later.', 429);
            }
            // Reject *before* buffering the body. We require a valid content-length
            // header so that chunked / no-length requests (which would otherwise
            // bypass this gate and allow `request.formData()` to buffer unbounded
            // memory) are rejected up front. The multipart envelope is always
            // larger than the underlying file, so checking against MAX_UPLOAD_BYTES
            // here is a safe upper bound — a 50 MB file cannot fit inside a 50 MB
            // request body.
            const contentLengthHeader = request.headers.get('content-length');
            if (!contentLengthHeader) {
                return errorResponse('Content-Length header is required for uploads (chunked encoding is not supported)', 411);
            }
            const contentLength = parseInt(contentLengthHeader, 10);
            if (!Number.isFinite(contentLength) || contentLength <= 0) {
                return errorResponse('Invalid Content-Length header', 400);
            }
            if (contentLength > MAX_UPLOAD_BYTES) {
                return errorResponse('File exceeds maximum size of 50MB', 413);
            }
            const formData = await request.formData();
            const file = formData.get('file');
            const skipOptimize = formData.get('skipOptimize') === 'true';
            if (!file) {
                return errorResponse('No file provided', 400);
            }
            const originalFilename = file.name;
            const contentType = file.type;
            const originalSize = file.size;
            // Belt-and-braces: even with the header check above, re-validate the
            // *actual* file size after parsing in case the multipart envelope
            // disagreed with the header. This must come BEFORE any further
            // processing (magic byte read, SVG sanitization, blob upload).
            if (originalSize > MAX_UPLOAD_BYTES) {
                return errorResponse('File exceeds maximum size of 50MB', 413);
            }
            // 1. Block file types that aren't on our allowlist outright.
            if (!validateMimeType(contentType, ALLOWED_UPLOAD_MIME_TYPES)) {
                return errorResponse(`Unsupported file type "${contentType || 'unknown'}". Allowed types: ${ALLOWED_UPLOAD_MIME_TYPES.join(', ')}`, 415);
            }
            const arrayBuffer = await file.arrayBuffer();
            // 2. Verify the file's actual bytes match the claimed mime type. Without
            // this, an attacker can upload a `.exe` with `Content-Type: image/png`
            // and have it served from our origin.
            const magicCheck = checkMagicBytes(arrayBuffer, contentType);
            if (!magicCheck.valid) {
                return errorResponse(`File contents do not match declared type "${contentType}".`, 415);
            }
            // 3. SVGs need an extra pass — even when the mime type is correct, the
            // XML body can contain `<script>` or event-handler attributes that
            // execute when an admin previews the file. Sanitize before storing.
            let uploadBuffer;
            let finalFilename = originalFilename;
            let finalMimeType = contentType;
            let finalSize = originalSize;
            let width = null;
            let height = null;
            let blurHash = null;
            let savings = 0;
            if (contentType === 'image/svg+xml') {
                // Strip <script>, on*, javascript: URLs, foreignObject, etc.
                const xml = new TextDecoder('utf-8', { fatal: false }).decode(arrayBuffer);
                const sanitized = sanitizeHtml(xml, {
                    allowedTags: [
                        'svg',
                        'g',
                        'path',
                        'circle',
                        'ellipse',
                        'line',
                        'polygon',
                        'polyline',
                        'rect',
                        'text',
                        'tspan',
                        'defs',
                        'use',
                        'symbol',
                        'title',
                        'desc',
                        'style',
                        'linearGradient',
                        'radialGradient',
                        'stop',
                        'mask',
                        'clipPath',
                        'pattern',
                        'filter',
                        'feGaussianBlur',
                        'feColorMatrix',
                        'feOffset',
                        'feBlend',
                        'feFlood',
                        'feComposite',
                        'feMerge',
                        'feMergeNode',
                    ],
                    allowedAttributes: {
                        '*': [
                            'id',
                            'class',
                            'fill',
                            'stroke',
                            'stroke-width',
                            'stroke-linecap',
                            'stroke-linejoin',
                            'opacity',
                            'transform',
                            'd',
                            'cx',
                            'cy',
                            'r',
                            'rx',
                            'ry',
                            'x',
                            'y',
                            'x1',
                            'y1',
                            'x2',
                            'y2',
                            'width',
                            'height',
                            'viewBox',
                            'xmlns',
                            'xmlns:xlink',
                            'preserveAspectRatio',
                            'points',
                            'points',
                            'offset',
                            'stop-color',
                            'stop-opacity',
                            'gradientUnits',
                            'gradientTransform',
                            'href',
                            'xlink:href',
                            'fill-rule',
                            'clip-rule',
                            'mask',
                            'clip-path',
                            'filter',
                            'patternUnits',
                            'patternContentUnits',
                            'in',
                            'in2',
                            'result',
                            'mode',
                            'values',
                            'type',
                            'stdDeviation',
                            'dx',
                            'dy',
                        ],
                    },
                });
                uploadBuffer = Buffer.from(sanitized, 'utf-8');
                finalSize = uploadBuffer.byteLength;
            }
            else if (!skipOptimize && contentType.startsWith('image/')) {
                const result = await optimizeImage(arrayBuffer, originalFilename, contentType);
                uploadBuffer = result.buffer;
                finalFilename = result.filename;
                finalMimeType = result.mimeType;
                finalSize = result.optimizedSize;
                width = result.width || null;
                height = result.height || null;
                blurHash = result.blurHash ?? null;
                savings = result.savings;
            }
            else {
                uploadBuffer = Buffer.from(arrayBuffer);
            }
            const sanitizedName = finalFilename.replace(/[^a-zA-Z0-9._-]/g, '_');
            const storageKey = `actuate/media/${Date.now()}-${sanitizedName}`;
            let publicUrl = '';
            // Prefer the configured platform storage adapter (e.g. platform-vercel,
            // platform-aws, or a consumer-provided one). Falling through to
            // @vercel/blob via dynamic import preserves the legacy behavior for
            // installs that haven't wired up a platform package yet.
            const { getStorageAdapter } = await import('../../storage/index.js');
            const storage = getStorageAdapter();
            if (storage) {
                try {
                    publicUrl = await storage.upload(storageKey, uploadBuffer, finalMimeType);
                }
                catch (err) {
                    if (process.env.NODE_ENV !== 'test') {
                        console.error('[Actuate CMS] Storage adapter upload failed:', err);
                    }
                    // The adapter is registered (so this isn't a config gap) — the upload
                    // itself failed. For Vercel Blob the usual cause is a missing/invalid
                    // BLOB_READ_WRITE_TOKEN: a linked store (BLOB_STORE_ID) does NOT
                    // provision the token via OIDC; it must be added to this environment.
                    return errorResponse('Storage upload failed. If using Vercel Blob, verify BLOB_READ_WRITE_TOKEN is set for this environment (a linked store / BLOB_STORE_ID alone is not enough — Vercel does not provide it via OIDC). Check the server logs for the underlying error, then retry.', 500);
                }
            }
            else {
                // Storage failures must surface as errors, never as a 201. The old
                // behavior swallowed blob errors and stored a `/api/cms/media/file/…`
                // fallback URL — a path nothing serves — producing a media record that
                // looks fine in the library but 404s forever while the bytes were
                // never persisted anywhere.
                try {
                    const blob = await importBlobStorage();
                    const result = await blob.put(storageKey, uploadBuffer, {
                        access: 'public',
                        contentType: finalMimeType,
                    });
                    publicUrl = result.url;
                }
                catch (err) {
                    if (process.env.NODE_ENV !== 'test') {
                        console.error('[Actuate CMS] Vercel Blob upload failed:', err);
                    }
                    // Distinguish the two failure modes so the operator fixes the right
                    // thing: a `platform.storage` shorthand with no registered adapter is
                    // a config gap (the shorthand doesn't wire uploads), not a missing
                    // token.
                    const platformStorage = getActuateConfig()?.platform?.storage;
                    const declaredProvider = platformStorage && typeof platformStorage.upload !== 'function'
                        ? platformStorage.provider
                        : undefined;
                    const message = declaredProvider && declaredProvider !== 'local'
                        ? `Media storage is not available. platform.storage.provider is "${declaredProvider}" but no storage adapter is registered — the storage shorthand does not handle uploads on its own. Register a platform adapter as platform.storage (e.g. vercel().platform.storage from platform-vercel) or setStorageAdapter(), then retry.`
                        : 'Media storage is not available. Register a storage adapter (e.g. platform-vercel via platform.storage) or set BLOB_READ_WRITE_TOKEN for Vercel Blob, then retry the upload.';
                    return errorResponse(message, 500);
                }
            }
            const media = await db().media.create({
                data: {
                    filename: finalFilename,
                    storageKey: publicUrl,
                    mimeType: finalMimeType,
                    fileSize: finalSize,
                    width,
                    height,
                    blurHash,
                    uploadedById: auth.session.userId,
                },
            });
            await logEvent({
                event: 'media_uploaded',
                userId: auth.session.userId,
                details: { mediaId: media.id, filename: finalFilename },
            });
            return json({
                data: {
                    ...media,
                    // Direct URL for immediate client-side previews (the admin reads
                    // `data.url` — e.g. the Settings branding thumbnails).
                    url: publicUrl,
                    optimization: {
                        originalSize,
                        optimizedSize: finalSize,
                        savings,
                        originalFormat: contentType,
                        outputFormat: finalMimeType,
                        originalSizeFormatted: formatBytes(originalSize),
                        optimizedSizeFormatted: formatBytes(finalSize),
                    },
                },
            }, 201);
        }
        catch (err) {
            return internalError(err);
        }
    });
    router.post('/media/:id/optimize', async (request, params) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const media = await db().media.findUnique({ where: { id: params.id } });
            if (!media)
                return errorResponse('Media not found', 404);
            if (!media.mimeType.startsWith('image/')) {
                return errorResponse('Only images can be optimized', 400);
            }
            if (media.mimeType === 'image/webp') {
                return json({
                    data: { ...media, optimization: { alreadyOptimized: true, savings: 0 } },
                });
            }
            if (!isAllowedStorageUrl(media.storageKey)) {
                return errorResponse('Invalid media storage URL', 400);
            }
            let fileBuffer;
            try {
                const response = await fetch(media.storageKey);
                if (!response.ok)
                    throw new Error('Failed to fetch media file');
                fileBuffer = Buffer.from(await response.arrayBuffer());
            }
            catch {
                return errorResponse('Could not retrieve the original file for optimization', 500);
            }
            const result = await optimizeImage(fileBuffer, media.filename, media.mimeType);
            if (result.savings <= 0) {
                return json({
                    data: { ...media, optimization: { alreadyOptimized: true, savings: 0 } },
                });
            }
            // Upload the optimized version. If storing the new variant fails, bail
            // WITHOUT touching the record — the original blob still exists, so the
            // worst case is "not optimized", never a record pointing at a dead URL.
            const sanitizedName = result.filename.replace(/[^a-zA-Z0-9._-]/g, '_');
            const newStorageKey = `actuate/media/${Date.now()}-${sanitizedName}`;
            let newPublicUrl = '';
            try {
                const blob = await importBlobStorage();
                const uploadResult = await blob.put(newStorageKey, result.buffer, {
                    access: 'public',
                    contentType: result.mimeType,
                });
                newPublicUrl = uploadResult.url;
                // Delete the old blob
                try {
                    await blob.del(media.storageKey);
                }
                catch {
                    /* best-effort */
                }
            }
            catch (err) {
                if (process.env.NODE_ENV !== 'test') {
                    console.error('[Actuate CMS] Optimized media upload failed:', err);
                }
                return errorResponse('Could not store the optimized file. The original is unchanged — check media storage configuration and retry.', 500);
            }
            const updated = await db().media.update({
                where: { id: params.id },
                data: {
                    filename: result.filename,
                    storageKey: newPublicUrl,
                    mimeType: result.mimeType,
                    fileSize: result.optimizedSize,
                    width: result.width || undefined,
                    height: result.height || undefined,
                    blurHash: result.blurHash ?? undefined,
                },
            });
            return json({
                data: {
                    ...updated,
                    optimization: {
                        originalSize: result.originalSize,
                        optimizedSize: result.optimizedSize,
                        savings: result.savings,
                        originalFormat: media.mimeType,
                        outputFormat: result.mimeType,
                        originalSizeFormatted: formatBytes(result.originalSize),
                        optimizedSizeFormatted: formatBytes(result.optimizedSize),
                    },
                },
            });
        }
        catch (err) {
            return internalError(err);
        }
    });
    router.put('/media/:id', async (request, params) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const scopeErr = requireMediaScope(auth.session);
            if (scopeErr)
                return scopeErr;
            if (!auth.session.apiKey) {
                const roleErr = requireRole(auth.session.role, WRITE_ROLES);
                if (roleErr)
                    return roleErr;
            }
            const body = (await request.json());
            const updated = await db().media.update({
                where: { id: params.id },
                data: {
                    ...(body.alt !== undefined ? { altText: body.alt } : {}),
                    ...(body.title !== undefined ? { title: body.title } : {}),
                    ...(body.filename !== undefined ? { filename: body.filename } : {}),
                    ...(body.focalX !== undefined ? { focalPointX: body.focalX } : {}),
                    ...(body.focalY !== undefined ? { focalPointY: body.focalY } : {}),
                },
            });
            return json({ data: updated });
        }
        catch (err) {
            return internalError(err);
        }
    });
    router.delete('/media/:id', async (request, params) => {
        try {
            const auth = await requireAuth(request);
            if (auth.error)
                return auth.error;
            const scopeErr = requireMediaScope(auth.session);
            if (scopeErr)
                return scopeErr;
            if (!auth.session.apiKey) {
                const roleErr = requireRole(auth.session.role, WRITE_ROLES);
                if (roleErr)
                    return roleErr;
            }
            const media = await db().media.findUnique({ where: { id: params.id } });
            if (!media) {
                return errorResponse('Media not found', 404);
            }
            try {
                const blob = await importBlobStorage();
                await blob.del(media.storageKey);
            }
            catch {
                // Storage delete failed or not available
            }
            await db().media.delete({ where: { id: params.id } });
            await logEvent({
                event: 'media_deleted',
                userId: auth.session.userId,
                details: { mediaId: params.id, filename: media.filename },
            });
            return json({ data: { success: true } });
        }
        catch (err) {
            return internalError(err);
        }
    });
}
//# sourceMappingURL=media.js.map