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 { Session, WorktreeRiskReport } from '../types/api.js';
import { getSetting } from './settings.js';
import { notifyCoderClose } from '../services/coder-notify.js';

const CreateBody = z.object({
  name: z.string().min(1).max(200).optional(),
  model: z.string().min(1).max(200).optional(),
  system_prompt: z.string().max(8000).optional(),
  agent_id: z.string().min(1).max(200).nullable().optional(),
});

// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added
// as pane kinds. Pane state is a reference only (chat_id + message_id +
// title) — the actual artifact body is fetched from the message row or
// message_parts.payload by the pane component on mount.
const MarkdownArtifactStateZ = z.object({
  chat_id: z.string().min(1).max(200),
  message_id: z.string().min(1).max(200),
  title: z.string().max(500),
});
const HtmlArtifactStateZ = z.object({
  chat_id: z.string().min(1).max(200),
  message_id: z.string().min(1).max(200),
  title: z.string().max(500),
});

const PaneKindZ = z.enum([
  'chat',
  'terminal',
  'coder',
  'agent', // legacy alias — normalized to coder on write
  'empty',
  'settings',
  'markdown_artifact',
  'html_artifact',
]);

// Mixed tabs: each tab carries its own kind (parallel to chatIds).
const TabKindZ = z.enum(['chat', 'coder', 'terminal']);

const WorkspacePaneZ = z.object({
  id: z.string().min(1).max(200),
  kind: PaneKindZ,
  chatId: z.string().min(1).max(200).optional(),
  chatIds: z.array(z.string().min(1).max(200)).max(50),
  tabKinds: z.array(TabKindZ).max(50).optional(),
  activeChatIdx: z.number().int(),
  markdown_artifact_state: MarkdownArtifactStateZ.optional(),
  html_artifact_state: HtmlArtifactStateZ.optional(),
});

// v2.6.x: workspace_panes column widened from a bare WorkspacePane[] to a
// WorkspaceState envelope (panes + stable session-scoped tab numbering +
// reopen stack). closedPaneStack entries are lighter than full panes — just
// the kind + chat ids needed to recreate a closed pane on reopen.
const ClosedPaneEntryZ = z.object({
  kind: PaneKindZ,
  chatIds: z.array(z.string().min(1).max(200)).max(50),
  tabKinds: z.array(TabKindZ).max(50).optional(),
  activeChatIdx: z.number().int(),
});

const WorkspaceStateZ = z.object({
  panes: z.array(WorkspacePaneZ).max(10),
  tabNumbers: z.record(z.string(), z.number().int()).default({}),
  nextTabNumber: z.number().int().default(1),
  closedPaneStack: z.array(ClosedPaneEntryZ).max(10).default([]),
});

// Accept either the legacy bare array OR the envelope. The handler normalizes
// to a full envelope before storing (see MIGRATION rule in the PATCH handler).
const WorkspacePanesBody = z.object({
  workspace_panes: z.union([z.array(WorkspacePaneZ).max(10), WorkspaceStateZ]),
});

const PatchBody = z.object({
  name: z.string().min(1).max(200).optional(),
  model: z.string().min(1).max(200).optional(),
  system_prompt: z.string().max(8000).optional(),
  agent_id: z.string().min(1).max(200).nullable().optional(),
  // v1.9: null = inherit from project default; true/false = explicit override.
  web_search_enabled: z.boolean().nullable().optional(),
  // v1.13.17-cross-repo-reads: revocation pathway. PATCH with a shortened
  // list deletes entries; the grant flow itself APPENDS via the separate
  // grant_read_access endpoint, never via this PATCH. Frontend treats this
  // as "send the new whole array". Per-entry shape validation: must be
  // absolute, no NUL, no `/..` traversal segment. Server doesn't re-validate
  // whitelist membership on PATCH — entries already in the array were
  // placed there by the grant endpoint after a full whitelist+repo-shape
  // check. THE SUBSET CHECK (every entry must already be in the current
  // array) is enforced at runtime in the PATCH handler below, NOT in this
  // zod refinement, because the refinement has no access to the existing
  // session row.
  allowed_read_paths: z
    .array(
      z
        .string()
        .min(1)
        .max(1024)
        .refine((p) => p.startsWith('/') && !p.includes('\0') && !p.includes('/..'), {
          message: 'must be an absolute path without traversal markers',
        }),
    )
    .max(64)
    .optional(),
});

