// v1.14.x-html-artifact-panes: artifact download routes.
//
// Two endpoints:
//   POST /api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html
//     Materialises a file under <projectRoot>/.boocode/artifacts/ and
//     returns {path, url}. fmt=html requires an existing html_artifact part
//     on the message (404 otherwise). fmt=md works on any assistant
//     message with non-empty content.
//
//   GET /api/projects/:project_id/artifacts/:filename
//     Streams a previously-written artifact back with
//     Content-Disposition: attachment. Path-guarded to the project's
//     artifacts dir; rejects traversal attempts.

import { createReadStream } from 'node:fs';
import { realpath, stat } from 'node:fs/promises';
import { resolve, sep, basename } from 'node:path';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import {
  writeHtmlArtifact,
  writeMarkdownArtifact,
  type HtmlArtifactPayload,
} from '../services/artifacts.js';

const DownloadQuery = z.object({
  fmt: z.enum(['md', 'html']),
});

// Filename safety: alnum, dash, dot, underscore only. Blocks `..`, slashes,
// nul bytes, etc. before we even touch the filesystem.
const FilenameRe = /^[A-Za-z0-9._-]+$/;

interface ChatRow {
  id: string;
  session_id: string;
  project_id: string;
  project_path: string;
}

interface MessageRow {
  id: string;
  chat_id: string;
  role: string;
  content: string;
}

export function registerArtifactRoutes(app: FastifyInstance, sql: Sql): void {
  app.post<{
    Params: { id: string; msg_id: string };
    Querystring: { fmt?: string };
  }>(
    '/api/chats/:id/messages/:msg_id/artifacts/download',
    async (req, reply) => {
      const parsed = DownloadQuery.safeParse(req.query);
      if (!parsed.success) {
        reply.code(400);
        return { error: 'invalid query', details: parsed.error.flatten() };
      }
      const { fmt } = parsed.data;
      const { id: chatId, msg_id: messageId } = req.params;

      const chatRows = await sql<ChatRow[]>`
        SELECT c.id, c.session_id, s.project_id, p.path AS project_path
        FROM chats c
        JOIN sessions s ON s.id = c.session_id
        JOIN projects p ON p.id = s.project_id
        WHERE c.id = ${chatId}
      `;
      if (chatRows.length === 0) {
        reply.code(404);
        return { error: 'chat not found' };
      }
      const chat = chatRows[0]!;

      const msgRows = await sql<MessageRow[]>`
        SELECT id, chat_id, role, content
        FROM messages
        WHERE id = ${messageId} AND chat_id = ${chatId}
      `;
      if (msgRows.length === 0) {
        reply.code(404);
        return { error: 'message not found' };
      }
      const msg = msgRows[0]!;
      if (msg.role !== 'assistant') {
        reply.code(400);
        return { error: 'only assistant messages produce artifacts' };
      }

      const ctx = { projectId: chat.project_id, projectRoot: chat.project_path };

      try {
        if (fmt === 'md') {
          if (!msg.content || msg.content.trim().length === 0) {
            reply.code(400);
            return { error: 'message has no content to export' };
          }
          const result = await writeMarkdownArtifact(
            { content: msg.content },
            ctx,
          );
          return result;
        }
        // fmt === 'html': require an html_artifact part on the message.
        const partRows = await sql<{ payload: HtmlArtifactPayload }[]>`
          SELECT payload
          FROM message_parts
          WHERE message_id = ${messageId} AND kind = 'html_artifact'
          ORDER BY sequence ASC
          LIMIT 1
        `;
        if (partRows.length === 0) {
          reply.code(404);
          return { error: 'no html_artifact part on this message' };
        }
        const result = await writeHtmlArtifact(partRows[0]!.payload, ctx);
        return result;
      } catch (err) {
        req.log.error({ err, messageId, fmt }, 'artifact write failed');
        reply.code(500);
        return {
          error: err instanceof Error ? err.message : 'artifact write failed',
        };
      }
    },
  );

  // v1.14.x-html-artifact-panes: HtmlArtifactPane needs the payload on click
  // to render its iframe. Returns 404 when the message has no html_artifact
  // sibling part — frontend uses that signal to open the markdown_artifact
  // pane variant instead. Payload shape matches HtmlArtifactPayload in
  // services/artifacts.ts.
  app.get<{ Params: { id: string; msg_id: string } }>(
    '/api/chats/:id/messages/:msg_id/html_artifact',
    async (req, reply) => {
      const { id: chatId, msg_id: messageId } = req.params;
      const partRows = await sql<{ payload: HtmlArtifactPayload }[]>`
        SELECT payload
        FROM message_parts mp
        JOIN messages m ON m.id = mp.message_id
        WHERE mp.message_id = ${messageId}
          AND m.chat_id = ${chatId}
          AND mp.kind = 'html_artifact'
        ORDER BY mp.sequence ASC
        LIMIT 1
      `;
      if (partRows.length === 0) {
        reply.code(404);
        return { error: 'no html_artifact part on this message' };
      }
      return partRows[0]!.payload;
    },
  );

  app.get<{ Params: { project_id: string; filename: string } }>(
    '/api/projects/:project_id/artifacts/:filename',
    async (req, reply) => {
      const { project_id: projectId, filename } = req.params;
      // Strip directory components defensively; only the basename is allowed.
      const base = basename(filename);
      if (base !== filename || !FilenameRe.test(base)) {
        reply.code(400);
        return { error: 'invalid filename' };
      }

      const projectRows = await sql<{ id: string; path: string }[]>`
        SELECT id, path FROM projects WHERE id = ${projectId}
      `;
      if (projectRows.length === 0) {
        reply.code(404);
        return { error: 'project not found' };
      }
      const project = projectRows[0]!;

      let resolvedRoot: string;
      try {
        resolvedRoot = await realpath(project.path);
      } catch {
        reply.code(404);
        return { error: 'project path missing' };
      }
      const artifactsDir = resolve(resolvedRoot, '.boocode/artifacts');
      const absPath = resolve(artifactsDir, base);
      if (!absPath.startsWith(artifactsDir + sep)) {
        reply.code(400);
        return { error: 'path traversal rejected' };
      }
      // Close the symlink-escape gap: if `.boocode/artifacts` (or an
      // ancestor) is a symlink pointing outside resolvedRoot, the lexical
      // prefix check above passes but the actual read lands outside the
      // sandbox. Realpath the artifacts dir and re-verify.
      try {
        const realArtifactsDir = await realpath(artifactsDir);
        if (
          realArtifactsDir !== resolvedRoot &&
          !realArtifactsDir.startsWith(resolvedRoot + sep)
        ) {
          reply.code(400);
          return { error: 'path traversal rejected' };
        }
      } catch {
        reply.code(404);
        return { error: 'artifact not found' };
      }
      try {
        await stat(absPath);
      } catch {
        reply.code(404);
        return { error: 'artifact not found' };
      }
      const ext = base.toLowerCase().endsWith('.html')
        ? 'text/html; charset=utf-8'
        : base.toLowerCase().endsWith('.md')
          ? 'text/markdown; charset=utf-8'
          : 'application/octet-stream';
      reply.header('Content-Type', ext);
      // Defense-in-depth on LLM-generated HTML served through this route.
      // Authelia gates the proxy; these headers limit blast radius if a
      // payload tries to escape that boundary in-browser.
      reply.header('X-Content-Type-Options', 'nosniff');
      reply.header('Content-Security-Policy', 'sandbox');
      reply.header(
        'Content-Disposition',
        `attachment; filename="${base.replace(/"/g, '')}"`,
      );
      return reply.send(createReadStream(absPath));
    },
  );
}
