import { randomUUID } from 'crypto';
import { VISION_MIME_ALLOWLIST, VISION_MAX_IMAGE_BYTES, DOC_MAX_UPLOAD_BYTES, DOC_PER_FILE_CHARS, } from '../../services/scratch-attachment-service.js';
import { resolveFormat } from '../../core/format-registry.js';
import { extractSidecarText } from '../../services/asset-sidecar-service.js';
import { promoteScratchFileToAssets, PromoteScratchError } from '../../services/scratch-asset-promote.js';
import * as access from '../../services/access-control.js';
import { serveBackendObject } from './asset-api.js';
import { ChatSessionTooLargeError, ChatSessionForbiddenError, } from '../../services/chat-session-service.js';
/** Backend key for a conversation-scratch file (composer-vision E2a, PR3). */
function scratchKey(sessionId, fileId, filename) {
    return `chat-scratch/${sessionId}/${fileId}/${filename}`;
}
/**
 * Classify a composer upload by the format registry + the vision MIME gate
 * (D11). Returns the readable disposition, or null for an unreadable file
 * (opaque binary, heic/avif/svg, video, zip…) which the route rejects — every
 * accepted file must yield pixels (image) or text (doc).
 *
 *   image  → vision (png/jpeg/gif/webp only; heic/avif aren't model-decodable)
 *   doc    → extracted text (pdf/docx/xlsx via extractor) OR raw text (txt/md/csv)
 */
function classifyReadable(filename, mime) {
    const mimeLc = mime.toLowerCase();
    if (VISION_MIME_ALLOWLIST.has(mimeLc))
        return { kind: 'image' };
    const fmt = resolveFormat(filename, mime);
    if (fmt.extractor === 'pdf' || fmt.extractor === 'docx' || fmt.extractor === 'xlsx') {
        return { kind: 'doc', extractor: fmt.extractor };
    }
    if (fmt.category === 'direct-note' || fmt.category === 'direct-dataset') {
        return { kind: 'doc', extractor: null }; // txt/md/csv → raw UTF-8
    }
    return null;
}
/** Extract a doc's text for composer context (D4: extract once, at upload).
 *  pdf/docx/xlsx via the shared extractor; txt/md/csv read raw. Returns the
 *  text truncated to the per-doc storage cap (D8) so the DB can't balloon, or
 *  '' if nothing readable came out. */
async function extractDocText(buffer, extractor, label) {
    let text;
    if (extractor) {
        ({ text } = await extractSidecarText(buffer, extractor, label));
    }
    else {
        text = buffer.toString('utf8');
    }
    text = (text ?? '').trim();
    return text.length > DOC_PER_FILE_CHARS ? text.slice(0, DOC_PER_FILE_CHARS) : text;
}
/**
 * Per-user chat-session persistence. The server is the SOLE store of the agent
 * rail's history (no client localStorage). Sessions are metadata rows; messages
 * are appended as rows so a chat has no size limit and metadata writes
 * (rename/pin via PATCH) are decoupled from message appends.
 */
