import { ArtifactRegressionError } from './artifact-stub-guard.js';
export function registerProjectRoutes(app, ctx) {
    const { db, design } = ctx;
    const { sendApiError, createSseResponse } = ctx.http;
    const { PROJECTS_DIR } = ctx.paths;
    const { insertProject, validateLinkedDirs, getProject, updateProject, dbDeleteProject, removeProjectDir } = ctx.projectStore;
    const { writeProjectFile, readProjectFile, ensureProject, listFiles, listTabs, setTabs, resolveProjectDir } = ctx.projectFiles;
    const { insertConversation, getConversation, listConversations, updateConversation, deleteConversation, listMessages, upsertMessage, listPreviewComments, upsertPreviewComment, updatePreviewCommentStatus, deletePreviewComment } = ctx.conversations;
    const { getTemplate, listTemplates, deleteTemplate, insertTemplate, findTemplateByNameAndProject, updateTemplate } = ctx.templates;
    const { listLatestProjectRunStatuses, listProjectsAwaitingInput, normalizeProjectDisplayStatus, composeProjectDisplayStatus, listProjects } = ctx.status;
    const { subscribeFileEvents, activeProjectEventSinks } = ctx.events;
    const { randomId } = ctx.ids;
    app.get('/api/projects', (_req, res) => {
        try {
            const latestRunStatuses = listLatestProjectRunStatuses(db);
            const awaitingInputProjects = listProjectsAwaitingInput(db);
            const activeRunStatuses = new Map();
            for (const run of design.runs.list()) {
                if (!run.projectId)
                    continue;
                const runStatus = projectStatusFromRun(run);
                if (design.runs.isTerminal(run.status)) {
                    const existing = latestRunStatuses.get(run.projectId);
                    if (!existing || run.updatedAt > (existing.updatedAt ?? 0)) {
                        latestRunStatuses.set(run.projectId, runStatus);
                    }
                }
                else {
                    const existing = activeRunStatuses.get(run.projectId);
                    if (!existing || run.updatedAt > (existing.updatedAt ?? 0)) {
                        activeRunStatuses.set(run.projectId, runStatus);
                    }
                }
            }
            /** @type {import('@open-design/contracts').ProjectsResponse} */
            const body = {
                projects: listProjects(db).map((project) => ({
                    ...project,
                    status: composeProjectDisplayStatus(activeRunStatuses.get(project.id) ??
                        latestRunStatuses.get(project.id) ?? { value: 'not_started' }, awaitingInputProjects, project.id),
                })),
            };
            res.json(body);
        }
        catch (err) {
            sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
        }
    });
    function projectStatusFromRun(run) {
        return {
            value: normalizeProjectDisplayStatus(run.status),
            updatedAt: run.updatedAt,
            runId: run.id,
        };
    }
    app.post('/api/projects', async (req, res) => {
        try {
            const { id, name, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } = req.body || {};
            if (typeof id !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(id)) {
                return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
            }
            if (typeof name !== 'string' || !name.trim()) {
                return sendApiError(res, 400, 'BAD_REQUEST', 'name required');
            }
            // baseDir is privileged: it lets a project root directly inside the
            // user's filesystem. The /api/import/folder endpoint is the only
            // path that's allowed to set it, because that's where realpath() +
            // RUNTIME_DATA_DIR reentry checks live. Block client-supplied
            // metadata.baseDir on this generic create endpoint so an attacker
            // can't smuggle e.g. /etc through here. Same rule for
            // originalBaseDir / importedFrom='folder' — only the import path
            // owns those state fields.
            if (metadata && typeof metadata === 'object') {
                if ('baseDir' in metadata) {
                    return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir can only be set via POST /api/import/folder');
                }
                if ('fromTrustedPicker' in metadata) {
                    return sendApiError(res, 400, 'BAD_REQUEST', 'fromTrustedPicker can only be set via POST /api/import/folder');
                }
            }
            if (customInstructions !== undefined
                && typeof customInstructions !== 'string'
                && customInstructions !== null) {
                return sendApiError(res, 400, 'BAD_REQUEST', 'customInstructions must be a string or null');
            }
            if (typeof customInstructions === 'string' && customInstructions.length > 5000) {
                return sendApiError(res, 400, 'BAD_REQUEST', 'customInstructions exceeds 5 000 character limit');
            }
            if (skipDiscoveryBrief !== undefined && typeof skipDiscoveryBrief !== 'boolean') {
                return sendApiError(res, 400, 'BAD_REQUEST', 'skipDiscoveryBrief must be a boolean');
            }
            const projectMetadata = metadata && typeof metadata === 'object'
                ? {
                    ...metadata,
                    ...(skipDiscoveryBrief === true ? { skipDiscoveryBrief: true } : {}),
                    ...(Array.isArray(metadata.linkedDirs)
                        ? (() => {
                            const v = validateLinkedDirs(metadata.linkedDirs);
                            return v.error ? {} : { linkedDirs: v.dirs };
                        })()
                        : {}),
                }
                : skipDiscoveryBrief === true
                    ? { skipDiscoveryBrief: true }
                    : null;
            const now = Date.now();
            const project = insertProject(db, {
                id,
                name: name.trim(),
                skillId: skillId ?? null,
                designSystemId: designSystemId ?? null,
                pendingPrompt: pendingPrompt || null,
                metadata: projectMetadata,
                customInstructions: typeof customInstructions === 'string'
                    ? customInstructions
                    : null,
                createdAt: now,
                updatedAt: now,
            });
            // Seed a default conversation so the UI always has somewhere to write.
            const cid = randomId();
            insertConversation(db, {
                id: cid,
                projectId: id,
                title: null,
                createdAt: now,
                updatedAt: now,
            });
            // For "from template" projects, seed the chosen template's snapshot
            // HTML into the new project folder so the agent can Read/edit files
            // on disk (the system prompt also embeds them, but a real on-disk
            // copy lets the agent treat them as the project's working state).
            if (metadata &&
                typeof metadata === 'object' &&
                metadata.kind === 'template' &&
                typeof metadata.templateId === 'string') {
                const tpl = getTemplate(db, metadata.templateId);
                if (tpl && Array.isArray(tpl.files) && tpl.files.length > 0) {
                    await ensureProject(PROJECTS_DIR, id);
                    for (const f of tpl.files) {
                        if (!f ||
                            typeof f.name !== 'string' ||
                            typeof f.content !== 'string') {
                            continue;
                        }
                        try {
                            await writeProjectFile(PROJECTS_DIR, id, f.name, Buffer.from(f.content, 'utf8'));
                        }
                        catch {
                            // Skip individual file failures — the template snapshot is
                            // best-effort; the agent still has the embedded copy.
                        }
                    }
                }
            }
            /** @type {import('@open-design/contracts').CreateProjectResponse} */
            const body = { project, conversationId: cid };
            res.json(body);
        }
        catch (err) {
            sendApiError(res, 400, 'BAD_REQUEST', String(err));
        }
    });
    app.get('/api/projects/:id', (req, res) => {
        const project = getProject(db, req.params.id);
        if (!project)
            return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
        const resolvedDir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
        /** @type {import('@open-design/contracts').ProjectResponse} */
        const body = { project, resolvedDir };
        res.json(body);
    });
    app.patch('/api/projects/:id', (req, res) => {
        try {
            const patch = req.body || {};
            // baseDir / folder-import state is privileged: it's set only by the
            // import endpoint and otherwise immutable. Two failure modes to
            // guard against here:
            //   1. Explicit attempt to change baseDir → reject with 400.
            //   2. A regular metadata patch that *omits* baseDir (e.g. a UI
            //      that only edits linkedDirs sends `{ metadata: { kind, linkedDirs } }`).
            //      updateProject() replaces metadata wholesale, so without
            //      preservation the existing baseDir gets wiped and the project
            //      detaches from the user's folder — subsequent reads/writes
            //      silently fall back to .od/projects/<id>.
            // For case 2 we re-stamp the immutable fields from the existing
            // project record onto the incoming patch so the user can keep
            // patching other metadata without ever losing their import root.
            if (patch.metadata && typeof patch.metadata === 'object') {
                const existing = getProject(db, req.params.id);
                const existingMeta = existing?.metadata;
                if ('fromTrustedPicker' in patch.metadata
                    && patch.metadata.fromTrustedPicker !== existingMeta?.fromTrustedPicker) {
                    return sendApiError(res, 400, 'BAD_REQUEST', 'fromTrustedPicker can only be set via POST /api/import/folder');
                }
                if (existingMeta?.baseDir) {
                    if ('baseDir' in patch.metadata && patch.metadata.baseDir !== existingMeta.baseDir) {
                        return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir is immutable after import; use a new import to change it');
                    }
                    patch.metadata = {
                        ...patch.metadata,
                        baseDir: existingMeta.baseDir,
                        ...(existingMeta.importedFrom === 'folder'
                            ? { importedFrom: 'folder' }
                            : {}),
                        ...(existingMeta.fromTrustedPicker === true
                            ? { fromTrustedPicker: true }
                            : {}),
                    };
                }
                else if ('baseDir' in patch.metadata) {
                    // Non-imported project trying to acquire a baseDir → reject (only
                    // /api/import/folder can set it).
                    return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir can only be set via POST /api/import/folder');
                }
            }
            if (patch.metadata?.linkedDirs) {
                const existing = getProject(db, req.params.id);
                const validated = validateLinkedDirs(patch.metadata.linkedDirs);
                if (validated.error) {
                    return sendApiError(res, 400, 'INVALID_LINKED_DIR', validated.error);
                }
                patch.metadata.linkedDirs =
                    existing?.metadata?.fromTrustedPicker === true
                        ? patch.metadata.linkedDirs
                        : validated.dirs;
            }
            if (patch.customInstructions !== undefined
                && typeof patch.customInstructions !== 'string'
                && patch.customInstructions !== null) {
                return sendApiError(res, 400, 'BAD_REQUEST', 'customInstructions must be a string or null');
            }
            if (typeof patch.customInstructions === 'string' && patch.customInstructions.length > 5000) {
                return sendApiError(res, 400, 'BAD_REQUEST', 'customInstructions exceeds 5 000 character limit');
            }
            const project = updateProject(db, req.params.id, patch);
            if (!project)
                return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
            /** @type {import('@open-design/contracts').ProjectResponse} */
            const body = { project };
            res.json(body);
        }
        catch (err) {
            sendApiError(res, 400, 'BAD_REQUEST', String(err));
        }
    });
    app.delete('/api/projects/:id', async (req, res) => {
        try {
            dbDeleteProject(db, req.params.id);
            await removeProjectDir(PROJECTS_DIR, req.params.id).catch(() => { });
            /** @type {import('@open-design/contracts').OkResponse} */
            const body = { ok: true };
            res.json(body);
        }
        catch (err) {
            sendApiError(res, 400, 'BAD_REQUEST', String(err));
        }
    });
    // SSE stream of file-changed events for a project. Drives preview live-reload.
    // Receipt of a `file-changed` event triggers a file-list refresh, which
    // propagates new mtimes through to FileViewer iframes (the URL-load
    // `?v=${mtime}` cache-bust from PR #384 then reloads the iframe automatically).
    // Subscribers come and go as users open/close project tabs; the underlying
    // chokidar watcher is refcounted in project-watchers.ts so we never hold
    // descriptors for projects no UI is looking at.
    app.get('/api/projects/:id/events', (req, res) => {
        if (!getProject(db, req.params.id)) {
            return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
        }
        let sub;
        try {
            const sse = createSseResponse(res);
            const projectEventSink = (payload) => {
                sse.send(payload.type, payload);
            };
            let sinks = activeProjectEventSinks.get(req.params.id);
            if (!sinks) {
                sinks = new Set();
                activeProjectEventSinks.set(req.params.id, sinks);
            }
            sinks.add(projectEventSink);
            const watchProject = getProject(db, req.params.id);
            sub = subscribeFileEvents(PROJECTS_DIR, req.params.id, (evt) => {
                sse.send('file-changed', evt);
            }, { metadata: watchProject?.metadata });
            sub.ready.then(() => sse.send('ready', { projectId: req.params.id })).catch(() => { });
            const cleanup = () => {
                if (sub) {
                    const { unsubscribe } = sub;
                    sub = null;
                    Promise.resolve(unsubscribe()).catch(() => { });
                }
                const currentSinks = activeProjectEventSinks.get(req.params.id);
                currentSinks?.delete(projectEventSink);
                if (currentSinks?.size === 0)
                    activeProjectEventSinks.delete(req.params.id);
            };
            res.on('close', cleanup);
            res.on('finish', cleanup);
        }
        catch (err) {
            if (sub)
                Promise.resolve(sub.unsubscribe()).catch(() => { });
            if (!res.headersSent)
                sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err));
        }
    });
    // ---- Conversations --------------------------------------------------------
    app.get('/api/projects/:id/conversations', (req, res) => {
        if (!getProject(db, req.params.id)) {
            return res.status(404).json({ error: 'project not found' });
        }
        res.json({ conversations: listConversations(db, req.params.id) });
    });
    app.post('/api/projects/:id/conversations', (req, res) => {
        if (!getProject(db, req.params.id)) {
            return res.status(404).json({ error: 'project not found' });
        }
        const { title } = req.body || {};
        const now = Date.now();
        const conv = insertConversation(db, {
            id: randomId(),
            projectId: req.params.id,
            title: typeof title === 'string' ? title.trim() || null : null,
            createdAt: now,
            updatedAt: now,
        });
        res.json({ conversation: conv });
    });
    app.patch('/api/projects/:id/conversations/:cid', (req, res) => {
        const conv = getConversation(db, req.params.cid);
        if (!conv || conv.projectId !== req.params.id) {
            return res.status(404).json({ error: 'not found' });
        }
        const updated = updateConversation(db, req.params.cid, req.body || {});
        res.json({ conversation: updated });
    });
    app.delete('/api/projects/:id/conversations/:cid', (req, res) => {
        const conv = getConversation(db, req.params.cid);
        if (!conv || conv.projectId !== req.params.id) {
            return res.status(404).json({ error: 'not found' });
        }
        deleteConversation(db, req.params.cid);
        res.json({ ok: true });
    });
    // ---- Messages -------------------------------------------------------------
    app.get('/api/projects/:id/conversations/:cid/messages', (req, res) => {
        const conv = getConversation(db, req.params.cid);
        if (!conv || conv.projectId !== req.params.id) {
            return res.status(404).json({ error: 'conversation not found' });
        }
        res.json({ messages: listMessages(db, req.params.cid) });
    });
    app.put('/api/projects/:id/conversations/:cid/messages/:mid', (req, res) => {
        const conv = getConversation(db, req.params.cid);
        if (!conv || conv.projectId !== req.params.id) {
            return res.status(404).json({ error: 'conversation not found' });
        }
        const m = req.body || {};
        if (m.id && m.id !== req.params.mid) {
            return res.status(400).json({ error: 'id mismatch' });
        }
        const saved = upsertMessage(db, req.params.cid, {
            ...m,
            id: req.params.mid,
        });
        // Bump the parent project's updatedAt so the project list re-orders.
        updateProject(db, req.params.id, {});
        ctx.telemetry?.reportFinalizedMessage(saved, m);
        res.json({ message: saved });
    });
    // ---- Preview comments ----------------------------------------------------
    app.get('/api/projects/:id/conversations/:cid/comments', (req, res) => {
        const conv = getConversation(db, req.params.cid);
        if (!conv || conv.projectId !== req.params.id) {
            return res.status(404).json({ error: 'conversation not found' });
        }
        res.json({
            comments: listPreviewComments(db, req.params.id, req.params.cid),
        });
    });
    app.post('/api/projects/:id/conversations/:cid/comments', (req, res) => {
        const conv = getConversation(db, req.params.cid);
        if (!conv || conv.projectId !== req.params.id) {
            return res.status(404).json({ error: 'conversation not found' });
        }
        try {
            const comment = upsertPreviewComment(db, req.params.id, req.params.cid, req.body || {});
            updateProject(db, req.params.id, {});
            res.json({ comment });
        }
        catch (err) {
            res.status(400).json({ error: String(err?.message || err) });
        }
    });
    app.patch('/api/projects/:id/conversations/:cid/comments/:commentId', (req, res) => {
        const conv = getConversation(db, req.params.cid);
        if (!conv || conv.projectId !== req.params.id) {
            return res.status(404).json({ error: 'conversation not found' });
        }
        try {
            const comment = updatePreviewCommentStatus(db, req.params.id, req.params.cid, req.params.commentId, req.body?.status);
            if (!comment)
                return res.status(404).json({ error: 'comment not found' });
            updateProject(db, req.params.id, {});
            res.json({ comment });
        }
        catch (err) {
            res.status(400).json({ error: String(err?.message || err) });
        }
    });
    app.delete('/api/projects/:id/conversations/:cid/comments/:commentId', (req, res) => {
        const conv = getConversation(db, req.params.cid);
        if (!conv || conv.projectId !== req.params.id) {
            return res.status(404).json({ error: 'conversation not found' });
        }
        const ok = deletePreviewComment(db, req.params.id, req.params.cid, req.params.commentId);
        if (!ok)
            return res.status(404).json({ error: 'comment not found' });
        updateProject(db, req.params.id, {});
        res.json({ ok: true });
    });
    // ---- Tabs -----------------------------------------------------------------
    app.get('/api/projects/:id/tabs', (req, res) => {
        if (!getProject(db, req.params.id)) {
            return res.status(404).json({ error: 'project not found' });
        }
        res.json(listTabs(db, req.params.id));
    });
    app.put('/api/projects/:id/tabs', (req, res) => {
        if (!getProject(db, req.params.id)) {
            return res.status(404).json({ error: 'project not found' });
        }
        const { tabs = [], active = null } = req.body || {};
        if (!Array.isArray(tabs) || !tabs.every((t) => typeof t === 'string')) {
            return res.status(400).json({ error: 'tabs must be string[]' });
        }
        const result = setTabs(db, req.params.id, tabs, typeof active === 'string' ? active : null);
        res.json(result);
    });
    // ---- Templates ----------------------------------------------------------
    // User-saved snapshots of a project's HTML files. Surfaced in the
    // "From template" tab of the new-project panel so a user can spin up
    // a fresh project pre-seeded with another project's design as a
    // starting point. Created via the project's Share menu (snapshots
    // every .html file in the project folder at the moment of save).
    app.get('/api/templates', (_req, res) => {
        res.json({ templates: listTemplates(db) });
    });
    app.get('/api/templates/:id', (req, res) => {
        const t = getTemplate(db, req.params.id);
        if (!t)
            return res.status(404).json({ error: 'not found' });
        res.json({ template: t });
    });
    app.post('/api/templates', async (req, res) => {
        try {
            const { name, description, sourceProjectId } = req.body || {};
            if (typeof name !== 'string' || !name.trim()) {
                return res.status(400).json({ error: 'name required' });
            }
            if (name.length > 100) {
                return res.status(400).json({ error: 'name must be 100 characters or fewer' });
            }
            if (typeof sourceProjectId !== 'string') {
                return res.status(400).json({ error: 'sourceProjectId required' });
            }
            const sourceProject = getProject(db, sourceProjectId);
            if (!sourceProject) {
                return res.status(404).json({ error: 'source project not found' });
            }
            // Snapshot every HTML / sketch / text file in the source project.
            // We deliberately skip binary uploads — templates are about the
            // generated design, not the user's reference imagery.
            const files = await listFiles(PROJECTS_DIR, sourceProjectId, {
                metadata: sourceProject.metadata,
            });
            const snapshot = [];
            for (const f of files) {
                if (f.kind !== 'html' && f.kind !== 'text' && f.kind !== 'code')
                    continue;
                const entry = await readProjectFile(PROJECTS_DIR, sourceProjectId, f.name, sourceProject.metadata);
                if (entry && Buffer.isBuffer(entry.buffer)) {
                    snapshot.push({
                        name: f.name,
                        content: entry.buffer.toString('utf8'),
                    });
                }
            }
            const trimmedName = name.trim();
            const descValue = typeof description === 'string' ? description : null;
            const existing = findTemplateByNameAndProject(db, trimmedName, sourceProjectId);
            let t;
            if (existing) {
                t = updateTemplate(db, existing.id, {
                    description: descValue,
                    files: snapshot,
                });
            }
            else {
                t = insertTemplate(db, {
                    id: randomId(),
                    name: trimmedName,
                    description: descValue,
                    sourceProjectId,
                    files: snapshot,
                    createdAt: Date.now(),
                });
            }
            res.json({ template: t });
        }
        catch (err) {
            res.status(400).json({ error: String(err) });
        }
    });
    app.delete('/api/templates/:id', (req, res) => {
        deleteTemplate(db, req.params.id);
        res.json({ ok: true });
    });
}
export function registerProjectArtifactRoutes(app, ctx) {
    const { upload } = ctx.uploads;
    const { ARTIFACTS_DIR } = ctx.paths;
    const { path, fs } = ctx.node;
    const { sanitizeSlug, lintArtifact, renderFindingsForAgent } = ctx.artifacts;
    app.post('/api/upload', upload.array('images', 8), (req, res) => {
        const files = (req.files || []).map((f) => ({
            name: f.originalname,
            path: f.path,
            size: f.size,
        }));
        res.json({ files });
    });
    // Persist a generated artifact (HTML) to disk so the user can re-open it
    // in their browser or hand it off. Returns the on-disk path + a served URL.
    // The body is also passed through the anti-slop linter; findings are
    // returned alongside the path so the UI can render a P0/P1 badge and the
    // chat layer can splice them into a system reminder for the agent.
    app.post('/api/artifacts/save', (req, res) => {
        try {
            const { identifier, title, html } = req.body || {};
            if (typeof html !== 'string' || html.length === 0) {
                return res.status(400).json({ error: 'html required' });
            }
            const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
            const slug = sanitizeSlug(identifier || title || 'artifact');
            const dir = path.join(ARTIFACTS_DIR, `${stamp}-${slug}`);
            fs.mkdirSync(dir, { recursive: true });
            const file = path.join(dir, 'index.html');
            fs.writeFileSync(file, html, 'utf8');
            const findings = lintArtifact(html);
            res.json({
                path: file,
                url: `/artifacts/${path.basename(dir)}/index.html`,
                lint: findings,
            });
        }
        catch (err) {
            res.status(500).json({ error: String(err) });
        }
    });
    // Standalone lint endpoint — POST raw HTML, get findings back.
    // The chat layer uses this to lint streamed-in artifacts without writing
    // them to disk first, so a P0 issue can be surfaced before save.
    app.post('/api/artifacts/lint', (req, res) => {
        try {
            const { html } = req.body || {};
            if (typeof html !== 'string' || html.length === 0) {
                return res.status(400).json({ error: 'html required' });
            }
            const findings = lintArtifact(html);
            res.json({
                findings,
                agentMessage: renderFindingsForAgent(findings),
            });
        }
        catch (err) {
            res.status(500).json({ error: String(err) });
        }
    });
}
export function registerProjectFileRoutes(app, ctx) {
    const { db } = ctx;
    const { sendApiError, sendMulterError } = ctx.http;
    const { PROJECTS_DIR } = ctx.paths;
    const { upload } = ctx.uploads;
    const { fs } = ctx.node;
    const { getProject } = ctx.projectStore;
    const { listFiles, searchProjectFiles, readProjectFile, resolveProjectFilePath, parseByteRange, renameProjectFile, deleteProjectFile, writeProjectFile, sanitizeName, ensureProject } = ctx.projectFiles;
    const { buildDocumentPreview } = ctx.documents;
    const { validateArtifactManifestInput } = ctx.artifacts;
    // Project files. Each project owns a flat folder under .od/projects/<id>/
    // containing every file the user has uploaded, pasted, sketched, or that
    // the agent has generated. Names are sanitized; paths are confined to the
    // project's own folder (see apps/daemon/src/projects.ts).
    app.get('/api/projects/:id/files', async (req, res) => {
        try {
            const since = Number(req.query?.since);
            const project = getProject(db, req.params.id);
            const files = await listFiles(PROJECTS_DIR, req.params.id, {
                since: Number.isFinite(since) ? since : undefined,
                metadata: project?.metadata,
            });
            /** @type {import('@open-design/contracts').ProjectFilesResponse} */
            const body = { files };
            res.json(body);
        }
        catch (err) {
            sendApiError(res, 400, 'BAD_REQUEST', String(err));
        }
    });
    app.get('/api/projects/:id/search', async (req, res) => {
        try {
            const query = String(req.query.q ?? '');
            if (!query) {
                sendApiError(res, 400, 'BAD_REQUEST', 'q query parameter is required');
                return;
            }
            const pattern = req.query.pattern ? String(req.query.pattern) : null;
            const max = Math.min(Number(req.query.max) || 200, 1000);
            const searchProject = getProject(db, req.params.id);
            const matches = await searchProjectFiles(PROJECTS_DIR, req.params.id, query, {
                pattern,
                max,
                metadata: searchProject?.metadata,
            });
            res.json({ query, matches });
        }
        catch (err) {
            sendApiError(res, 400, 'BAD_REQUEST', String(err));
        }
    });
    // Preflight for the raw file route. Current artifact fetches are simple GETs
    // (no preflight needed), but an explicit handler future-proofs the route if
    // artifacts ever add custom request headers.
    app.options('/api/projects/:id/raw/*', (req, res) => {
        if (req.headers.origin === 'null') {
            res.header('Access-Control-Allow-Origin', '*');
            res.header('Access-Control-Allow-Methods', 'GET');
            res.header('Access-Control-Allow-Headers', 'Content-Type');
        }
        res.sendStatus(204);
    });
    app.get('/api/projects/:id/raw/*', async (req, res) => {
        try {
            const relPath = req.params[0];
            const project = getProject(db, req.params.id);
            // PreviewModal loads artifact HTML via srcdoc, giving the iframe Origin: "null".
            // data: URIs, file://, and some sandboxed iframes also send null — all are
            // local-only callers, so this is safe. Real cross-origin sites send a real
            // origin and remain blocked by the browser's same-origin policy.
            if (req.headers.origin === 'null') {
                res.header('Access-Control-Allow-Origin', '*');
            }
            const meta = await resolveProjectFilePath(PROJECTS_DIR, req.params.id, relPath, project?.metadata);
            if (meta.mime.startsWith('video/') || meta.mime.startsWith('audio/')) {
                res.setHeader('Accept-Ranges', 'bytes');
                res.setHeader('Content-Type', meta.mime);
                if (meta.size === 0) {
                    res.setHeader('Content-Length', '0');
                    return res.status(200).end();
                }
                const range = parseByteRange(req.headers.range, meta.size);
                if (range === 'unsatisfiable') {
                    res.setHeader('Content-Range', `bytes */${meta.size}`);
                    return res.status(416).end();
                }
                let start;
                let end;
                let statusCode;
                if (range) {
                    ({ start, end } = range);
                    statusCode = 206;
                    res.setHeader('Content-Range', `bytes ${start}-${end}/${meta.size}`);
                    res.setHeader('Content-Length', String(end - start + 1));
                }
                else {
                    start = 0;
                    end = meta.size - 1;
                    statusCode = 200;
                    res.setHeader('Content-Length', String(meta.size));
                }
                res.status(statusCode);
                const stream = fs.createReadStream(meta.filePath, { start, end });
                stream.on('error', (streamErr) => {
                    if (!res.headersSent) {
                        sendApiError(res, 500, 'STREAM_ERROR', String(streamErr));
                    }
                    else {
                        res.destroy(streamErr);
                    }
                });
                stream.pipe(res);
                return;
            }
            const file = await readProjectFile(PROJECTS_DIR, req.params.id, relPath, project?.metadata);
            res.type(file.mime).send(file.buffer);
        }
        catch (err) {
            const status = err && err.code === 'ENOENT' ? 404 : 400;
            sendApiError(res, status, status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST', String(err));
        }
    });
    app.delete('/api/projects/:id/raw/*', async (req, res) => {
        try {
            const project = getProject(db, req.params.id);
            await deleteProjectFile(PROJECTS_DIR, req.params.id, req.params[0], project?.metadata);
            /** @type {import('@open-design/contracts').DeleteProjectFileResponse} */
            const body = { ok: true };
            res.json(body);
        }
        catch (err) {
            const status = err && err.code === 'ENOENT' ? 404 : 400;
            sendApiError(res, status, status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST', String(err));
        }
    });
    app.get('/api/projects/:id/files/:name/preview', async (req, res) => {
        try {
            const project = getProject(db, req.params.id);
            const file = await readProjectFile(PROJECTS_DIR, req.params.id, req.params.name, project?.metadata);
            const preview = await buildDocumentPreview(file);
            res.json(preview);
        }
        catch (err) {
            const status = err && err.statusCode
                ? err.statusCode
                : err && err.code === 'ENOENT'
                    ? 404
                    : 400;
            sendApiError(res, status, status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST', err?.message || 'preview unavailable');
        }
    });
    app.get('/api/projects/:id/files/*', async (req, res) => {
        try {
            const project = getProject(db, req.params.id);
            const file = await readProjectFile(PROJECTS_DIR, req.params.id, req.params[0], project?.metadata);
            res.type(file.mime).send(file.buffer);
        }
        catch (err) {
            const status = err && err.code === 'ENOENT' ? 404 : 400;
            sendApiError(res, status, status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST', String(err));
        }
    });
    // Two ways to upload: multipart for binary files (images), and JSON
    // {name, content, encoding} for sketches and pasted text. The frontend
    // uses both depending on the file source.
    app.post('/api/projects/:id/files', (req, res, next) => {
        upload.single('file')(req, res, (err) => {
            if (err)
                return sendMulterError(res, err);
            next();
        });
    }, async (req, res) => {
        try {
            const uploadProject = getProject(db, req.params.id);
            await ensureProject(PROJECTS_DIR, req.params.id, uploadProject?.metadata);
            if (req.file) {
                const buf = await fs.promises.readFile(req.file.path);
                const desiredName = sanitizeName(req.body?.name || req.file.originalname);
                const meta = await writeProjectFile(PROJECTS_DIR, req.params.id, desiredName, buf, {}, uploadProject?.metadata);
                fs.promises.unlink(req.file.path).catch(() => { });
                /** @type {import('@open-design/contracts').ProjectFileResponse} */
                const body = { file: meta };
                return res.json(body);
            }
            const { name, content, encoding, artifactManifest } = req.body || {};
            if (typeof name !== 'string' || typeof content !== 'string') {
                return sendApiError(res, 400, 'BAD_REQUEST', 'name and content required');
            }
            if (artifactManifest !== undefined && artifactManifest !== null) {
                const validated = validateArtifactManifestInput(artifactManifest, name);
                if (!validated.ok) {
                    return sendApiError(res, 400, 'BAD_REQUEST', `invalid artifactManifest: ${validated.error}`);
                }
            }
            const buf = encoding === 'base64'
                ? Buffer.from(content, 'base64')
                : Buffer.from(content, 'utf8');
            const meta = await writeProjectFile(PROJECTS_DIR, req.params.id, name, buf, { artifactManifest }, uploadProject?.metadata);
            /** @type {import('@open-design/contracts').ProjectFileResponse} */
            const body = { file: meta };
            res.json(body);
        }
        catch (err) {
            if (err instanceof ArtifactRegressionError) {
                return sendApiError(res, 422, 'ARTIFACT_REGRESSION', err.message, {
                    details: {
                        identifier: err.identifier,
                        newSize: err.newSize,
                        priorSize: err.priorSize,
                        priorName: err.priorName,
                    },
                });
            }
            sendApiError(res, 500, 'INTERNAL_ERROR', 'upload failed');
        }
    });
    app.post('/api/projects/:id/files/rename', async (req, res) => {
        try {
            const { from, to } = req.body || {};
            if (typeof from !== 'string' || typeof to !== 'string') {
                return sendApiError(res, 400, 'BAD_REQUEST', 'from and to required');
            }
            const project = getProject(db, req.params.id);
            const result = await renameProjectFile(PROJECTS_DIR, req.params.id, from, to, project?.metadata);
            /** @type {import('@open-design/contracts').RenameProjectFileResponse} */
            const body = result;
            res.json(body);
        }
        catch (err) {
            if (err?.code === 'EEXIST') {
                return sendApiError(res, 409, 'CONFLICT', String(err?.message || err));
            }
            const message = String(err?.message || err);
            if (err?.code === 'ENOENT' || message.includes('ENOENT') || message.includes('no such file or directory')) {
                return sendApiError(res, 404, 'FILE_NOT_FOUND', message);
            }
            sendApiError(res, 400, 'BAD_REQUEST', message);
        }
    });
    app.delete('/api/projects/:id/files/:name', async (req, res) => {
        try {
            const delProject = getProject(db, req.params.id);
            await deleteProjectFile(PROJECTS_DIR, req.params.id, req.params.name, delProject?.metadata);
            /** @type {import('@open-design/contracts').DeleteProjectFileResponse} */
            const body = { ok: true };
            res.json(body);
        }
        catch (err) {
            const status = err && err.code === 'ENOENT' ? 404 : 400;
            sendApiError(res, status, status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST', String(err));
        }
    });
}
export function registerProjectUploadRoutes(app, ctx) {
    const { sendApiError } = ctx.http;
    const { handleProjectUpload } = ctx.uploads;
    const { fs } = ctx.node;
    app.post('/api/projects/:id/upload', handleProjectUpload, async (req, res) => {
        try {
            const incoming = Array.isArray(req.files) ? req.files : [];
            const out = [];
            for (const f of incoming) {
                try {
                    const stat = await fs.promises.stat(f.path);
                    out.push({
                        name: f.filename,
                        path: f.filename,
                        size: stat.size,
                        mtime: stat.mtimeMs,
                        originalName: f.originalname,
                    });
                }
                catch {
                    // skip files that vanished mid-flight
                }
            }
            /** @type {import('@open-design/contracts').UploadProjectFilesResponse} */
            const body = { files: out };
            res.json(body);
        }
        catch (err) {
            sendApiError(res, 500, 'INTERNAL_ERROR', 'upload failed');
        }
    });
}
//# sourceMappingURL=project-routes.js.map