async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
  const fromDb = await getSetting<string>(sql, 'default_model');
  if (typeof fromDb === 'string' && fromDb.length > 0) return fromDb;
  return config.DEFAULT_MODEL;
}

// v1.13.17-cross-repo-reads: subset enforcement for PATCH allowed_read_paths.
// The PATCH route can only SHRINK the array; growth happens exclusively via
// POST /api/chats/:id/grant_read_access (which requires user consent).
// Returns the list of disallowed-additions; an empty list means the request
// is a valid shrink-or-no-op. Exported for the unit test.
export function findUnauthorizedAdditions(
  prior: readonly string[],
  requested: readonly string[],
): string[] {
  const priorSet = new Set(prior);
  return requested.filter((p) => !priorSet.has(p));
}

export function registerSessionRoutes(
  app: FastifyInstance,
  sql: Sql,
  config: Config,
  broker: Broker
): void {
  app.get<{ Params: { id: string }; Querystring: { status?: string } }>(
    '/api/projects/:id/sessions',
    async (req, reply) => {
      const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`;
      if (project.length === 0) {
        reply.code(404);
        return { error: 'project not found' };
      }
      const status = req.query.status === 'archived' ? 'archived' : 'open';
      const rows = await sql<Session[]>`
        SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths, state_graph_enabled
        FROM sessions
        WHERE project_id = ${req.params.id} AND status = ${status}
        ORDER BY updated_at DESC
      `;
      return rows;
    }
  );

  app.post<{ Params: { id: string } }>(
    '/api/projects/:id/sessions',
    async (req, reply) => {
      const parsed = CreateBody.safeParse(req.body ?? {});
      if (!parsed.success) {
        reply.code(400);
        return { error: 'invalid body', details: parsed.error.flatten() };
      }
      const project = await sql<{ id: string }[]>`
        SELECT id FROM projects WHERE id = ${req.params.id}
      `;
      if (project.length === 0) {
        reply.code(404);
        return { error: 'project not found' };
      }

      let model = parsed.data.model;
      if (!model) {
        const lastUsed = await sql<{ model: string }[]>`
          SELECT model FROM sessions
          WHERE project_id = ${req.params.id}
          ORDER BY created_at DESC
          LIMIT 1
        `;
        model = lastUsed[0]?.model ?? (await resolveDefaultModel(sql, config));
      }

      const name = parsed.data.name ?? 'New session';
      const systemPrompt = parsed.data.system_prompt ?? '';
      // v1.11.5.2: default is null (no agent / raw chat) when the client
      // omits agent_id. Sam can still pick one from the AgentPicker after
      // the session loads. Was: first agent in the project's effective list
      // (alphabetically — usually "Code Reviewer"), which felt presumptuous.
      const agentId = parsed.data.agent_id ?? null;

      const row = await sql.begin(async (tx) => {
        const [session] = await tx<Session[]>`
          INSERT INTO sessions (project_id, name, model, system_prompt, agent_id)
          VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}, ${agentId})
          RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
        `;
        await tx`
          INSERT INTO chats (session_id, name, status)
          VALUES (${session!.id}, NULL, 'open')
        `;
        return session!;
      });
      broker.publishUserFrame('default', {
        type: 'session_created',
        session: row,
        project_id: row.project_id,
      });
      reply.code(201);
      return row;
    }
  );

  app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
    const rows = await sql<Session[]>`
      SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths, state_graph_enabled
      FROM sessions WHERE id = ${req.params.id}
    `;
    if (rows.length === 0) {
      reply.code(404);
      return { error: 'session not found' };
    }
    return rows[0];
  });

  app.patch<{ Params: { id: string } }>(
    '/api/sessions/:id',
    async (req, reply) => {
      const parsed = PatchBody.safeParse(req.body);
      if (!parsed.success) {
        reply.code(400);
        return { error: 'invalid body', details: parsed.error.flatten() };
      }
      const { name, model, system_prompt } = parsed.data;
      // agent_id and web_search_enabled are both tri-state on the wire: omitted
      // = no change, null = clear/inherit, value = set. CASE WHEN inside SET
      // handles all three atomically.
      const agentIdProvided = parsed.data.agent_id !== undefined;
      const newAgentId = parsed.data.agent_id ?? null;
      const wseProvided = parsed.data.web_search_enabled !== undefined;
      const newWse = parsed.data.web_search_enabled ?? null;
      // v1.13.17-cross-repo-reads: tri-state on the wire (undefined = no
      // change, [] = clear). Frontend currently uses this PATCH only for
      // revocation (delete a single entry from the existing array, send
      // shortened result). Append-style grants go through the dedicated
      // grant_read_access endpoint inside the inference loop.
      const arpProvided = parsed.data.allowed_read_paths !== undefined;
      const newArp = parsed.data.allowed_read_paths ?? [];
      // Read the prior name + grants so the post-update publish can skip no-op
      // renames (PATCH { name: "Foo" } where the session is already "Foo") AND
      // so the subset check below has the current grant list to compare against.
      // The window between SELECT and UPDATE is sub-millisecond in the same
      // request handler; a concurrent rename in that gap would just mean one
      // stale publish, which existing clients dedup by id.
      const before = await sql<{ name: string; allowed_read_paths: string[] }[]>`
        SELECT name, allowed_read_paths FROM sessions WHERE id = ${req.params.id}
      `;
      const priorName = before[0]?.name;
      const priorArp = before[0]?.allowed_read_paths ?? [];

      // v1.13.17-cross-repo-reads: subset enforcement. The grant flow is the
      // ONLY path that can add entries to allowed_read_paths — PATCH can only
      // shrink the array, never grow it. Without this guard, a malicious
      // client could POST {"allowed_read_paths":["/etc"]} and bypass the
      // user-consent prompt entirely. Sam flagged this in the v1.13.17
      // compliance review (2026-05-22).
      // Race note: a concurrent grant landing between this SELECT and the
      // UPDATE below would briefly make a "shouldn't-have-been-valid" PATCH
      // succeed (the newly-granted root sneaks in). Inverse race — a
      // legitimate revoke happening alongside a concurrent grant — could
      // briefly reject the revoke; the user retries. Both are acceptable
      // given the single-user threat model + sub-millisecond window.
      if (arpProvided) {
        const extras = findUnauthorizedAdditions(priorArp, newArp);
        if (extras.length > 0) {
          reply.code(400);
          return {
            error: 'invalid body',
            details: {
              fieldErrors: {
                allowed_read_paths: [
                  `entries must already be granted; cannot add via PATCH: ${extras.join(', ')}`,
                ],
              },
            },
          };
        }
      }
      const rows = await sql<Session[]>`
        UPDATE sessions
        SET
          name = COALESCE(${name ?? null}, name),
          model = COALESCE(${model ?? null}, model),
          system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
          agent_id = CASE WHEN ${agentIdProvided} THEN ${newAgentId} ELSE agent_id END,
          web_search_enabled = CASE WHEN ${wseProvided} THEN ${newWse} ELSE web_search_enabled END,
          allowed_read_paths = CASE WHEN ${arpProvided} THEN ${sql.array(newArp, 25)} ELSE allowed_read_paths END,
          updated_at = clock_timestamp()
        WHERE id = ${req.params.id}
        RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
                  agent_id, web_search_enabled, workspace_panes, allowed_read_paths
      `;
      if (rows.length === 0) {
        reply.code(404);
        return { error: 'session not found' };
      }
      const session = rows[0]!;
      if (name !== undefined && session.name !== priorName) {
        broker.publishUserFrame('default', {
          type: 'session_renamed',
          session_id: session.id,
          name: session.name,
        });
      }
      // v1.9: any successful PATCH broadcasts session_updated so listeners
      // (notably the SettingsPane open in another tab) can refetch and pick
      // up the new fields. Frame stays lean (decision d) — payload is just
      // ids + name + updated_at, the client refetches via api.sessions.get.
      broker.publishUserFrame('default', {
        type: 'session_updated',
        session_id: session.id,
        project_id: session.project_id,
        name: session.name,
        updated_at: session.updated_at,
      });
      return session;
    }
  );

  app.patch<{ Params: { id: string } }>(
    '/api/sessions/:id/workspace',
    async (req, reply) => {
      const parsed = WorkspacePanesBody.safeParse(req.body);
      if (!parsed.success) {
        reply.code(400);
        return { error: 'invalid body', details: parsed.error.flatten() };
      }
      // v2.6.x MIGRATION: the body is either a legacy bare WorkspacePane[] or
      // the WorkspaceState envelope. Normalize to a full envelope so the column
      // always stores the envelope shape going forward.
      const body = parsed.data.workspace_panes;
      const envelope = Array.isArray(body)
        ? { panes: body, tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] }
        : body;
      // agent → coder normalization on the panes array (unchanged write rule).
      envelope.panes = envelope.panes.map((pane) =>
        pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane,
      );
      const rows = await sql<Session[]>`
        UPDATE sessions
        SET workspace_panes = ${sql.json(envelope as never)},
          updated_at = clock_timestamp()
        WHERE id = ${req.params.id}
        RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
                  agent_id, web_search_enabled, workspace_panes, allowed_read_paths, state_graph_enabled
      `;
      if (rows.length === 0) {
        reply.code(404);
        return { error: 'session not found' };
      }
      const session = rows[0]!;
      broker.publishUserFrame('default', {
        type: 'session_workspace_updated',
        session_id: session.id,
        workspace_panes: session.workspace_panes,
      });
      return session;
    }
  );

  // v1.9: bulk-archive every open session in a project. Mirrors the
  // single-archive shape (same broker frame type) so the existing useSidebar
  // reducer cases handle it without changes — just N frames instead of 1.
  app.post<{ Params: { id: string } }>(
    '/api/projects/:id/sessions/archive-all',
    async (req, reply) => {
      const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`;
      if (project.length === 0) {
        reply.code(404);
        return { error: 'project not found' };
      }
      const rows = await sql<{ id: string }[]>`
        UPDATE sessions
           SET status = 'archived', updated_at = clock_timestamp()
         WHERE project_id = ${req.params.id} AND status = 'open'
         RETURNING id
      `;
      const ids = rows.map((r) => r.id);
      for (const id of ids) {
        broker.publishUserFrame('default', {
          type: 'session_archived',
          session_id: id,
          project_id: req.params.id,
        });
      }
      return { archived: ids.length, ids };
    }
  );

  // v1.9: count helper for the confirm dialog. Cheap COUNT(*) — the settings
  // pane calls it on click, not on render.
  app.get<{ Params: { id: string } }>(
    '/api/projects/:id/sessions/open-count',
    async (req, reply) => {
      const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`;
      if (project.length === 0) {
        reply.code(404);
        return { error: 'project not found' };
      }
      const rows = await sql<{ count: number }[]>`
        SELECT COUNT(*)::int AS count
          FROM sessions
         WHERE project_id = ${req.params.id} AND status = 'open'
      `;
      return { count: rows[0]?.count ?? 0 };
    }
  );

  app.post<{ Params: { id: string } }>(
    '/api/sessions/:id/archive',
    async (req, reply) => {
      const rows = await sql<{ id: string; project_id: string }[]>`
        UPDATE sessions SET status = 'archived', updated_at = clock_timestamp()
        WHERE id = ${req.params.id} AND status = 'open'
        RETURNING id, project_id
      `;
      if (rows.length === 0) {
        reply.code(404);
        return { error: 'session not found or already archived' };
      }
      broker.publishUserFrame('default', {
        type: 'session_archived',
        session_id: rows[0]!.id,
        project_id: rows[0]!.project_id,
      });
      reply.code(204);
      return null;
    }
  );

  app.post<{ Params: { id: string } }>(
    '/api/sessions/:id/unarchive',
    async (req, reply) => {
      const rows = await sql<Session[]>`
        UPDATE sessions SET status = 'open', updated_at = clock_timestamp()
        WHERE id = ${req.params.id} AND status = 'archived'
        RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
      `;
      if (rows.length === 0) {
        reply.code(404);
        return { error: 'session not found or not archived' };
      }
      const session = rows[0]!;
      broker.publishUserFrame('default', {
        type: 'session_created',
        session: session,
        project_id: session.project_id,
      });
      reply.code(200);
      return session;
    }
  );

  app.delete<{ Params: { id: string }; Querystring: { force?: string } }>(
    '/api/sessions/:id',
    async (req, reply) => {
      const id = req.params.id;
      const force = req.query.force === 'true' || req.query.force === '1';

      // Session-delete work-loss guard. The check MUST run BEFORE the DELETE:
      // worktrees.session_id is ON DELETE SET NULL (P1.5-b), so once the session
      // is gone the worktree rows no longer point back to it — read them while
      // the link still exists.
      //
      // Optimization: read worktrees (P1.5-b — was session_worktrees) from our
      // own (shared) DB first. No row => chat-only session => nothing on disk =>
      // delete immediately, zero round-trip. Only worktree-backed sessions pay
      // the host git check.
      if (!force) {
        const worktrees = await sql<{ path: string }[]>`
          SELECT path FROM worktrees WHERE session_id = ${id}
        `;
        if (worktrees.length > 0) {
          // Worktree dirs live on the host; only BooCoder can run git on them.
          const origin = process.env.BOOCODER_URL ?? 'http://boocoder:3000';
          let reports: WorktreeRiskReport[];
          try {
            const res = await fetch(`${origin}/api/sessions/${id}/worktree-risk`);
            if (!res.ok) {
              // Fail-closed: can't verify => don't risk silent loss. Force escapes.
              reply.code(409);
              return {
                error: 'could not verify worktree safety (BooCoder check failed). Use force to delete anyway.',
                reports: [] as WorktreeRiskReport[],
              };
            }
            reports = ((await res.json()) as { reports?: WorktreeRiskReport[] }).reports ?? [];
          } catch {
            // Fail-closed: BooCoder unreachable. Force bypasses this path entirely.
            reply.code(409);
            return {
              error: 'BooCoder unreachable; cannot verify worktree safety. Use force to delete anyway.',
              reports: [] as WorktreeRiskReport[],
            };
          }
          if (reports.some((r) => r.atRisk)) {
            reply.code(409);
            return { error: 'This session has work at risk in its worktree.', reports };
          }
        }
      }

      const deleted = await sql<{ project_id: string }[]>`
        DELETE FROM sessions WHERE id = ${id} RETURNING project_id
      `;
      if (deleted.length === 0) {
        reply.code(404);
        return { error: 'not found' };
      }
      const project_id = deleted[0]!.project_id;
      broker.publishUserFrame('default', { type: 'session_deleted', session_id: id, project_id });
      // Fire-and-forget: ask BooCoder to tear down this session's warm agent
      // backends + worktree immediately. Best-effort — never blocks/fails the
      // delete; the coder's idle-evict + orphan reaper backstop a missed call.
      void notifyCoderClose('session', id, req.log);
      reply.code(204);
      return null;
    }
  );
}
