import { randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { mkdir, open, rename, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { builtInNotificationAssets } from '../../shared/notificationAssets.js';
import { requireAllowedOrigin } from './authRoutes.js';
import { authenticateTerminalRequest } from './terminalRoutes.js';
const notificationAssetMaxBytes = 5 * 1024 * 1024;
const allowedContentTypes = new Map([
    ['video/mp4', '.mp4'],
    ['video/webm', '.webm']
]);
class NotificationAssetError extends Error {
    statusCode;
    code;
    constructor(statusCode, code) {
        super(code);
        this.statusCode = statusCode;
        this.code = code;
    }
}
export async function registerNotificationAssetRoutes(app, config, services) {
    app.get('/api/notification-assets', async (request, reply) => {
        const auth = await authenticateTerminalRequest(services.authService, request, reply);
        if (!auth)
            return reply;
        const state = await services.fileStore.read();
        return notificationAssetListResponse(state.notificationAssets, state.notificationAssetDisabledIds);
    });
    app.post('/api/notification-assets', { preHandler: (request, reply) => requireAllowedOrigin(config, request, reply) }, async (request, reply) => {
        const auth = await authenticateTerminalRequest(services.authService, request, reply);
        if (!auth)
            return reply;
        if (!request.isMultipart()) {
            return reply.code(400).send({ error: 'multipart_required' });
        }
        const part = await request.file({
            limits: {
                files: 1,
                fileSize: notificationAssetMaxBytes + 1,
                parts: 1
            }
        });
        if (!part) {
            return reply.code(400).send({ error: 'notification_asset_required' });
        }
        const contentType = part.mimetype;
        if (!isAllowedContentType(contentType)) {
            await drain(part.file);
            return reply.code(415).send({ error: 'unsupported_notification_asset_type' });
        }
        const root = notificationAssetRoot(config);
        const id = randomUUID();
        const storedFilename = `${id}${allowedContentTypes.get(contentType)}`;
        const tempPath = path.join(root, `${storedFilename}.${process.pid}.tmp`);
        const finalPath = path.join(root, storedFilename);
        let size = 0;
        try {
            size = await writeLimitedFile(part.file, tempPath, notificationAssetMaxBytes);
            if (size === 0) {
                await rm(tempPath, { force: true });
                return reply.code(400).send({ error: 'empty_notification_asset' });
            }
            await rename(tempPath, finalPath);
        }
        catch (error) {
            await rm(tempPath, { force: true });
            if (error instanceof NotificationAssetError) {
                return reply.code(error.statusCode).send({ error: error.code });
            }
            throw error;
        }
        const asset = {
            id,
            filename: sanitizeFilename(part.filename, contentType),
            storedFilename,
            contentType,
            size,
            createdAt: new Date().toISOString()
        };
        await services.fileStore.update((state) => ({ ...state, notificationAssets: [...state.notificationAssets, asset] }), { flush: 'immediate' });
        return reply.code(201).send({ asset: toSummary(asset, isNotificationAssetEnabled(asset.id, new Set())) });
    });
    app.put('/api/notification-assets/selection', { preHandler: (request, reply) => requireAllowedOrigin(config, request, reply) }, async (request, reply) => {
        const auth = await authenticateTerminalRequest(services.authService, request, reply);
        if (!auth)
            return reply;
        const enabledAssetIds = Array.isArray(request.body?.enabledAssetIds) ? request.body.enabledAssetIds : null;
        if (!enabledAssetIds) {
            return reply.code(400).send({ error: 'invalid_notification_asset_selection' });
        }
        const uniqueEnabledAssetIds = Array.from(new Set(enabledAssetIds.filter((assetId) => typeof assetId === 'string')));
        if (uniqueEnabledAssetIds.length === 0) {
            return reply.code(400).send({ error: 'notification_asset_selection_required' });
        }
        let nextState;
        try {
            nextState = await services.fileStore.update((state) => {
                const knownAssetIds = notificationAssetIds(state.notificationAssets);
                const unknownAssetId = uniqueEnabledAssetIds.find((assetId) => !knownAssetIds.has(assetId));
                if (unknownAssetId) {
                    throw new NotificationAssetError(400, 'unknown_notification_asset');
                }
                const enabledSet = new Set(uniqueEnabledAssetIds);
                return {
                    ...state,
                    notificationAssetDisabledIds: Array.from(knownAssetIds).filter((assetId) => !enabledSet.has(assetId))
                };
            }, { flush: 'immediate' });
        }
        catch (error) {
            if (error instanceof NotificationAssetError) {
                return reply.code(error.statusCode).send({ error: error.code });
            }
            throw error;
        }
        return notificationAssetListResponse(nextState.notificationAssets, nextState.notificationAssetDisabledIds);
    });
    app.delete('/api/notification-assets/:id', { preHandler: (request, reply) => requireAllowedOrigin(config, request, reply) }, async (request, reply) => {
        const auth = await authenticateTerminalRequest(services.authService, request, reply);
        if (!auth)
            return reply;
        const currentState = await services.fileStore.read();
        const removed = currentState.notificationAssets.find((asset) => asset.id === request.params.id);
        if (!removed) {
            return reply.code(404).send({ error: 'notification_asset_not_found' });
        }
        await services.fileStore.update((state) => ({
            ...state,
            notificationAssets: state.notificationAssets.filter((asset) => asset.id !== request.params.id),
            notificationAssetDisabledIds: state.notificationAssetDisabledIds.filter((assetId) => assetId !== request.params.id)
        }), { flush: 'immediate' });
        await rm(path.join(notificationAssetRoot(config), removed.storedFilename), { force: true });
        return reply.code(204).send();
    });
    app.get('/api/notification-assets/:id/content', async (request, reply) => {
        const auth = await authenticateTerminalRequest(services.authService, request, reply);
        if (!auth)
            return reply;
        const state = await services.fileStore.read();
        const asset = state.notificationAssets.find((candidate) => candidate.id === request.params.id);
        if (!asset) {
            return reply.code(404).send({ error: 'notification_asset_not_found' });
        }
        return sendAssetContent(request, reply, config, asset);
    });
}
function notificationAssetListResponse(assets, disabledIds) {
    const disabledSet = new Set(disabledIds);
    return {
        assets: [
            ...builtInNotificationAssets.map((asset) => ({
                ...asset,
                source: 'built-in',
                enabled: isNotificationAssetEnabled(asset.id, disabledSet)
            })),
            ...assets.map((asset) => toSummary(asset, isNotificationAssetEnabled(asset.id, disabledSet)))
        ],
        maxFileBytes: notificationAssetMaxBytes
    };
}
function toSummary(asset, enabled) {
    return {
        id: asset.id,
        filename: asset.filename,
        source: 'uploaded',
        enabled,
        contentType: asset.contentType,
        size: asset.size,
        createdAt: asset.createdAt,
        url: `/api/notification-assets/${encodeURIComponent(asset.id)}/content`
    };
}
function notificationAssetIds(assets) {
    return new Set([...builtInNotificationAssets.map((asset) => asset.id), ...assets.map((asset) => asset.id)]);
}
function isNotificationAssetEnabled(assetId, disabledIds) {
    return !disabledIds.has(assetId);
}
function notificationAssetRoot(config) {
    return path.join(path.dirname(config.statePath), 'notification-assets');
}
function isAllowedContentType(value) {
    return value === 'video/mp4' || value === 'video/webm';
}
function sanitizeFilename(filename, contentType) {
    const fallback = `notification${allowedContentTypes.get(contentType)}`;
    const base = path.basename(filename || fallback).replace(/[\u0000-\u001f\u007f]/g, '').trim();
    return (base || fallback).slice(0, 160);
}
async function writeLimitedFile(stream, filePath, maxBytes) {
    await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
    const handle = await open(filePath, 'wx', 0o600);
    let bytes = 0;
    let tooLarge = false;
    try {
        for await (const chunk of stream) {
            const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
            bytes += buffer.length;
            if (bytes > maxBytes) {
                tooLarge = true;
                continue;
            }
            await handle.write(buffer);
        }
    }
    finally {
        await handle.close();
    }
    if (tooLarge) {
        throw new NotificationAssetError(413, 'notification_asset_too_large');
    }
    return bytes;
}
async function sendAssetContent(request, reply, config, asset) {
    const filePath = path.join(notificationAssetRoot(config), asset.storedFilename);
    let fileStat;
    try {
        fileStat = await stat(filePath);
    }
    catch (error) {
        if (error.code === 'ENOENT') {
            return reply.code(404).send({ error: 'notification_asset_file_not_found' });
        }
        throw error;
    }
    reply.type(asset.contentType);
    reply.header('Cache-Control', 'no-store');
    reply.header('Accept-Ranges', 'bytes');
    const range = parseRangeHeader(request.headers.range, fileStat.size);
    if (range === 'invalid') {
        reply.header('Content-Range', `bytes */${fileStat.size}`);
        return reply.code(416).send();
    }
    if (range) {
        reply.header('Content-Range', `bytes ${range.start}-${range.end}/${fileStat.size}`);
        reply.header('Content-Length', String(range.end - range.start + 1));
        return reply.code(206).send(createReadStream(filePath, range));
    }
    reply.header('Content-Length', String(fileStat.size));
    return reply.send(createReadStream(filePath));
}
function parseRangeHeader(value, size) {
    if (!value) {
        return null;
    }
    const match = /^bytes=(\d*)-(\d*)$/.exec(value.trim());
    if (!match) {
        return 'invalid';
    }
    const [, startText, endText] = match;
    if (!startText && !endText) {
        return 'invalid';
    }
    if (!startText) {
        const suffixLength = Number(endText);
        if (!Number.isInteger(suffixLength) || suffixLength <= 0) {
            return 'invalid';
        }
        return {
            start: Math.max(size - suffixLength, 0),
            end: Math.max(size - 1, 0)
        };
    }
    const start = Number(startText);
    const end = endText ? Number(endText) : size - 1;
    if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= size) {
        return 'invalid';
    }
    return {
        start,
        end: Math.min(end, size - 1)
    };
}
async function drain(stream) {
    for await (const _chunk of stream) {
        // Consume invalid uploads so the multipart parser can finish cleanly.
    }
}
//# sourceMappingURL=notificationAssetRoutes.js.map