import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import type { Broker } from '../services/broker.js';
import type { Chat, Message, MessageMetadata, Session, ToolCall } from '../types/api.js';
// v1.13.17-cross-repo-reads: grant_read_access resolves the grant root at
// decision time (not at request time) so concurrent project changes don't
// stale-bind the resolution.
import { resolveGrantRoot } from '../services/grant_resolver.js';
import { MESSAGE_COLUMNS } from '../services/message-columns.js';
import { setServerPermission, getServerName } from '../services/mcp-client.js';
import { lookupPendingToolCall } from '../services/pending-tool-lookup.js';

const SendBody = z.object({
  content: z.string().min(1).max(64_000),
});

// v1.8.2: Continue extends an inference loop that hit the tool budget. Caller
// passes the sentinel message it's continuing from; server validates shape
// and the per-chat hard ceiling before resuming.
const ContinueBody = z.object({
  sentinel_message_id: z.string().uuid(),
});

// Batch 9.7: ask_user_input answer submission. Defensive shape — the question
// content is echoed back for traceability but the server does NOT trust it
// (the source of truth is the assistant message's tool_calls.args.questions).
const AnswerUserInputBody = z.object({
  tool_call_id: z.string().min(1),
  answers: z
    .array(
      z.object({
        question: z.string(),
        selected_options: z.array(z.string()),
        free_text: z.string().nullable(),
      }),
    )
    .min(1)
    .max(3),
});

// Same shape the model declared via the tool's zod input. Re-derived here so
// the route can validate args without depending on services/tools.ts (which
// would pull in fs/path_guard for nothing).
const AskUserInputArgs = z.object({
  questions: z
    .array(
      z.object({
        question: z.string(),
        type: z.enum(['single_select', 'multi_select']),
        options: z.array(z.string()).min(1),
      }),
    )
    .min(1)
    .max(3),
});

// v1.13.17-cross-repo-reads: grant decision body. tool_call_id is the
// model-emitted id (e.g. "call_abc123"), not a UUID. decision is binary.
const GrantReadAccessBody = z.object({
  tool_call_id: z.string().min(1),
  decision: z.enum(['allow', 'deny']),
});

// Same shape as services/request_read_access.ts RequestReadAccessInput.
// Re-derived to avoid the services/tools.ts import (matches the
// AskUserInputArgs pattern above).
const RequestReadAccessArgs = z.object({
  path: z.string().min(1),
  reason: z.string().min(1).max(500),
});

interface MessageHandlers {
  enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
  runCompaction: (chatId: string) => Promise<void>;
  publishUserMessage: (
    sessionId: string,
    chatId: string,
    userMessageId: string,
    content: string
  ) => void;
  publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
  // Batch 9.7: lets the answer endpoint emit the tool_result frame that the
  // pause path intentionally skipped. Matches SkillInvokeHandlers in
  // routes/skills.ts so index.ts can pass the same broker.publish adapter.
  publishSessionFrame: (
    sessionId: string,
    frame: Record<string, unknown> & { type: string }
  ) => void;
  cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
  hasActiveInference: (chatId: string) => boolean;
}