export async function registerChatSessionsApiRoutes(fastify, chatSessionService, workspaceManager, authService, wsHub, getAgent) {
    function userFrom(req) {
        return req.user ?? null;
    }
    // The scratch blob store is workspace-global; reach it via any repo's graph
    // (every graph shares the one backend). Used for upload/serve/cleanup of
    // conversation-scratch images, which are NOT catalogued assets.
    function anyGraph() {
        for (const cfg of workspaceManager.getAllRepoConfigs()) {
            try {
                const g = workspaceManager.getGraph(cfg.name);
                if (g.getAssetService?.())
                    return g;
            }
            catch { /* try next */ }
        }
        return null;
    }
    // Cascade scratch cleanup onto chat deletion (single or all): delete the blob
    // bytes by exact key AND the agent-pool session JSON (which holds base64 copies
    // of the vision images). Best-effort — a failed external delete must not break
    // the row delete that already committed.
    chatSessionService.setScratchCleanup((userId, sessionId, blobKeys) => {
        if (blobKeys.length > 0) {
            const svc = anyGraph()?.getAssetService?.();
            if (svc)
                for (const key of blobKeys)
                    void svc.deleteByKey(key).catch(() => { });
        }
        const agent = getAgent();
        if (sessionId)
            agent?.deleteChatSessionState(userId, sessionId);
        else
            agent?.deleteAllChatSessionState(userId);
    });
    // ── List the caller's sessions (metadata only, most-recently-updated first) ──
    fastify.get('/api/chat-sessions', async (req, reply) => {
        const user = userFrom(req);
        if (!user)
            return reply.status(401).send({ error: 'Authentication required' });
        return reply.send({ sessions: chatSessionService.listMeta(user.id) });
    });
    // ── Fetch one session WITH its messages (seq order) ──
    fastify.get('/api/chat-sessions/:id', async (req, reply) => {
        const user = userFrom(req);
        if (!user)
            return reply.status(401).send({ error: 'Authentication required' });
        const session = chatSessionService.get(user.id, req.params.id);
        if (!session)
            return reply.status(404).send({ error: 'Session not found' });
        return reply.send({ session });
    });
    // Mutations are client-driven on every turn; a generous per-user rate limit
    // keeps a runaway loop from hammering the DB.
    const mutationRateLimit = { config: { rateLimit: { max: 240, timeWindow: '1 minute' } } };
    // ── Create / upsert session metadata (on first message) ──
    fastify.post('/api/chat-sessions', mutationRateLimit, async (req, reply) => {
        const user = userFrom(req);
        if (!user)
            return reply.status(401).send({ error: 'Authentication required' });
        const body = req.body ?? {};
        if (typeof body.id !== 'string' || !body.id) {
            return reply.status(400).send({ error: 'id is required' });
        }
        try {
            const session = chatSessionService.upsertMeta(user.id, {
                id: body.id,
                title: body.title,
                titleGenerated: body.titleGenerated,
                pinned: body.pinned,
                pinnedAt: body.pinnedAt,
                createdAt: body.createdAt,
                updatedAt: body.updatedAt,
            });
            return reply.send({ session });
        }
        catch (err) {
            if (err instanceof ChatSessionForbiddenError) {
                return reply.status(403).send({ error: 'Session belongs to another user' });
            }
            throw err;
        }
    });
    // ── Update session metadata only (title / pin) — never touches messages ──
    fastify.patch('/api/chat-sessions/:id', mutationRateLimit, async (req, reply) => {
        const user = userFrom(req);
        if (!user)
            return reply.status(401).send({ error: 'Authentication required' });
        const body = req.body ?? {};
        try {
            const session = chatSessionService.upsertMeta(user.id, {
                id: req.params.id,
                title: body.title,
                titleGenerated: body.titleGenerated,
                pinned: body.pinned,
                pinnedAt: body.pinnedAt,
                updatedAt: body.updatedAt,
            });
            return reply.send({ session });
        }
        catch (err) {
            if (err instanceof ChatSessionForbiddenError) {
                return reply.status(403).send({ error: 'Session belongs to another user' });
            }
            throw err;
        }
    });
    // ── Append the turn's messages on completion ──
    fastify.post('/api/chat-sessions/:id/messages', mutationRateLimit, async (req, reply) => {
        const user = userFrom(req);
        if (!user)
            return reply.status(401).send({ error: 'Authentication required' });
        const body = req.body ?? {};
        if (!Array.isArray(body.messages)) {
            return reply.status(400).send({ error: 'messages array is required' });
        }
        const messages = body.messages.map(m => ({
            role: m.role === 'user' ? 'user' : 'agent',
            content: typeof m.content === 'string' ? m.content : '',
            meta: m.meta ?? null,
        }));
        try {
            const result = chatSessionService.appendMessages(user.id, req.params.id, messages, { title: body.title });
            return reply.send(result);
        }
        catch (err) {
            if (err instanceof ChatSessionForbiddenError) {
                return reply.status(403).send({ error: 'Session belongs to another user' });
            }
            if (err instanceof ChatSessionTooLargeError) {
                return reply.status(413).send({ error: err.message });
            }
            throw err;
        }
    });
    // ── Delete a session (cascades its messages) ──
    fastify.delete('/api/chat-sessions/:id', mutationRateLimit, async (req, reply) => {
        const user = userFrom(req);
        if (!user)
            return reply.status(401).send({ error: 'Authentication required' });
        chatSessionService.delete(user.id, req.params.id);
        return reply.send({ ok: true });
    });
    /** Reserve the session for the caller (create-if-missing, reject-if-owned-by-
     *  another). Returns false after replying with 403 if it belongs to someone
     *  else. */
    function reserveSession(reply, user, sessionId) {
        const owner = chatSessionService.getSessionOwner(sessionId);
        if (owner != null && owner !== user.id) {
            reply.status(403).send({ error: 'Session belongs to another user' });
            return false;
        }
        if (owner == null) {
            chatSessionService.upsertMeta(user.id, { id: sessionId, title: 'New chat' });
        }
        return true;
    }
    // ── Upload a composer FILE into conversation-scratch storage (PR3) ──
    // Ephemeral, conversation-scoped (composer-vision-attach / E2a, generalized to
    // any READABLE file): bytes go to the blob backend under their OWN keyspace
    // (NOT a catalogued asset → never in the library), with metadata in
    // chat_scratch_files; purged when the chat is deleted. Images → vision; docs →
    // extracted text (D4: extract once, here). The server mints the fileId.
    //
    // Consistency (D13): extract first (in memory) → reject if a doc yields no
    // text → upload the blob → insert the row → delete the blob if the insert
    // throws (no orphan blob, no half-row).
    fastify.post('/api/chat-sessions/:id/files', mutationRateLimit, async (req, reply) => {
        const user = userFrom(req);
        if (!user)
            return reply.status(401).send({ error: 'Authentication required' });
        const sessionId = req.params.id;
        if (!sessionId)
            return reply.status(400).send({ error: 'Session id required' });
        if (!reserveSession(reply, user, sessionId))
            return;
        const svc = anyGraph()?.getAssetService?.();
        if (!svc) {
            return reply.status(501).send({ error: 'File uploads are not available. Configure R2 or local storage.' });
        }
        const file = await req.file();
        if (!file)
            return reply.status(400).send({ error: 'No file uploaded' });
        const mime = file.mimetype.toLowerCase();
        // A multipart part can omit the filename — default rather than throw on
        // `.replace` of undefined (which would 500 after reserveSession ran).
        const filename = (file.filename ?? '').replace(/[^a-zA-Z0-9._-]/g, '_') || 'file';
        // D11: composer-readable only (vision images + extractable docs). Opaque
        // binaries / video / heic / svg / zip are rejected here, honestly.
        const klass = classifyReadable(filename, mime);
        if (!klass) {
            return reply.status(415).send({ error: "I can't read that file type. Try an image, PDF, Office doc, or text file." });
        }
        const buffer = await file.toBuffer();
        const cap = klass.kind === 'image' ? VISION_MAX_IMAGE_BYTES : DOC_MAX_UPLOAD_BYTES;
        if (buffer.length > cap) {
            const mb = Math.round(cap / (1024 * 1024));
            return reply.status(413).send({ error: `File is too large (max ${mb} MB).` });
        }
        // Docs: extract NOW; reject up front if there's nothing to read (D13 — no
        // silent "failed" chip that violates "every chip is readable").
        let extractedText = null;
        if (klass.kind === 'doc') {
            try {
                extractedText = await extractDocText(buffer, klass.extractor, filename);
            }
            catch {
                extractedText = '';
            }
            if (!extractedText) {
                return reply.status(422).send({ error: "I couldn't read any text from that file." });
            }
        }
        const fileId = randomUUID();
        const key = scratchKey(sessionId, fileId, filename);
        try {
            await svc.uploadByKey(key, buffer, mime);
        }
        catch (err) {
            return reply.status(400).send({ error: err.message });
        }
        try {
            chatSessionService.insertScratchFile({
                sessionId, fileId, userId: user.id, filename, contentType: mime, size: buffer.length,
                blobKey: key, kind: klass.kind,
                extractedText, extractState: klass.kind === 'doc' ? 'ready' : 'none', source: 'upload',
            });
        }
        catch (err) {
            // Compensation (D13): the row didn't land — don't strand the blob.
            void svc.deleteByKey(key).catch(() => { });
            return reply.status(500).send({ error: 'Could not record the upload.' });
        }
        return reply.send({
            kind: 'scratch',
            sessionId,
            fileId,
            filename,
            fileKind: klass.kind,
            url: `/api/chat-sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}`,
        });
    });
    // ── Attach a library DOC as conversation context (PR3, D9) ──
    // Library IMAGE picks resolve live via their asset ref; library DOCS need their
    // text extracted, so we materialize a scratch row (source='library') that
    // REFERENCES the permanent asset blob (blob_key = the asset's key) and stores
    // the extracted text. Cleanup never deletes a library-sourced blob (the asset
    // is permanent). Authz: the caller must have read access to the asset's repo.
    fastify.post('/api/chat-sessions/:id/library-files', mutationRateLimit, async (req, reply) => {
        const user = userFrom(req);
        if (!user)
            return reply.status(401).send({ error: 'Authentication required' });
        const sessionId = req.params.id;
        const assetId = req.body?.assetId;
        if (!sessionId || !assetId)
            return reply.status(400).send({ error: 'Session id and assetId required' });
        if (!reserveSession(reply, user, sessionId))
            return;
        const assetRow = workspaceManager.getAssetIndex?.()?.get(assetId);
        if (!assetRow)
            return reply.status(404).send({ error: 'Asset not found' });
        if (!access.hasRepoAccess(user, assetRow.repo, workspaceManager, authService)) {
            return reply.status(403).send({ error: "You can't access that asset." });
        }
        const klass = classifyReadable(assetRow.filename, assetRow.contentType);
        if (!klass || klass.kind !== 'doc') {
            // Images are attached directly via their asset ref — only docs need a row.
            return reply.status(400).send({ error: 'Only document assets can be attached this way.' });
        }
        // Same byte cap as the upload path — a huge library doc must not force an
        // unbounded synchronous parse on the request thread. Reject on the indexed
        // size up front when known (avoids even downloading).
        if (assetRow.size != null && assetRow.size > DOC_MAX_UPLOAD_BYTES) {
            return reply.status(413).send({ error: 'That file is too large to attach.' });
        }
        let bytes;
        try {
            bytes = await workspaceManager.getGraph(assetRow.repo).downloadAssetById(assetId, assetRow.filename);
        }
        catch {
            return reply.status(404).send({ error: 'Asset content unavailable' });
        }
        if (bytes.length > DOC_MAX_UPLOAD_BYTES) {
            return reply.status(413).send({ error: 'That file is too large to attach.' });
        }
        let extractedText;
        try {
            extractedText = await extractDocText(bytes, klass.extractor, assetRow.filename);
        }
        catch {
            extractedText = '';
        }
        if (!extractedText)
            return reply.status(422).send({ error: "I couldn't read any text from that file." });
        const fileId = randomUUID();
        // Reference the asset's own immutable key — NOT a chat-scratch key. Cleanup
        // skips source='library' rows so this permanent blob is never deleted.
        const blobKey = `${assetId}/${assetRow.filename}`;
        chatSessionService.insertScratchFile({
            sessionId, fileId, userId: user.id, filename: assetRow.filename, contentType: assetRow.contentType,
            size: assetRow.size ?? bytes.length, blobKey, kind: 'doc',
            extractedText, extractState: 'ready', source: 'library',
            // Persist the source so serve + replay can RE-verify repo access later.
            sourceRepo: assetRow.repo, sourceAssetId: assetId,
        });
        return reply.send({
            kind: 'scratch',
            sessionId,
            fileId,
            filename: assetRow.filename,
            fileKind: 'doc',
            url: `/api/chat-sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}`,
        });
    });
    // ── Promote a scratch file into the curated asset library ("Save to library") ──
    // Deliberate-retention path: copies the ephemeral scratch file into a
    // free-standing asset (default = the workspace-wide Assets store) so it shows
    // up in the library. Authz = scratch row.user_id === caller (in the service).
    // The scratch copy is left in place (still the conversation's context).
    fastify.post('/api/chat-sessions/:id/files/:fileId/save', mutationRateLimit, async (req, reply) => {
        const user = userFrom(req);
        if (!user)
            return reply.status(401).send({ error: 'Authentication required' });
        const { id: sessionId, fileId } = req.params;
        try {
            const result = await promoteScratchFileToAssets(workspaceManager, chatSessionService, authService, {
                sessionId,
                fileId,
                user,
                targetRepo: req.body?.repo,
            });
            // Refresh asset views in realtime (the manager tracks repo_sync, not
            // entity_change — free-standing assets aren't entities).
            wsHub.broadcast({
                type: 'repo_sync',
                repo: result.repo,
                actor: user.displayName ?? 'API',
                source: 'api',
                timestamp: new Date().toISOString(),
            });
            return reply.send({ success: true, ...result });
        }
        catch (err) {
            if (err instanceof PromoteScratchError) {
                const status = err.code === 'SCRATCH_NOT_FOUND' ? 404
                    : err.code === 'NO_WRITE_ACCESS' ? 403
                        : err.code === 'NOT_SAVABLE' ? 400
                            : 501;
                return reply.status(status).send({ error: err.message, code: err.code });
            }
            req.log?.warn?.({ err, sessionId, fileId }, 'save scratch file to assets failed');
            return reply.status(500).send({ error: 'Could not save the file.' });
        }
    });
    // ── Serve a conversation-scratch file (composer-vision E2a, PR3) ──
    // Authz = the metadata row's user_id === caller (chat ownership). Bytes served
    // via the shared backend-object primitive (local stream / R2 302 / private
    // cache). Works for uploads and library-sourced rows alike (blob_key resolves
    // in the same backend either way).
    fastify.route({
        method: ['GET', 'HEAD'],
        url: '/api/chat-sessions/:id/files/:fileId',
        handler: async (req, reply) => {
            const user = userFrom(req);
            if (!user)
                return reply.status(404).send({ error: 'Not found' });
            const { id: sessionId, fileId } = req.params;
            const row = chatSessionService.getScratchFile(sessionId, fileId);
            if (!row || row.userId !== user.id) {
                return reply.status(404).send({ error: 'Not found' });
            }
            // Library rows point at a permanent asset in ITS OWN repo: re-verify repo
            // access on EVERY serve (revocable after attach) and resolve the bytes via
            // that repo's backend — not repo[0], which is wrong in per-repo backends.
            let repo;
            if (row.source === 'library') {
                if (!row.sourceRepo || !access.hasRepoAccess(user, row.sourceRepo, workspaceManager, authService)) {
                    return reply.status(404).send({ error: 'Not found' });
                }
                repo = row.sourceRepo;
            }
            else {
                repo = workspaceManager.getAllRepoConfigs()[0]?.name;
            }
            const svc = repo ? workspaceManager.getGraph(repo)?.getAssetService?.() : undefined;
            if (!svc || !repo)
                return reply.status(404).send({ error: 'Not found' });
            return serveBackendObject(workspaceManager, reply, {
                repo,
                key: row.blobKey,
                filename: row.filename,
                contentType: row.contentType,
                isHead: req.method === 'HEAD',
                cacheControl: 'private, max-age=14400',
                signR2Url: (method) => svc.getSignedUrlForKey(row.blobKey, method),
            });
        },
    });
}
//# sourceMappingURL=chat-sessions-api.js.map