import { readFile, stat } from 'node:fs/promises';
import net from 'node:net';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { basename, isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import multipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static';
import websocket from '@fastify/websocket';
import Fastify, {} from 'fastify';
import { lookup as lookupMimeType } from 'mime-types';
import WebSocket from 'ws';
import { isPathInsideDirectory, persistThreadAttachmentStream, readAttachmentMetadata, resolveProjectAttachmentsDir } from './attachment-store.js';
import { CodoriError } from './errors.js';
import { listGitBranches } from './git.js';
import { LocalFileViewError, readProjectLocalFile } from './local-file-viewer.js';
import { createRuntimeManager } from './process-manager.js';
import { createServiceUpdateController } from './service-update.js';
const isCodoriError = (error) => error instanceof CodoriError;
const resolveBundledClientDir = () => {
    const candidates = [
        fileURLToPath(new URL('../client-dist', import.meta.url)),
        fileURLToPath(new URL('../../client/.output/public', import.meta.url))
    ];
    for (const candidate of candidates) {
        if (existsSync(join(candidate, 'index.html'))) {
            return candidate;
        }
    }
    return null;
};
const toRequestPath = (url) => url.split('?')[0]?.split('#')[0] ?? url;
const isAssetRequest = (pathname) => /\.[a-z0-9]+$/i.test(pathname);
const MAX_ATTACHMENTS_PER_MESSAGE = 8;
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
const toStatusCode = (error) => {
    switch (error.code) {
        case 'PROJECT_NOT_FOUND':
            return 404;
        case 'INVALID_CONFIG':
        case 'MISSING_PROJECT_ID':
        case 'MISSING_THREAD_ID':
        case 'INVALID_ATTACHMENT':
        case 'MISSING_ROOT':
            return 400;
        case 'SERVICE_UPDATE_UNAVAILABLE':
        case 'SERVICE_UPDATE_IN_PROGRESS':
            return 409;
        default:
            return 500;
    }
};
const getProjectIdFromRequest = (value) => {
    if (!value) {
        throw new CodoriError('MISSING_PROJECT_ID', 'Missing project id.');
    }
    return value;
};
const resolveMentionAssetRoots = (projectPath) => [
    resolve(projectPath, '.agents/plugins'),
    resolve(projectPath, '.codex/plugins'),
    resolve(homedir(), '.codex/plugins')
];
const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[a-z]:[\\/]/i;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
const isAbsoluteFilesystemPath = (value) => isAbsolute(value)
    || WINDOWS_ABSOLUTE_PATH_PATTERN.test(value)
    || WINDOWS_UNC_PATH_PATTERN.test(value);
const resolveValue = async (value) => value;
const isStatusCodeCarrier = (error) => typeof error === 'object'
    && error !== null
    && 'statusCode' in error
    && typeof error.statusCode === 'number';
const normalizeImageMediaType = (input) => {
    const declared = input.declaredMediaType?.trim().toLowerCase() ?? null;
    if (declared?.startsWith('image/')) {
        return declared;
    }
    if (declared) {
        return null;
    }
    const inferred = lookupMimeType(input.filename);
    if (typeof inferred === 'string' && inferred.toLowerCase().startsWith('image/')) {
        return inferred.toLowerCase();
    }
    return null;
};
const touchProjectActivity = async (manager, projectId) => {
    if (!manager.noteProjectActivity) {
        return;
    }
    await resolveValue(manager.noteProjectActivity(projectId));
};
const touchProjectActivityInBackground = (manager, projectId, session) => {
    const task = session?.touchActivity
        ? resolveValue(session.touchActivity())
        : touchProjectActivity(manager, projectId);
    void task.catch(() => { });
};
const wait = async (ms) => new Promise((resolvePromise) => {
    setTimeout(resolvePromise, ms);
});
const canConnectToPort = (port, host = '127.0.0.1', timeoutMs = 200) => new Promise((resolvePromise) => {
    const socket = net.createConnection({ host, port });
    let settled = false;
    const settle = (value) => {
        if (settled) {
            return;
        }
        settled = true;
        socket.removeAllListeners();
        socket.destroy();
        resolvePromise(value);
    };
    socket.once('connect', () => settle(true));
    socket.once('error', () => settle(false));
    socket.setTimeout(timeoutMs, () => settle(false));
});
const waitForPortReady = async (port, host = '127.0.0.1', timeoutMs = 5_000) => {
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
        if (await canConnectToPort(port, host)) {
            return true;
        }
        await wait(100);
    }
    return false;
};
export const createHttpServer = async (manager, options = {}) => {
    const app = Fastify({
        logger: false
    });
    app.addHook('onClose', async () => {
        await resolveValue(manager.dispose?.());
    });
    const clientBundleDir = options.clientBundleDir === undefined
        ? resolveBundledClientDir()
        : options.clientBundleDir;
    const serviceUpdateController = options.serviceUpdateController ?? null;
    await app.register(multipart, {
        limits: {
            files: MAX_ATTACHMENTS_PER_MESSAGE,
            fields: 4,
            fileSize: MAX_ATTACHMENT_BYTES
        }
    });
    await app.register(websocket);
    if (clientBundleDir) {
        await app.register(fastifyStatic, {
            root: clientBundleDir
        });
    }
    app.setErrorHandler((error, _request, reply) => {
        if (isCodoriError(error)) {
            reply.status(toStatusCode(error)).send({
                error: {
                    code: error.code,
                    message: error.message,
                    details: error.details ?? null
                }
            });
            return;
        }
        if (isStatusCodeCarrier(error)) {
            reply.status(error.statusCode).send({
                error: {
                    code: error.code ?? 'REQUEST_ERROR',
                    message: error.message ?? 'Request failed.'
                }
            });
            return;
        }
        reply.status(500).send({
            error: {
                code: 'INTERNAL_ERROR',
                message: error instanceof Error ? error.message : 'Unknown error.'
            }
        });
    });
    app.get('/api/projects', async () => ({
        projects: await resolveValue(manager.listProjectStatuses())
    }));
    app.get('/api/service/update', async () => ({
        serviceUpdate: serviceUpdateController
            ? await serviceUpdateController.getStatus()
            : {
                enabled: false,
                updateAvailable: false,
                updating: false,
                installedVersion: null,
                latestVersion: null
            }
    }));
    app.post('/api/service/update', async (_request, reply) => {
        if (!serviceUpdateController) {
            throw new CodoriError('SERVICE_UPDATE_UNAVAILABLE', 'Self-update is only available while Codori is running as a registered service.');
        }
        reply.status(202);
        return {
            serviceUpdate: await serviceUpdateController.requestUpdate()
        };
    });
    app.get('/api/projects/:projectId', async (request) => ({
        project: await resolveValue(manager.getProjectStatus(getProjectIdFromRequest(request.params.projectId)))
    }));
    app.get('/api/projects/:projectId/status', async (request) => ({
        project: await resolveValue(manager.getProjectStatus(getProjectIdFromRequest(request.params.projectId)))
    }));
    app.get('/api/projects/:projectId/git/branches', async (request) => {
        const projectId = getProjectIdFromRequest(request.params.projectId);
        const project = await resolveValue(manager.getProjectStatus(projectId));
        await touchProjectActivity(manager, projectId);
        return await listGitBranches(project.projectPath);
    });
    app.post('/api/projects/:projectId/start', async (request) => ({
        project: await resolveValue(manager.startProject(getProjectIdFromRequest(request.params.projectId)))
    }));
    app.post('/api/projects/:projectId/stop', async (request) => ({
        project: await resolveValue(manager.stopProject(getProjectIdFromRequest(request.params.projectId)))
    }));
    app.post('/api/projects/:projectId/attachments', async (request, reply) => {
        const projectId = getProjectIdFromRequest(request.params.projectId);
        const project = await resolveValue(manager.getProjectStatus(projectId));
        await touchProjectActivity(manager, projectId);
        const files = [];
        let threadId = null;
        for await (const part of request.parts()) {
            if (part.type === 'file') {
                if (!threadId) {
                    throw new CodoriError('MISSING_THREAD_ID', 'Thread id must be provided before file parts.');
                }
                const mediaType = normalizeImageMediaType({
                    filename: part.filename ?? 'attachment',
                    declaredMediaType: part.mimetype || null
                });
                if (!mediaType) {
                    throw new CodoriError('INVALID_ATTACHMENT', 'Only image attachments are supported.');
                }
                const attachment = await persistThreadAttachmentStream({
                    projectPath: project.projectPath,
                    threadId,
                    filename: part.filename ?? 'attachment',
                    mediaType,
                    stream: part.file,
                    rootDir: options.attachmentsRootDir
                });
                files.push(attachment);
                continue;
            }
            if (part.fieldname === 'threadId' && typeof part.value === 'string') {
                threadId = part.value.trim() || null;
            }
        }
        if (!threadId) {
            throw new CodoriError('MISSING_THREAD_ID', 'Missing thread id.');
        }
        if (!files.length) {
            throw new CodoriError('INVALID_ATTACHMENT', 'No files provided.');
        }
        reply.header('cache-control', 'no-store');
        return {
            threadId,
            files
        };
    });
    app.get('/api/projects/:projectId/attachments/file', async (request, reply) => {
        const projectId = getProjectIdFromRequest(request.params.projectId);
        const requestedPath = typeof request.query.path === 'string'
            ? request.query.path.trim()
            : '';
        if (!requestedPath) {
            throw new CodoriError('INVALID_ATTACHMENT', 'Missing attachment path.');
        }
        const project = await resolveValue(manager.getProjectStatus(projectId));
        await touchProjectActivity(manager, projectId);
        const allowedRoot = resolveProjectAttachmentsDir(project.projectPath, options.attachmentsRootDir);
        const resolvedPath = resolve(requestedPath);
        if (!isPathInsideDirectory(resolvedPath, allowedRoot)) {
            reply.status(403);
            return {
                error: {
                    code: 'FORBIDDEN',
                    message: 'Invalid attachment path.'
                }
            };
        }
        let fileStat;
        try {
            fileStat = await stat(resolvedPath);
        }
        catch {
            reply.status(404);
            return {
                error: {
                    code: 'NOT_FOUND',
                    message: 'Attachment not found.'
                }
            };
        }
        if (!fileStat.isFile()) {
            reply.status(404);
            return {
                error: {
                    code: 'NOT_FOUND',
                    message: 'Attachment not found.'
                }
            };
        }
        const attachmentMetadata = await readAttachmentMetadata(resolvedPath);
        const inferredMediaType = typeof lookupMimeType(resolvedPath) === 'string'
            ? String(lookupMimeType(resolvedPath)).toLowerCase()
            : null;
        const mediaType = attachmentMetadata?.mediaType?.toLowerCase()
            ?? inferredMediaType
            ?? null;
        if (!mediaType?.startsWith('image/')) {
            reply.status(415);
            return {
                error: {
                    code: 'UNSUPPORTED_MEDIA_TYPE',
                    message: 'Attachment preview is only available for image files.'
                }
            };
        }
        reply.header('cache-control', 'private, max-age=3600');
        reply.header('cross-origin-resource-policy', 'cross-origin');
        reply.header('content-type', mediaType);
        reply.header('content-disposition', `inline; filename="${basename(resolvedPath).replace(/"/g, '')}"`);
        return await readFile(resolvedPath);
    });
    app.get('/api/projects/:projectId/mentions/icon', async (request, reply) => {
        const projectId = getProjectIdFromRequest(request.params.projectId);
        const requestedPath = typeof request.query.path === 'string'
            ? request.query.path.trim()
            : '';
        if (!requestedPath) {
            throw new CodoriError('INVALID_ATTACHMENT', 'Missing mention asset path.');
        }
        if (!isAbsoluteFilesystemPath(requestedPath)) {
            reply.status(400);
            return {
                error: {
                    code: 'INVALID_ATTACHMENT',
                    message: 'Mention asset path must be absolute.'
                }
            };
        }
        const project = await resolveValue(manager.getProjectStatus(projectId));
        await touchProjectActivity(manager, projectId);
        const resolvedPath = resolve(requestedPath);
        if (!resolveMentionAssetRoots(project.projectPath).some(root => isPathInsideDirectory(resolvedPath, root))) {
            reply.status(403);
            return {
                error: {
                    code: 'FORBIDDEN',
                    message: 'Invalid mention asset path.'
                }
            };
        }
        let fileStat;
        try {
            fileStat = await stat(resolvedPath);
        }
        catch {
            reply.status(404);
            return {
                error: {
                    code: 'NOT_FOUND',
                    message: 'Mention asset not found.'
                }
            };
        }
        if (!fileStat.isFile()) {
            reply.status(404);
            return {
                error: {
                    code: 'NOT_FOUND',
                    message: 'Mention asset not found.'
                }
            };
        }
        const mediaType = typeof lookupMimeType(resolvedPath) === 'string'
            ? String(lookupMimeType(resolvedPath)).toLowerCase()
            : null;
        if (!mediaType?.startsWith('image/')) {
            reply.status(415);
            return {
                error: {
                    code: 'UNSUPPORTED_MEDIA_TYPE',
                    message: 'Mention assets must be image files.'
                }
            };
        }
        reply.header('cache-control', 'public, max-age=300');
        reply.header('cross-origin-resource-policy', 'cross-origin');
        reply.header('content-type', mediaType);
        reply.header('content-disposition', `inline; filename="${basename(resolvedPath).replace(/"/g, '')}"`);
        return await readFile(resolvedPath);
    });
    app.get('/api/projects/:projectId/local-file', async (request, reply) => {
        const projectId = getProjectIdFromRequest(request.params.projectId);
        const requestedPath = typeof request.query.path === 'string'
            ? request.query.path.trim()
            : '';
        if (!requestedPath) {
            reply.status(400);
            return {
                error: {
                    code: 'INVALID_LOCAL_FILE',
                    message: 'Missing local file path.'
                }
            };
        }
        const project = await resolveValue(manager.getProjectStatus(projectId));
        await touchProjectActivity(manager, projectId);
        try {
            const file = await readProjectLocalFile(project.projectPath, requestedPath);
            reply.header('cache-control', 'no-store');
            return { file };
        }
        catch (error) {
            if (error instanceof LocalFileViewError) {
                const statusCode = error.code === 'FORBIDDEN'
                    ? 403
                    : error.code === 'NOT_FOUND' || error.code === 'NOT_A_FILE'
                        ? 404
                        : 415;
                reply.status(statusCode);
                return {
                    error: {
                        code: error.code,
                        message: error.message
                    }
                };
            }
            throw error;
        }
    });
    app.get('/api/projects/:projectId/rpc', { websocket: true }, async (clientSocket, request) => {
        const projectId = getProjectIdFromRequest(request.params.projectId);
        const pendingClientMessages = [];
        const session = manager.acquireProjectSession?.(projectId) ?? null;
        let upstream = null;
        let sessionReleased = false;
        const releaseSession = () => {
            if (sessionReleased) {
                return;
            }
            sessionReleased = true;
            session?.release();
        };
        const closeBoth = (code = 1011, reason = 'proxy error') => {
            if (clientSocket.readyState === clientSocket.OPEN || clientSocket.readyState === clientSocket.CONNECTING) {
                clientSocket.close(code, reason);
            }
            if (upstream && (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING)) {
                upstream.close(code, reason);
            }
        };
        clientSocket.on('message', (message, isBinary) => {
            touchProjectActivityInBackground(manager, projectId, session);
            if (upstream?.readyState === WebSocket.OPEN) {
                upstream.send(message, { binary: isBinary });
                return;
            }
            pendingClientMessages.push({ message, isBinary });
        });
        clientSocket.on('error', () => {
            closeBoth(1011, 'client websocket failed');
        });
        clientSocket.on('close', () => {
            releaseSession();
            if (upstream && (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING)) {
                upstream.close();
            }
        });
        void (async () => {
            const started = await resolveValue(manager.startProject(projectId));
            if (typeof started.port !== 'number') {
                closeBoth(1011, 'runtime port unavailable');
                return;
            }
            const ready = await waitForPortReady(started.port);
            if (!ready) {
                closeBoth(1011, 'runtime did not become ready');
                return;
            }
            upstream = new WebSocket(`ws://127.0.0.1:${started.port}`);
            upstream.once('open', () => {
                for (const entry of pendingClientMessages.splice(0, pendingClientMessages.length)) {
                    upstream?.send(entry.message, { binary: entry.isBinary });
                }
            });
            upstream.on('message', (message, isBinary) => {
                touchProjectActivityInBackground(manager, projectId, session);
                clientSocket.send(message, { binary: isBinary });
            });
            upstream.on('error', () => {
                closeBoth(1011, 'upstream websocket failed');
            });
            upstream.on('close', () => {
                if (clientSocket.readyState === clientSocket.OPEN || clientSocket.readyState === clientSocket.CONNECTING) {
                    clientSocket.close();
                }
            });
        })().catch(() => {
            closeBoth(1011, 'upstream bootstrap failed');
        });
    });
    if (clientBundleDir) {
        app.setNotFoundHandler((request, reply) => {
            const requestPath = toRequestPath(request.url);
            const acceptsHtml = request.headers.accept?.includes('text/html') ?? false;
            if (request.method !== 'GET') {
                return reply.status(404).send({
                    error: {
                        code: 'NOT_FOUND',
                        message: 'Route not found.'
                    }
                });
            }
            if (requestPath.startsWith('/api/')) {
                return reply.status(404).send({
                    error: {
                        code: 'NOT_FOUND',
                        message: 'Route not found.'
                    }
                });
            }
            if (isAssetRequest(requestPath) && !acceptsHtml) {
                return reply.status(404).send({
                    error: {
                        code: 'NOT_FOUND',
                        message: 'Asset not found.'
                    }
                });
            }
            return reply.type('text/html').sendFile('index.html');
        });
    }
    return app;
};
export const startHttpServer = async (manager = createRuntimeManager()) => {
    if (!manager.config) {
        throw new CodoriError('INVALID_CONFIG', 'Manager config is required to start the HTTP server.');
    }
    const app = await createHttpServer(manager, {
        serviceUpdateController: createServiceUpdateController({
            root: manager.config.root
        })
    });
    await app.listen({
        host: manager.config.server.host,
        port: manager.config.server.port
    });
    return app;
};