export function registerMessageRoutes(
  app: FastifyInstance,
  sql: Sql,
  config: Config,
  broker: Broker,
  handlers: MessageHandlers
): void {
  app.get<{ Params: { id: string } }>(
    '/api/sessions/:id/messages',
    async (req, reply) => {
      const session = await sql<Session[]>`SELECT id FROM sessions WHERE id = ${req.params.id}`;
      if (session.length === 0) {
        reply.code(404);
        return { error: 'session not found' };
      }
      // v1.11: returns ALL messages including compacted ones. The UI
      // distinguishes via the new `summary` flag (renders an accordion
      // SummaryCard) and shows compacted_at-stamped rows inline for context.
      // Internal inference assembly filters compacted_at IS NULL separately —
      // see services/inference.ts loadContext + services/compaction.ts.
      // v1.13.1-B: reads tool_calls/tool_results via the parts-merged view.
      const rows = await sql<Message[]>`
        SELECT ${sql.unsafe(MESSAGE_COLUMNS)}
        FROM messages_with_parts
        WHERE session_id = ${req.params.id}
        ORDER BY created_at ASC, id ASC
      `;
      return rows;
    }
  );

  app.post<{ Params: { id: string } }>(
    '/api/chats/:id/messages',
    async (req, reply) => {
      const parsed = SendBody.safeParse(req.body);
      if (!parsed.success) {
        reply.code(400);
        return { error: 'invalid body', details: parsed.error.flatten() };
      }

      const chatRows = await sql<Chat[]>`
        SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
      `;
      if (chatRows.length === 0) {
        reply.code(404);
        return { error: 'chat not found' };
      }
      const chat = chatRows[0]!;
      const sessionId = chat.session_id;

      const result = await sql.begin(async (tx) => {
        const [userMsg] = await tx<{ id: string }[]>`
          INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
          VALUES (${sessionId}, ${chat.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp())
          RETURNING id
        `;
        const [assistantMsg] = await tx<{ id: string }[]>`
          INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
          VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
          RETURNING id
        `;
        await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
        await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
        return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
      });

      handlers.publishUserMessage(
        sessionId,
        chat.id,
        result.user_message_id,
        parsed.data.content
      );
      handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');

      reply.code(202);
      return result;
    }
  );

  app.post<{ Params: { id: string; message_id: string } }>(
    '/api/chats/:id/messages/:message_id/regenerate',
    async (req, reply) => {
      const { id: chatId, message_id: targetId } = req.params;

      const chatRows = await sql<Chat[]>`
        SELECT id, session_id FROM chats WHERE id = ${chatId}
      `;
      if (chatRows.length === 0) {
        reply.code(404);
        return { error: 'chat not found' };
      }
      const chat = chatRows[0]!;
      const sessionId = chat.session_id;

      const target = await sql<{ id: string; role: string; status: string }[]>`
        SELECT id, role, status
        FROM messages
        WHERE chat_id = ${chatId} AND id = ${targetId}
      `;
      if (target.length === 0) {
        reply.code(404);
        return { error: 'message not found' };
      }
      const targetRow = target[0]!;
      if (targetRow.role !== 'assistant') {
        reply.code(400);
        return { error: 'only assistant messages can be regenerated' };
      }
      if (targetRow.status === 'streaming') {
        reply.code(409);
        return { error: 'message is still streaming' };
      }

      const { newAssistantId, deletedIds } = await sql.begin(async (tx) => {
        const deletedRows = await tx<{ id: string }[]>`
          DELETE FROM messages
          WHERE chat_id = ${chatId}
            AND created_at >= (
              SELECT created_at FROM messages WHERE id = ${targetId}
            )
          RETURNING id
        `;
        const [row] = await tx<{ id: string }[]>`
          INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
          VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
          RETURNING id
        `;
        await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
        await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
        return {
          newAssistantId: row!.id,
          deletedIds: deletedRows.map((r) => r.id),
        };
      });

      handlers.publishMessagesDeleted(sessionId, chatId, deletedIds);
      handlers.enqueueInference(sessionId, chatId, newAssistantId, 'default');

      reply.code(202);
      return { assistant_message_id: newAssistantId };
    }
  );

  app.delete<{ Params: { id: string; message_id: string } }>(
    '/api/chats/:id/messages/:message_id',
    async (req, reply) => {
      const { id: chatId, message_id: messageId } = req.params;

      const chatRows = await sql<Chat[]>`
        SELECT id, session_id FROM chats WHERE id = ${chatId}
      `;
      if (chatRows.length === 0) {
        reply.code(404);
        return { error: 'chat not found' };
      }
      const chat = chatRows[0]!;

      if (handlers.hasActiveInference(chatId)) {
        reply.code(409);
        return { error: 'chat is currently streaming; stop it first' };
      }

      const deletedIds = await sql.begin(async (tx) => {
        const deletedRows = await tx<{ id: string }[]>`
          DELETE FROM messages
          WHERE chat_id = ${chatId}
            AND created_at >= (
              SELECT created_at FROM messages
              WHERE id = ${messageId} AND chat_id = ${chatId}
            )
          RETURNING id
        `;
        if (deletedRows.length > 0) {
          await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
        }
        return deletedRows.map((r) => r.id);
      });

      if (deletedIds.length === 0) {
        reply.code(404);
        return { error: 'message not found' };
      }

      handlers.publishMessagesDeleted(chat.session_id, chatId, deletedIds);

      reply.code(204);
      return null;
    }
  );

  app.post<{ Params: { id: string } }>(
    '/api/chats/:id/compact',
    async (req, reply) => {
      const chatRows = await sql<{ id: string }[]>`
        SELECT id FROM chats WHERE id = ${req.params.id} AND status = 'open'
      `;
      if (chatRows.length === 0) {
        reply.code(404);
        return { error: 'chat not found' };
      }
      try {
        await handlers.runCompaction(chatRows[0]!.id);
      } catch (err) {
        req.log.error({ err, chatId: chatRows[0]!.id }, 'manual compaction failed');
        reply.code(500);
        return { error: err instanceof Error ? err.message : 'compaction failed' };
      }
      reply.code(200);
      return { ok: true };
    }
  );

  app.post<{ Params: { id: string } }>(
    '/api/chats/:id/stop',
    async (req, reply) => {
      const chatRows = await sql<Chat[]>`
        SELECT id, session_id FROM chats WHERE id = ${req.params.id}
      `;
      if (chatRows.length === 0) {
        reply.code(404);
        return { error: 'chat not found' };
      }
      const chat = chatRows[0]!;

      const cancelled = await handlers.cancelInference(chat.session_id, chat.id);
      if (!cancelled) {
        reply.code(409);
        return { error: 'no active generation to stop' };
      }

      reply.code(200);
      return { stopped: true };
    }
  );

  app.post<{ Params: { id: string } }>(
    '/api/chats/:id/continue',
    async (req, reply) => {
      const parsed = ContinueBody.safeParse(req.body);
      if (!parsed.success) {
        reply.code(400);
        return { error: 'invalid body', details: parsed.error.flatten() };
      }

      const chatRows = await sql<Chat[]>`
        SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
      `;
      if (chatRows.length === 0) {
        reply.code(404);
        return { error: 'chat not found' };
      }
      const chat = chatRows[0]!;
      const sessionId = chat.session_id;

      // Cap-hit sentinels are only ever inserted after a turn completes, so
      // there must not be an active inference at this moment. If there is,
      // the client is racing the cap-hit summary that just emitted the
      // sentinel — bail rather than enqueue a parallel run.
      if (handlers.hasActiveInference(chat.id)) {
        reply.code(409);
        return { error: 'chat is currently streaming' };
      }

      const sentinel = await sql<{ metadata: { kind?: unknown; can_continue?: unknown } | null }[]>`
        SELECT metadata
        FROM messages
        WHERE id = ${parsed.data.sentinel_message_id}
          AND chat_id = ${chat.id}
          AND role = 'system'
      `;
      if (sentinel.length === 0) {
        reply.code(404);
        return { error: 'sentinel not found' };
      }
      const meta = sentinel[0]!.metadata;
      if (!meta || meta.kind !== 'cap_hit') {
        reply.code(400);
        return { error: 'message is not a cap-hit sentinel' };
      }
      // Server-side hard ceiling check. UI already disables the button when
      // can_continue is false; defending against a stale tab or a direct
      // API hit is the only reason this lives on the server too.
      if (meta.can_continue !== true) {
        reply.code(409);
        return { error: 'hard limit reached for this chat' };
      }

      const result = await sql.begin(async (tx) => {
        const [assistantMsg] = await tx<{ id: string }[]>`
          INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
          VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
          RETURNING id
        `;
        await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
        await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
        return { assistant_message_id: assistantMsg!.id };
      });

      handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');

      reply.code(202);
      return result;
    }
  );

  app.post<{ Params: { id: string } }>(
    '/api/chats/:id/force_send',
    async (req, reply) => {
      const parsed = SendBody.safeParse(req.body);
      if (!parsed.success) {
        reply.code(400);
        return { error: 'invalid body', details: parsed.error.flatten() };
      }

      const chatRows = await sql<Chat[]>`
        SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
      `;
      if (chatRows.length === 0) {
        reply.code(404);
        return { error: 'chat not found' };
      }
      const chat = chatRows[0]!;
      const sessionId = chat.session_id;

      // Await actual cancellation completion (catch block persists state).
      // 5s timeout guards against llama-swap stalls; if hit, proceed anyway.
      await Promise.race([
        handlers.cancelInference(sessionId, chat.id).then(() => undefined),
        new Promise<void>((_, rej) =>
          setTimeout(() => rej(new Error('cancel-timeout')), 5000)
        ),
      ]).catch((e: Error) => {
        if (e.message !== 'cancel-timeout') throw e;
        req.log.warn({ chatId: chat.id }, 'cancel timeout exceeded, proceeding with force-send');
      });

      const result = await sql.begin(async (tx) => {
        const [userMsg] = await tx<{ id: string }[]>`
          INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
          VALUES (${sessionId}, ${chat.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp())
          RETURNING id
        `;
        const [assistantMsg] = await tx<{ id: string }[]>`
          INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
          VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
          RETURNING id
        `;
        await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
        await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
        return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
      });

      handlers.publishUserMessage(
        sessionId,
        chat.id,
        result.user_message_id,
        parsed.data.content
      );
      handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');

      reply.code(202);
      return result;
    }
  );

  // Batch 9.7: resume an ask_user_input pause. Validates the body matches the
  // question shape the model declared, UPDATEs the pending tool row's
  // tool_results to the AnswerSet, publishes the deferred tool_result frame,
  // and enqueues the next assistant turn. Error codes per spec:
  //   400 invalid_body / mismatched_answer_shape
  //   404 chat_not_found / unknown_tool_call_id
  //   409 tool_call_already_answered
  app.post<{ Params: { id: string } }>(
    '/api/chats/:id/answer_user_input',
    async (req, reply) => {
      const parsed = AnswerUserInputBody.safeParse(req.body);
      if (!parsed.success) {
        reply.code(400);
        return { error: 'invalid_body', details: parsed.error.flatten() };
      }
      const { tool_call_id, answers } = parsed.data;

      const chatRows = await sql<Chat[]>`
        SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
      `;
      if (chatRows.length === 0) {
        reply.code(404);
        return { error: 'chat_not_found' };
      }
      const chat = chatRows[0]!;
      const sessionId = chat.session_id;

      // v1.13.1-C: resolve the originating tool_call + pending tool row.
      // Pre-v1.13.0 history has no parts rows — those become unreachable (404).
      const ctx = await lookupPendingToolCall(
        sql, chat.id, tool_call_id, 'ask_user_input', 'tool_call_not_ask_user_input',
      );
      if (!ctx.ok) {
        reply.code(ctx.code);
        return ctx.body;
      }
      const { foundCall, toolMessageId } = ctx;

      // Validate the args themselves — the LLM could have emitted bad JSON.
      const argsParsed = AskUserInputArgs.safeParse(foundCall.args);
      if (!argsParsed.success) {
        reply.code(400);
        return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
      }
      const questions = argsParsed.data.questions;
      if (answers.length !== questions.length) {
        reply.code(400);
        return {
          error: 'mismatched_answer_shape',
          detail: `expected ${questions.length} answer(s), got ${answers.length}`,
        };
      }
      for (let i = 0; i < questions.length; i++) {
        const q = questions[i]!;
        const a = answers[i]!;
        for (const sel of a.selected_options) {
          if (!q.options.includes(sel)) {
            reply.code(400);
            return {
              error: 'mismatched_answer_shape',
              detail: `answer ${i + 1} contains option not in question: ${sel}`,
            };
          }
        }
        if (q.type === 'single_select' && a.selected_options.length > 1) {
          reply.code(400);
          return {
            error: 'mismatched_answer_shape',
            detail: `answer ${i + 1} has multiple selections on single_select`,
          };
        }
        const hasOpt = a.selected_options.length > 0;
        const hasText = a.free_text !== null && a.free_text.trim().length > 0;
        if (!hasOpt && !hasText) {
          reply.code(400);
          return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} is empty` };
        }
      }

      const answerSet = { answers };
      const newToolResults = {
        tool_call_id,
        output: answerSet,
        truncated: false,
      };

      const result = await sql.begin(async (tx) => {
        // v1.13.20: parts-only. Replace the pending tool_result part inserted
        // at message creation (tool-phase.ts) with the answered one. Delete-
        // then-insert is simpler than UPDATE because parts are append-style
        // elsewhere; the UNIQUE (message_id, sequence) constraint blocks
        // plain insert.
        await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
        await tx`
          INSERT INTO message_parts (message_id, sequence, kind, payload)
          VALUES (${toolMessageId}, 0, 'tool_result', ${tx.json(newToolResults as never)})
        `;
        const [assistantMsg] = await tx<{ id: string }[]>`
          INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
          VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
          RETURNING id
        `;
        await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
        await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
        return {
          tool_message_id: toolMessageId,
          assistant_message_id: assistantMsg!.id,
        };
      });

      // Publish the deferred tool_result frame. useSessionStream's reducer
      // updates the matching tool_run.result so AskUserInputCard flips into
      // its read-only "answered" mode without a refetch.
      handlers.publishSessionFrame(sessionId, {
        type: 'tool_result',
        tool_message_id: result.tool_message_id,
        tool_call_id,
        chat_id: chat.id,
        output: answerSet,
        truncated: false,
      });
      handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');

      reply.code(202);
      return result;
    },
  );

  // v1.13.17-cross-repo-reads: resume an awaiting-grant pause. Mirror shape
  // of /answer_user_input (validate, look up via message_parts, UPDATE,
  // publish, enqueue). Differences vs /answer_user_input:
  //   - On 'allow', re-resolves the grant root via grant_resolver (state
  //     may have changed since the prompt fired — concurrent project add,
  //     etc.). Resolution failure auto-falls to a denial with reason text
  //     rather than 500ing.
  //   - On 'allow' with a valid root, appends to sessions.allowed_read_paths
  //     (deduplicated) inside the same transaction.
  //   - On success, also publishes session_updated so an open SettingsPane
  //     refetches the new grant list.
  // Error codes match /answer:
  //   400 invalid_body / mismatched_answer_shape (bad args on the tool_call)
  //   404 chat_not_found / unknown_tool_call_id
  //   409 tool_call_already_answered
  app.post<{ Params: { id: string } }>(
    '/api/chats/:id/grant_read_access',
    async (req, reply) => {
      const parsed = GrantReadAccessBody.safeParse(req.body);
      if (!parsed.success) {
        reply.code(400);
        return { error: 'invalid_body', details: parsed.error.flatten() };
      }
      const { tool_call_id, decision } = parsed.data;

      const chatRows = await sql<Chat[]>`
        SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
      `;
      if (chatRows.length === 0) {
        reply.code(404);
        return { error: 'chat_not_found' };
      }
      const chat = chatRows[0]!;
      const sessionId = chat.session_id;

      const grantCtx = await lookupPendingToolCall(
        sql, chat.id, tool_call_id, 'request_read_access', 'tool_call_not_request_read_access',
      );
      if (!grantCtx.ok) {
        reply.code(grantCtx.code);
        return grantCtx.body;
      }
      const { foundCall, toolMessageId } = grantCtx;

      const argsParsed = RequestReadAccessArgs.safeParse(foundCall.args);
      if (!argsParsed.success) {
        reply.code(400);
        return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
      }
      const requestedPath = argsParsed.data.path;

      // Look up session + project so we can re-resolve the grant root and
      // append to allowed_read_paths atomically. We don't need agent or
      // history here — just the project path for the resolver.
      const sessionRows = await sql<{
        id: string;
        project_id: string;
        allowed_read_paths: string[];
        project_path: string;
      }[]>`
        SELECT s.id, s.project_id, s.allowed_read_paths, p.path AS project_path
        FROM sessions s
        JOIN projects p ON p.id = s.project_id
        WHERE s.id = ${sessionId}
      `;
      const sessionRow = sessionRows[0];
      if (!sessionRow) {
        reply.code(404);
        return { error: 'session_not_found' };
      }

      // Decision branch. 'deny' is the easy path: nothing to resolve or
      // persist. 'allow' resolves the grant root; if resolution fails (e.g.
      // path was deleted, project removed since prompt) the tool gets a
      // denial with the resolver's reason text instead of a 500.
      let resultOutput: string;
      let grantRoot: string | null = null;
      if (decision === 'allow') {
        const resolution = await resolveGrantRoot(
          sql,
          requestedPath,
          sessionRow.project_path,
          config.PROJECT_ROOT_WHITELIST,
        );
        if (!resolution.ok) {
          resultOutput = `denied: ${resolution.reason}`;
        } else {
          grantRoot = resolution.root;
          resultOutput = `granted: ${grantRoot}`;
        }
      } else {
        resultOutput = 'denied';
      }

      const newToolResults = {
        tool_call_id,
        output: resultOutput,
        truncated: false,
      };
      const dbResult = await sql.begin(async (tx) => {
        // v1.13.20: parts-only. Same delete+insert dance as /answer —
        // UNIQUE (message_id, sequence) blocks plain UPDATE on append-style
        // parts.
        await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
        await tx`
          INSERT INTO message_parts (message_id, sequence, kind, payload)
          VALUES (${toolMessageId}, 0, 'tool_result', ${tx.json(newToolResults as never)})
        `;
        // Persist the grant if we have one. ARRAY-level dedup — append only
        // when the root isn't already present. The session row gets
        // touched (updated_at) so the post-update publish below has a
        // fresh timestamp.
        let allowedRootsAfter = sessionRow.allowed_read_paths;
        if (grantRoot !== null) {
          if (!sessionRow.allowed_read_paths.includes(grantRoot)) {
            const updated = await tx<{ allowed_read_paths: string[] }[]>`
              UPDATE sessions
              SET allowed_read_paths = array_append(allowed_read_paths, ${grantRoot}),
                  updated_at = clock_timestamp()
              WHERE id = ${sessionId}
              RETURNING allowed_read_paths
            `;
            allowedRootsAfter = updated[0]?.allowed_read_paths ?? sessionRow.allowed_read_paths;
          } else {
            // Already present — touch updated_at so any open settings
            // panel still picks up the no-op via session_updated.
            await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
          }
        }
        const [assistantMsg] = await tx<{ id: string }[]>`
          INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
          VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
          RETURNING id
        `;
        await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
        return {
          tool_message_id: toolMessageId,
          assistant_message_id: assistantMsg!.id,
          allowed_roots_after: allowedRootsAfter,
        };
      });

      // Publish the deferred tool_result frame so the pending card flips to
      // its answered view without a refetch.
      handlers.publishSessionFrame(sessionId, {
        type: 'tool_result',
        tool_message_id: dbResult.tool_message_id,
        tool_call_id,
        chat_id: chat.id,
        output: resultOutput,
        truncated: false,
      });
      // session_updated nudge so any open SettingsPane refetches and sees
      // the new allowed_read_paths. We publish on the user channel to match
      // the existing PATCH /api/sessions/:id behavior — frontend refetches
      // via api.sessions.get on receipt.
      const nowIso = new Date().toISOString();
      broker.publishUserFrame('default', {
        type: 'session_updated',
        session_id: sessionId,
        project_id: sessionRow.project_id,
        // session name doesn't change on grant; we look it up fresh to
        // avoid carrying stale state if a rename raced us.
        name:
          (
            await sql<{ name: string }[]>`SELECT name FROM sessions WHERE id = ${sessionId}`
          )[0]?.name ?? '',
        updated_at: nowIso,
      });
      handlers.enqueueInference(sessionId, chat.id, dbResult.assistant_message_id, 'default');

      reply.code(202);
      return {
        tool_message_id: dbResult.tool_message_id,
        assistant_message_id: dbResult.assistant_message_id,
        allowed_read_paths: dbResult.allowed_roots_after,
      };
    },
  );

  // v1.15.0-mcp-permission: approve/deny MCP tool calls for 'ask' state servers.
  const McpApproveBody = z.object({
    tool_call_id: z.string().min(1),
    permission: z.enum(['allow_once', 'allow_always', 'deny']),
  });

  app.post<{ Params: { id: string } }>(
    '/api/chats/:id/mcp-approve',
    async (req, reply) => {
      const parsed = McpApproveBody.safeParse(req.body);
      if (!parsed.success) {
        reply.code(400);
        return { error: 'invalid body', details: parsed.error.flatten() };
      }
      const { tool_call_id, permission } = parsed.data;

      const chatRows = await sql<{ id: string }[]>`
        SELECT id FROM chats WHERE id = ${req.params.id} AND status = 'open'
      `;
      if (chatRows.length === 0) {
        reply.code(404);
        return { error: 'chat_not_found' };
      }

      // Look up the tool call to get the prefixed tool name
      const callerRows = await sql<{
        payload: { name: string };
      }[]>`
        SELECT p.payload
        FROM message_parts p
        JOIN messages m ON m.id = p.message_id
        WHERE m.chat_id = ${req.params.id}
          AND m.role = 'assistant'
          AND p.kind = 'tool_call'
          AND p.payload->>'id' = ${tool_call_id}
        ORDER BY m.created_at DESC
        LIMIT 1
      `;
      const callerRow = callerRows[0];
      if (!callerRow) {
        reply.code(404);
        return { error: 'tool_call_not_found' };
      }

      const toolName = callerRow.payload.name;
      const serverName = getServerName(toolName);
      if (!serverName) {
        reply.code(400);
        return { error: 'not_an_mcp_tool', detail: `tool '${toolName}' is not from an MCP server` };
      }

      if (permission === 'allow_always' || permission === 'allow_once') {
        setServerPermission(serverName, 'allow');
      } else if (permission === 'deny') {
        setServerPermission(serverName, 'deny');
      }

      return { ok: true };
    },
  );
}
