/**
 * Asset upload and serving API routes.
 *
 * POST /api/repos/:repo/entities/:type/:id/assets — multipart image upload
 * GET  /api/assets/*                               — serve local assets
 */
import * as access from '../../services/access-control.js';
import { listRepoAssets } from '../../services/asset-listing.js';
import { existsSync, createReadStream, statSync } from 'fs';
import { join, extname } from 'path';
const MIME_TYPES = {
    '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
    '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
    '.ico': 'image/x-icon', '.avif': 'image/avif',
};
function hasRepoAccess(req, repoName, workspaceManager, authService) {
    const user = req.user;
    return access.hasRepoAccess(user, repoName, workspaceManager, authService);
}
export async function registerAssetApiRoutes(fastify, workspaceManager, authService) {
    // POST /api/repos/:repo/entities/:type/:id/assets — upload an image
    fastify.post('/api/repos/:repo/entities/:type/:id/assets', async (req, reply) => {
        const { repo, type, id } = req.params;
        if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
            return reply.status(404).send({ error: `Folder "${repo}" not found` });
        }
        let graph;
        try {
            graph = workspaceManager.getGraph(repo);
        }
        catch (err) {
            return reply.status(404).send({ error: err.message });
        }
        const file = await req.file();
        if (!file)
            return reply.status(400).send({ error: 'No file uploaded' });
        if (!file.mimetype.startsWith('image/')) {
            return reply.status(400).send({ error: 'Only image files are supported' });
        }
        // Block SVG: stored XSS — SVG can contain <script> and the GET route
        // serves it inline as image/svg+xml, executing in the app origin.
        if (file.mimetype === 'image/svg+xml' || file.filename?.toLowerCase().endsWith('.svg')) {
            return reply.status(415).send({ error: 'SVG uploads are not supported (stored XSS risk).' });
        }
        const buffer = await file.toBuffer();
        const filename = `${Date.now()}-${file.filename.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
        try {
            const asset = await graph.uploadAsset(type, id, filename, buffer);
            // uploadAsset returns the canonical public URL (immutable-key scheme
            // when the index is wired, legacy otherwise) — don't re-derive it.
            return reply.send({ url: asset.publicUrl, filename: asset.filename, size: asset.size });
        }
        catch (err) {
            if (err.message?.includes('not configured')) {
                return reply.status(501).send({ error: 'Image uploads are not available. Configure R2 or local storage.' });
            }
            return reply.status(400).send({ error: err.message });
        }
    });
    // GET / HEAD /api/assets/:assetId/:filename — IMMUTABLE-KEY serve route.
    //
    // New scheme: assets are keyed `{assetId}/{filename}` (assetId = UUIDv4) and
    // ownership/authz lives in the AssetIndexService, not in the URL. Two path
    // segments after /api/assets/ vs three for the legacy route below — Fastify
    // disambiguates by arity, so both coexist during the migration cutover.
    //
    // Authz: index.get(assetId) → repo → hasRepoAccess. Fail-closed on a miss.
    // The filename in the URL MUST match the indexed filename, otherwise a valid
    // assetId would authorize arbitrary keys under that UUID directory.
    fastify.route({
        method: ['GET', 'HEAD'],
        url: '/api/assets/:assetId/:filename',
        handler: async (req, reply) => {
            const { assetId, filename } = req.params;
            const isHead = req.method === 'HEAD';
            const row = workspaceManager.getAssetIndex().get(assetId);
            // Fail-closed: unknown id, or a filename that doesn't match the indexed
            // one, both 404 (never leak existence; never authorize a sibling key).
            if (!row || row.filename !== filename) {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            if (!hasRepoAccess(req, row.repo, workspaceManager, authService)) {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            let graph;
            try {
                graph = workspaceManager.getGraph(row.repo);
            }
            catch {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            if (graph.getAssetStorage() === 'r2') {
                try {
                    const signed = await graph.getAssetSignedUrlByAssetId(assetId, filename, isHead ? 'HEAD' : 'GET');
                    reply.header('Cache-Control', 'private, max-age=14400');
                    return reply.redirect(signed, 302);
                }
                catch (err) {
                    req.log?.warn?.({ err, assetId, filename }, 'asset r2 signed-url generation failed');
                    return reply.status(404).send({ error: 'Asset not found' });
                }
            }
            // Local backend: stream from .studiograph/assets/{assetId}/{filename}.
            // Defense-in-depth segment validation (Fastify already blocks `/`).
            if (filename.includes('..') || filename.includes('/') || filename.includes('\\') || filename.startsWith('.') ||
                assetId.includes('..') || assetId.includes('/') || assetId.includes('\\')) {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            const localBase = workspaceManager.workspacePath ?? process.cwd();
            const filePath = join(localBase, '.studiograph', 'assets', assetId, filename);
            if (!existsSync(filePath)) {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            const ext = extname(filename).toLowerCase();
            reply.header('Content-Type', MIME_TYPES[ext] || 'application/octet-stream');
            reply.header('Content-Length', statSync(filePath).size);
            reply.header('Cache-Control', 'public, max-age=31536000, immutable');
            if (isHead)
                return reply.send();
            return reply.send(createReadStream(filePath));
        },
    });
    // GET / HEAD /api/assets/:type/:id/:filename — LEGACY serve route (kept for
    // the migration cutover window; removed once legacy-hit logs drain to 0).
    //
    // Per-repo authz: the asset storage layout is workspace-global
    // (`.studiograph/assets/{type}/{id}/{filename}` for local;
    // `{type}/{id}/{filename}` keys for R2) — the path doesn't carry repo
    // information. To enforce that a user with access to folder A can't
    // read assets owned by folder B, we resolve the owning repo from the
    // entity ID via workspaceManager.findEntityById (the same primitive
    // the agent's create_entity uses for cross-repo dedup checks), then
    // run hasRepoAccess against that repo. Both miss and forbid return 404
    // — never leak entity IDs as an existence oracle.
    //
    // Stage 2 (this commit): the route is now the single chokepoint for
    // both backends. Local files stream directly. R2-backed assets get a
    // 302 redirect to a short-lived pre-signed URL, so the underlying
    // bucket can be flipped to private without breaking the workflow.
    // Both GET and HEAD are registered — link unfurlers, OpenGraph
    // crawlers, and CDN healthchecks issue HEAD against this URL and
    // expect the same 302 (signed for HEAD) or local headers.
    fastify.route({
        method: ['GET', 'HEAD'],
        url: '/api/assets/:type/:id/:filename',
        handler: async (req, reply) => {
            const { type, id, filename } = req.params;
            const isHead = req.method === 'HEAD';
            // Legacy-scheme hit. Monitored during cutover: once this drains to 0
            // (all bodies migrated to /api/assets/{assetId}/...), this route is
            // removed. Tagged so it's greppable in logs / metrics.
            req.log?.info?.({ type, id, filename }, 'asset legacy-scheme route hit');
            // Look up by (type, id) — entity IDs are unique within (repo, type)
            // but NOT across the whole workspace. A case-study in `portfolio`
            // and a reference in `analogous-experiences` can share the same id.
            // Type-blind findEntityById returns whichever repo iterates first
            // and 404s any URL whose type doesn't match that arbitrary winner.
            const found = workspaceManager.findEntityByTypeAndId(type, id);
            if (!found) {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            if (!hasRepoAccess(req, found.repoName, workspaceManager, authService)) {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            let graph;
            try {
                graph = workspaceManager.getGraph(found.repoName);
            }
            catch {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            // R2 backend: 302 to a short-lived pre-signed URL. Cache the 302
            // for the lifetime of the signed URL (4 hours) — this is the
            // load-bearing perf optimization. Signed URLs are unique per
            // request (each carries its own X-Amz-Signature), so without
            // caching the redirect, every <img> tag fires a fresh proxy
            // roundtrip AND a fresh R2 download (the signed URL is different
            // each time, so the browser's image cache miss-matches and
            // re-downloads bytes it already has). Caching the 302 means
            // subsequent requests in the same 4-hour window resolve to the
            // SAME signed URL → cached image bytes → zero network. Single
            // line, multi-second page-load improvement.
            //
            // Trade-off: a signed URL cached in the browser keeps working
            // for up to 4 hours after the user loses folder access. Same
            // threat model the 4-hour signed-URL TTL already accepted —
            // fresh URLs lived 4 hours, now cached URLs do too. `private`
            // keeps shared caches (CDNs, corporate proxies) from holding
            // the 302; only the originating browser does.
            //
            // Pre-signed URLs are method-bound, so HEAD requests need a
            // HEAD-signed URL.
            if (graph.getAssetStorage() === 'r2') {
                try {
                    const signed = await graph.getAssetSignedUrl(type, id, filename, undefined, undefined, isHead ? 'HEAD' : 'GET');
                    reply.header('Cache-Control', 'private, max-age=14400');
                    return reply.redirect(signed, 302);
                }
                catch (err) {
                    // Surface SDK / network failures so credential rotation issues,
                    // R2 outages, etc. aren't silently presented to users as
                    // 'Asset not found'. The user-facing response stays 404 so
                    // entity IDs aren't leaked as an existence oracle.
                    req.log?.warn?.({ err, type, id, filename }, 'asset r2 signed-url generation failed');
                    return reply.status(404).send({ error: 'Asset not found' });
                }
            }
            // Local backend: stream the file directly. Defense-in-depth — the
            // R2 branch already validates filename inside AssetService, so
            // hold the local branch to the same standard. Fastify's segment
            // matcher blocks `/` in path params, but a literal `..` or hidden
            // dotfile would otherwise be passed through to `join` here.
            if (filename.includes('..') || filename.includes('/') || filename.includes('\\') || filename.startsWith('.') ||
                id.includes('..') || id.includes('/') || id.includes('\\') ||
                type.includes('..') || type.includes('/') || type.includes('\\')) {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            const workspacePath = workspaceManager.workspacePath ?? process.cwd();
            const filePath = join(workspacePath, '.studiograph', 'assets', type, id, filename);
            if (!existsSync(filePath)) {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            const ext = extname(filename).toLowerCase();
            const contentType = MIME_TYPES[ext] || 'application/octet-stream';
            const stats = statSync(filePath);
            reply.header('Content-Type', contentType);
            reply.header('Content-Length', stats.size);
            reply.header('Cache-Control', 'public, max-age=31536000, immutable');
            // HEAD: send headers without a body. Fastify auto-strips body on
            // HEAD, but being explicit keeps the file handle from opening.
            if (isHead)
                return reply.send();
            return reply.send(createReadStream(filePath));
        },
    });
    // GET /api/repos/:repo/assets — list all assets in a folder. The listing
    // logic (backend walk + immutable-index merge + name resolution) lives in
    // listRepoAssets() so the MCP `list_assets` tool returns an identical shape.
    fastify.get('/api/repos/:repo/assets', async (req, reply) => {
        const { repo } = req.params;
        if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
            return reply.status(404).send({ error: `Folder "${repo}" not found` });
        }
        // Verify the repo exists before delegating; listRepoAssets swallows
        // missing-graph errors as [], which is right for the bulk endpoint but
        // wrong for the single-repo one (we want a 404).
        try {
            workspaceManager.getGraph(repo);
        }
        catch (err) {
            return reply.status(404).send({ error: err.message });
        }
        return { assets: await listRepoAssets(repo, workspaceManager) };
    });
    // GET /api/workspace/assets — bulk asset lists for ALL accessible repos in
    // one round-trip. Replaces 15+ parallel /api/repos/:repo/assets calls so
    // GalleryView's thumbnail effect doesn't saturate the browser HTTP/1.1
    // 6-slot queue and block other navigation fetches.
    // Response shape: `{ [repoName]: RepoAsset[] }`.
    fastify.get('/api/workspace/assets', async (req, reply) => {
        const user = req.user;
        // Use the shared access-control helper so personal-folder restrictions
        // and workspace-owner-on-shared-folder semantics apply consistently.
        const accessible = access.getAccessibleRepos(user, workspaceManager, authService).map(r => r.name);
        const result = {};
        // Run in parallel — each repo is an independent listAll() call.
        await Promise.all(accessible.map(async (repo) => {
            result[repo] = await listRepoAssets(repo, workspaceManager);
        }));
        return reply.send(result);
    });
    // DELETE /api/repos/:repo/entities/:type/:id/assets/:filename — delete an asset
    fastify.delete('/api/repos/:repo/entities/:type/:id/assets/:filename', async (req, reply) => {
        const { repo, type, id, filename } = req.params;
        if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
            return reply.status(404).send({ error: `Folder "${repo}" not found` });
        }
        let graph;
        try {
            graph = workspaceManager.getGraph(repo);
        }
        catch (err) {
            return reply.status(404).send({ error: err.message });
        }
        try {
            await graph.deleteAsset(type, id, filename);
            return { success: true };
        }
        catch (err) {
            return reply.status(404).send({ error: err.message });
        }
    });
}
//# sourceMappingURL=asset-api.js.map