/**
 * MCP Server — registers SF orchestration, project-state, and workflow tools.
 *
 * Session tools (6): sf_execute, sf_status, sf_result, sf_cancel, sf_query, sf_resolve_blocker
 * Interactive tools (2): ask_user_questions, secure_env_collect via MCP form elicitation
 * Read-only tools (6): sf_progress, sf_roadmap, sf_history, sf_doctor, sf_captures, sf_knowledge
 * Workflow tools (29): headless-safe planning, metadata persistence, replanning, completion, validation, reassessment, gate result, status, and journal tools
 *
 * Uses dynamic imports for @modelcontextprotocol/sdk because TS Node16
 * cannot resolve the SDK's subpath exports statically (same pattern as
 * src/mcp-server.ts in the main package).
 */

import { readFile, readdir, stat } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { z } from 'zod';
import type { SessionManager } from './session-manager.js';
import { readProgress } from './readers/state.js';
import { readRoadmap } from './readers/roadmap.js';
import { readHistory } from './readers/metrics.js';
import { readCaptures } from './readers/captures.js';
import { readKnowledge } from './readers/knowledge.js';
import { buildGraph, writeGraph, writeSnapshot, graphStatus, graphQuery, graphDiff } from './readers/graph.js';
import { resolveSFRoot } from './readers/paths.js';
import { runDoctorLite } from './readers/doctor-lite.js';
import { registerWorkflowTools } from './workflow-tools.js';
import { applySecrets, checkExistingEnvKeys, detectDestination } from './env-writer.js';

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const MCP_PKG = '@modelcontextprotocol/sdk';
const SERVER_NAME = 'sf';
const SERVER_VERSION = '2.53.0';

// ---------------------------------------------------------------------------
// Tool result helpers
// ---------------------------------------------------------------------------

/** Wrap a JSON-serializable value as MCP tool content. */
function jsonContent(data: unknown): { content: Array<{ type: 'text'; text: string }> } {
  return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}

/** Return an MCP error response. */
function errorContent(message: string): { isError: true; content: Array<{ type: 'text'; text: string }> } {
  return { isError: true, content: [{ type: 'text' as const, text: message }] };
}

/** Return raw text content without JSON wrapping. */
function textContent(text: string): { content: Array<{ type: 'text'; text: string }> } {
  return { content: [{ type: 'text' as const, text }] };
}

// ---------------------------------------------------------------------------
// sf_query filesystem reader
// ---------------------------------------------------------------------------

/**
 * Normalized query categories for {@link readProjectState}.
 *
 * Maps user-supplied query strings (or empty) to the set of fields we return.
 * Accepts common synonyms so the MCP client can pass intuitive values.
 */
const QUERY_FIELDS = {
  all: ['state', 'project', 'requirements', 'milestones'] as const,
  state: ['state'] as const,
  status: ['state'] as const,
  project: ['project'] as const,
  requirements: ['requirements'] as const,
  milestones: ['milestones'] as const,
} as const;

type QueryCategory = keyof typeof QUERY_FIELDS;
type ProjectStateField = (typeof QUERY_FIELDS)[QueryCategory][number];

function normalizeQuery(query: string | undefined): QueryCategory {
  const key = (query ?? 'all').trim().toLowerCase();
  if (key in QUERY_FIELDS) return key as QueryCategory;
  return 'all';
}

async function readProjectState(projectDir: string, query: string | undefined): Promise<Record<string, unknown>> {
  const sfDir = join(resolve(projectDir), '.sf');
  const category = normalizeQuery(query);
  const wanted = new Set<ProjectStateField>(QUERY_FIELDS[category]);

  const result: Record<string, unknown> = {
    projectDir: resolve(projectDir),
    query: category,
  };

  if (wanted.has('state')) {
    try {
      result.state = await readFile(join(sfDir, 'STATE.md'), 'utf-8');
    } catch {
      result.state = null;
    }
  }

  if (wanted.has('project')) {
    try {
      result.project = await readFile(join(sfDir, 'PROJECT.md'), 'utf-8');
    } catch {
      result.project = null;
    }
  }

  if (wanted.has('requirements')) {
    try {
      result.requirements = await readFile(join(sfDir, 'REQUIREMENTS.md'), 'utf-8');
    } catch {
      result.requirements = null;
    }
  }

  if (wanted.has('milestones')) {
    const milestonesDir = join(sfDir, 'milestones');
    try {
      const entries = await readdir(milestonesDir, { withFileTypes: true });
      const milestones: Array<{ id: string; hasRoadmap: boolean; hasSummary: boolean }> = [];
      for (const entry of entries) {
        if (!entry.isDirectory()) continue;
        const mDir = join(milestonesDir, entry.name);
        const hasRoadmap = await fileExists(join(mDir, `${entry.name}-ROADMAP.md`));
        const hasSummary = await fileExists(join(mDir, `${entry.name}-SUMMARY.md`));
        milestones.push({ id: entry.name, hasRoadmap, hasSummary });
      }
      result.milestones = milestones;
    } catch {
      result.milestones = [];
    }
  }

  return result;
}

async function fileExists(path: string): Promise<boolean> {
  try {
    await stat(path);
    return true;
  } catch {
    return false;
  }
}

// ---------------------------------------------------------------------------
// MCP Server type — minimal interface for the dynamically-imported McpServer
// ---------------------------------------------------------------------------

interface ElicitResult {
  action: 'accept' | 'decline' | 'cancel';
  content?: Record<string, string | number | boolean | string[]>;
}

interface ElicitRequestFormParams {
  mode?: 'form';
  message: string;
  requestedSchema: {
    type: 'object';
    properties: Record<string, Record<string, unknown>>;
    required?: string[];
  };
}

/**
 * Handler extra — the second argument passed by McpServer.tool handlers.
 * Contains an AbortSignal scoped to the JSON-RPC request (cancelled when
 * the client cancels the `tools/call`) plus other per-request metadata.
 * Tools that can actually be stopped mid-flight should honour `signal`.
 */
export interface McpToolExtra {
  signal?: AbortSignal;
  requestId?: string | number;
  sendNotification?: (notification: unknown) => void | Promise<void>;
}

interface McpServerInstance {
  tool(
    name: string,
    description: string,
    params: Record<string, unknown>,
    handler: (args: Record<string, unknown>, extra?: McpToolExtra) => Promise<unknown>,
  ): unknown;
  server: {
    elicitInput(
      params: AskUserQuestionsElicitRequest | ElicitRequestFormParams,
      options?: unknown,
    ): Promise<AskUserQuestionsElicitResult>;
  };
  connect(transport: unknown): Promise<void>;
  close(): Promise<void>;
}

interface AskUserQuestionOption {
  label: string;
  description: string;
}

interface AskUserQuestion {
  id: string;
  header: string;
  question: string;
  options: AskUserQuestionOption[];
  allowMultiple?: boolean;
}

interface AskUserQuestionsParams {
  questions: AskUserQuestion[];
}

type AskUserQuestionsContentValue = string | number | boolean | string[];

interface AskUserQuestionsElicitResult {
  action: 'accept' | 'decline' | 'cancel';
  content?: Record<string, AskUserQuestionsContentValue>;
}

interface AskUserQuestionsElicitRequest {
  mode: 'form';
  message: string;
  requestedSchema: {
    type: 'object';
    properties: Record<string, Record<string, unknown>>;
    required?: string[];
  };
}

const OTHER_OPTION_LABEL = 'None of the above';

function normalizeAskUserQuestionsNote(value: AskUserQuestionsContentValue | undefined): string {
  return typeof value === 'string' ? value.trim() : '';
}

function normalizeAskUserQuestionsAnswers(
  value: AskUserQuestionsContentValue | undefined,
  allowMultiple: boolean,
): string[] {
  if (allowMultiple) {
    return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
  }

  return typeof value === 'string' && value.length > 0 ? [value] : [];
}

function validateAskUserQuestionsPayload(questions: AskUserQuestion[]): string | null {
  if (questions.length === 0 || questions.length > 3) {
    return 'Error: questions must contain 1-3 items';
  }

  for (const question of questions) {
    if (!question.options || question.options.length === 0) {
      return `Error: ask_user_questions requires non-empty options for every question (question "${question.id}" has none)`;
    }
  }

  return null;
}

export function buildAskUserQuestionsElicitRequest(questions: AskUserQuestion[]): AskUserQuestionsElicitRequest {
  const properties: Record<string, Record<string, unknown>> = {};
  const required = questions.map((question) => question.id);

  for (const question of questions) {
    if (question.allowMultiple) {
      properties[question.id] = {
        type: 'array',
        title: question.header,
        description: question.question,
        minItems: 1,
        maxItems: question.options.length,
        items: {
          anyOf: question.options.map((option) => ({
            const: option.label,
            title: option.label,
          })),
        },
      };
      continue;
    }

    properties[question.id] = {
      type: 'string',
      title: question.header,
      description: question.question,
      oneOf: [...question.options, { label: OTHER_OPTION_LABEL, description: 'Choose this when the listed options do not fit.' }].map((option) => ({
        const: option.label,
        title: option.label,
      })),
    };

    properties[`${question.id}__note`] = {
      type: 'string',
      title: `${question.header} Note`,
      description: `Optional note for "${OTHER_OPTION_LABEL}".`,
      maxLength: 500,
    };
  }

  return {
    mode: 'form',
    message: 'Please answer the following question(s). For single-select questions, choose "None of the above" and add a note if the provided options do not fit.',
    requestedSchema: {
      type: 'object',
      properties,
      required,
    },
  };
}

export function formatAskUserQuestionsElicitResult(
  questions: AskUserQuestion[],
  result: AskUserQuestionsElicitResult,
): string {
  const answers: Record<string, { answers: string[] }> = {};
  const content = result.content ?? {};

  for (const question of questions) {
    const answerList = normalizeAskUserQuestionsAnswers(content[question.id], !!question.allowMultiple);

    if (!question.allowMultiple && answerList[0] === OTHER_OPTION_LABEL) {
      const note = normalizeAskUserQuestionsNote(content[`${question.id}__note`]);
      if (note) {
        answerList.push(`user_note: ${note}`);
      }
    }

    answers[question.id] = { answers: answerList };
  }

  return JSON.stringify({ answers });
}

// ---------------------------------------------------------------------------
// createMcpServer
// ---------------------------------------------------------------------------

/**
 * Create and configure an MCP server with session, read-only, and workflow tools.
 *
 * Returns the McpServer instance — call `connect(transport)` to start serving.
 * Uses dynamic imports for the MCP SDK to avoid TS subpath resolution issues.
 */
export async function createMcpServer(sessionManager: SessionManager): Promise<{
  server: McpServerInstance;
}> {
  // Dynamic import — same workaround as src/mcp-server.ts
  const mcpMod = await import(`${MCP_PKG}/server/mcp.js`);
  const McpServer = mcpMod.McpServer;

  const server: McpServerInstance = new McpServer(
    { name: SERVER_NAME, version: SERVER_VERSION },
    { capabilities: { tools: {}, elicitation: {} } },
  );

  // -----------------------------------------------------------------------
  // sf_execute — start a new SF auto-mode session.
  //
  // If the JSON-RPC request is aborted while the session is starting (or
  // immediately after), we cancel the session so we don't leak a background
  // RpcClient process. Once the session is running the caller should use
  // `sf_cancel` to stop it via sessionId.
  // -----------------------------------------------------------------------
  server.tool(
    'sf_execute',
    'Start a SF auto-mode session for a project directory. Returns a sessionId for tracking.',
    {
      projectDir: z.string().describe('Absolute path to the project directory'),
      command: z.string().optional().describe('Command to send (default: "/sf auto")'),
      model: z.string().optional().describe('Model ID override'),
      bare: z.boolean().optional().describe('Run in bare mode (skip user config)'),
    },
    async (args: Record<string, unknown>, extra?: McpToolExtra) => {
      const { projectDir, command, model, bare } = args as {
        projectDir: string; command?: string; model?: string; bare?: boolean;
      };
      try {
        const sessionId = await sessionManager.startSession(projectDir, { command, model, bare });

        // If the client aborted while startSession was running, cancel the
        // newly-created session rather than leaving an orphaned process.
        if (extra?.signal?.aborted) {
          await sessionManager.cancelSession(sessionId).catch(() => { /* swallow */ });
          return errorContent('sf_execute aborted by client before returning');
        }

        return jsonContent({ sessionId, status: 'started' });
      } catch (err) {
        return errorContent(err instanceof Error ? err.message : String(err));
      }
    },
  );

  // -----------------------------------------------------------------------
  // sf_status — poll session status
  // -----------------------------------------------------------------------
  server.tool(
    'sf_status',
    'Get the current status of a SF session including progress, recent events, and pending blockers.',
    {
      sessionId: z.string().describe('Session ID returned from sf_execute'),
    },
    async (args: Record<string, unknown>) => {
      const { sessionId } = args as { sessionId: string };
      try {
        const session = sessionManager.getSession(sessionId);
        if (!session) return errorContent(`Session not found: ${sessionId}`);

        const durationMs = Date.now() - session.startTime;
        const toolCallCount = session.events.filter(
          (e) => (e as Record<string, unknown>).type === 'tool_use' ||
                 (e as Record<string, unknown>).type === 'tool_execution_start'
        ).length;

        return jsonContent({
          status: session.status,
          progress: {
            eventCount: session.events.length,
            toolCalls: toolCallCount,
          },
          recentEvents: session.events.slice(-10),
          pendingBlocker: session.pendingBlocker
            ? {
                id: session.pendingBlocker.id,
                method: session.pendingBlocker.method,
                message: session.pendingBlocker.message,
              }
            : null,
          cost: session.cost,
          durationMs,
        });
      } catch (err) {
        return errorContent(err instanceof Error ? err.message : String(err));
      }
    },
  );

  // -----------------------------------------------------------------------
  // sf_result — get accumulated session result
  // -----------------------------------------------------------------------
  server.tool(
    'sf_result',
    'Get the result of a SF session. Returns partial results if the session is still running.',
    {
      sessionId: z.string().describe('Session ID returned from sf_execute'),
    },
    async (args: Record<string, unknown>) => {
      const { sessionId } = args as { sessionId: string };
      try {
        const result = sessionManager.getResult(sessionId);
        return jsonContent(result);
      } catch (err) {
        return errorContent(err instanceof Error ? err.message : String(err));
      }
    },
  );

  // -----------------------------------------------------------------------
  // sf_cancel — cancel a running session
  // -----------------------------------------------------------------------
  server.tool(
    'sf_cancel',
    'Cancel a running SF session. Aborts the current operation and stops the process.',
    {
      sessionId: z.string().describe('Session ID returned from sf_execute'),
    },
    async (args: Record<string, unknown>) => {
      const { sessionId } = args as { sessionId: string };
      try {
        await sessionManager.cancelSession(sessionId);
        return jsonContent({ cancelled: true });
      } catch (err) {
        return errorContent(err instanceof Error ? err.message : String(err));
      }
    },
  );

  // -----------------------------------------------------------------------
  // sf_query — read project state from filesystem (no session needed).
  //
  // `query` is optional: when omitted the tool returns all fields (STATE.md,
  // PROJECT.md, requirements, milestone listing). Accepted narrow values:
  // "state" / "status", "project", "requirements", "milestones", "all".
  // Unknown values fall back to "all" for forward-compatibility.
  // -----------------------------------------------------------------------
  server.tool(
    'sf_query',
    'Query SF project state from the filesystem. By default returns STATE.md, PROJECT.md, requirements, and milestone listing. Pass `query` to narrow the response (accepted: "state"/"status", "project", "requirements", "milestones", "all"). Does not require an active session.',
    {
      projectDir: z.string().describe('Absolute path to the project directory'),
      query: z
        .enum(['all', 'state', 'status', 'project', 'requirements', 'milestones'])
        .optional()
        .describe('Narrow the response to a single field (default: "all")'),
    },
    async (args: Record<string, unknown>) => {
      const { projectDir, query } = args as { projectDir: string; query?: string };
      try {
        const state = await readProjectState(projectDir, query);
        return jsonContent(state);
      } catch (err) {
        return errorContent(err instanceof Error ? err.message : String(err));
      }
    },
  );

  // -----------------------------------------------------------------------
  // sf_resolve_blocker — resolve a pending blocker
  // -----------------------------------------------------------------------
  server.tool(
    'sf_resolve_blocker',
    'Resolve a pending blocker in a SF session by sending a response to the UI request.',
    {
      sessionId: z.string().describe('Session ID returned from sf_execute'),
      response: z.string().describe('Response to send for the pending blocker'),
    },
    async (args: Record<string, unknown>) => {
      const { sessionId, response } = args as { sessionId: string; response: string };
      try {
        await sessionManager.resolveBlocker(sessionId, response);
        return jsonContent({ resolved: true });
      } catch (err) {
        return errorContent(err instanceof Error ? err.message : String(err));
      }
    },
  );

  // -----------------------------------------------------------------------
  // ask_user_questions — structured user input via MCP form elicitation
  // -----------------------------------------------------------------------
  server.tool(
    'ask_user_questions',
    'Request user input for one to three short questions and wait for the response. Single-select questions include a free-form "None of the above" path. Multi-select questions allow multiple choices.',
    {
      questions: z.array(z.object({
        id: z.string().describe('Stable identifier for mapping answers (snake_case)'),
        header: z.string().describe('Short header label shown in the UI (12 or fewer chars)'),
        question: z.string().describe('Single-sentence prompt shown to the user'),
        options: z.array(z.object({
          label: z.string().describe('User-facing label (1-5 words)'),
          description: z.string().describe('One short sentence explaining impact/tradeoff if selected'),
        })).describe('Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with "(Recommended)". Do not include an "Other" option for single-select questions.'),
        allowMultiple: z.boolean().optional().describe('If true, the user can select multiple options. No "None of the above" option is added.'),
      })).describe('Questions to show the user. Prefer 1 and do not exceed 3.'),
    },
    async (args: Record<string, unknown>) => {
      const { questions } = args as unknown as AskUserQuestionsParams;
      try {
        const validationError = validateAskUserQuestionsPayload(questions);
        if (validationError) return errorContent(validationError);

        const elicitation = await server.server.elicitInput(buildAskUserQuestionsElicitRequest(questions));
        if (elicitation.action !== 'accept' || !elicitation.content) {
          return textContent('ask_user_questions was cancelled before receiving a response');
        }

        return textContent(formatAskUserQuestionsElicitResult(questions, elicitation));
      } catch (err) {
        return errorContent(err instanceof Error ? err.message : String(err));
      }
    },
  );

  // -----------------------------------------------------------------------
  // secure_env_collect — collect secrets via MCP form elicitation
  // -----------------------------------------------------------------------
  server.tool(
    'secure_env_collect',
    'Collect environment variables securely via form input. Values are written directly to .env (or Vercel/Convex) and NEVER appear in tool output — only key names and applied/skipped status are returned. Use this instead of asking users to manually edit .env files or paste secrets into chat.',
    {
      projectDir: z.string().describe('Absolute path to the project directory'),
      keys: z.array(z.object({
        key: z.string().describe('Env var name, e.g. OPENAI_API_KEY'),
        hint: z.string().optional().describe('Format hint shown to user, e.g. "starts with sk-"'),
        guidance: z.array(z.string()).optional().describe('Step-by-step instructions for obtaining this key'),
      })).min(1).describe('Environment variables to collect'),
      destination: z.enum(['dotenv', 'vercel', 'convex']).optional().describe('Where to write secrets. Auto-detected from project files if omitted.'),
      envFilePath: z.string().optional().describe('Path to .env file (dotenv only). Defaults to .env in projectDir.'),
      environment: z.enum(['development', 'preview', 'production']).optional().describe('Target environment (vercel/convex only)'),
    },
    async (args: Record<string, unknown>) => {
      const { projectDir, keys, destination, envFilePath, environment } = args as {
        projectDir: string;
        keys: Array<{ key: string; hint?: string; guidance?: string[] }>;
        destination?: 'dotenv' | 'vercel' | 'convex';
        envFilePath?: string;
        environment?: 'development' | 'preview' | 'production';
      };

      try {
        const resolvedProjectDir = resolve(projectDir);
        const resolvedEnvPath = resolve(resolvedProjectDir, envFilePath ?? '.env');

        // (1) Check which keys already exist
        const allKeyNames = keys.map((k) => k.key);
        const existingKeys = await checkExistingEnvKeys(allKeyNames, resolvedEnvPath);
        const existingSet = new Set(existingKeys);
        const pendingKeys = keys.filter((k) => !existingSet.has(k.key));

        // If all keys already exist, return immediately
        if (pendingKeys.length === 0) {
          const lines = existingKeys.map((k) => `• ${k}: already set`);
          return textContent(`All ${existingKeys.length} key(s) already set.\n${lines.join('\n')}`);
        }

        // (2) Build elicitation form — one string field per pending key
        const properties: Record<string, Record<string, unknown>> = {};
        const required: string[] = [];

        for (const item of pendingKeys) {
          const descParts: string[] = [];
          if (item.hint) descParts.push(`Format: ${item.hint}`);
          if (item.guidance && item.guidance.length > 0) {
            descParts.push('How to get this:');
            item.guidance.forEach((step, i) => descParts.push(`${i + 1}. ${step}`));
          }
          descParts.push('Leave empty to skip.');

          properties[item.key] = {
            type: 'string',
            title: item.key,
            description: descParts.join('\n'),
          };
          // Don't mark as required — empty string = skip
        }

        // (3) Elicit input from the MCP client
        const elicitation = await server.server.elicitInput({
          message: `Enter values for ${pendingKeys.length} environment variable(s). Values are written directly to the project and never shown to the AI.`,
          requestedSchema: {
            type: 'object',
            properties,
            required,
          },
        });

        if (elicitation.action !== 'accept' || !elicitation.content) {
          return textContent('secure_env_collect was cancelled by user.');
        }

        // (4) Separate provided vs skipped from form response
        const provided: Array<{ key: string; value: string }> = [];
        const skipped: string[] = [];

        for (const item of pendingKeys) {
          const raw = elicitation.content[item.key];
          const value = typeof raw === 'string' ? raw.trim() : '';
          if (value.length > 0) {
            provided.push({ key: item.key, value });
          } else {
            skipped.push(item.key);
          }
        }

        // (5) Auto-detect destination if not specified
        const resolvedDestination = destination ?? detectDestination(resolvedProjectDir);

        // (6) Write secrets to destination
        const { applied, errors } = await applySecrets(provided, resolvedDestination, {
          envFilePath: resolvedEnvPath,
          environment,
        });

        // (7) Build result — NEVER include secret values
        const lines: string[] = [
          `destination: ${resolvedDestination}${!destination ? ' (auto-detected)' : ''}${environment ? ` (${environment})` : ''}`,
        ];
        for (const k of applied) lines.push(`✓ ${k}: applied`);
        for (const k of skipped) lines.push(`• ${k}: skipped`);
        for (const k of existingKeys) lines.push(`• ${k}: already set`);
        for (const e of errors) lines.push(`✗ ${e}`);

        return errors.length > 0 && applied.length === 0
          ? errorContent(lines.join('\n'))
          : textContent(lines.join('\n'));
      } catch (err) {
        return errorContent(err instanceof Error ? err.message : String(err));
      }
    },
  );

  // =======================================================================
  // READ-ONLY TOOLS — no session required, pure filesystem reads
  // =======================================================================

  // -----------------------------------------------------------------------
  // sf_progress — structured project progress metrics
  // -----------------------------------------------------------------------
  server.tool(
    'sf_progress',
    'Get structured project progress: active milestone/slice/task, phase, completion counts, blockers, and next action. No session required — reads directly from .sf/ on disk.',
    {
      projectDir: z.string().describe('Absolute path to the project directory'),
    },
    async (args: Record<string, unknown>) => {
      const { projectDir } = args as { projectDir: string };
      try {
        return jsonContent(readProgress(projectDir));
      } catch (err) {
        return errorContent(err instanceof Error ? err.message : String(err));
      }
    },
  );

  // -----------------------------------------------------------------------
  // sf_roadmap — milestone/slice/task structure with status
  // -----------------------------------------------------------------------
  server.tool(
    'sf_roadmap',
    'Get the full project roadmap structure: milestones with their slices, tasks, status, risk, and dependencies. Optionally filter to a single milestone. No session required.',
    {
      projectDir: z.string().describe('Absolute path to the project directory'),
      milestoneId: z.string().optional().describe('Filter to a specific milestone (e.g. "M001")'),
    },
    async (args: Record<string, unknown>) => {
      const { projectDir, milestoneId } = args as { projectDir: string; milestoneId?: string };
      try {
        return jsonContent(readRoadmap(projectDir, milestoneId));
      } catch (err) {
        return errorContent(err instanceof Error ? err.message : String(err));
      }
    },
  );

  // -----------------------------------------------------------------------
  // sf_history — execution history with cost/token metrics
  // -----------------------------------------------------------------------
  server.tool(
    'sf_history',
    'Get execution history with cost, token usage, model, and duration per unit. Returns totals across all units. No session required.',
    {
      projectDir: z.string().describe('Absolute path to the project directory'),
      limit: z.number().optional().describe('Max entries to return (most recent first). Default: all.'),
    },
    async (args: Record<string, unknown>) => {
      const { projectDir, limit } = args as { projectDir: string; limit?: number };
      try {
        return jsonContent(readHistory(projectDir, limit));
      } catch (err) {
        return errorContent(err instanceof Error ? err.message : String(err));
      }
    },
  );

  // -----------------------------------------------------------------------
  // sf_doctor — lightweight structural health check
  // -----------------------------------------------------------------------
  server.tool(
    'sf_doctor',
    'Run a lightweight structural health check on the .sf/ directory. Checks for missing files, status inconsistencies, and orphaned state. No session required.',
    {
      projectDir: z.string().describe('Absolute path to the project directory'),
      scope: z.string().optional().describe('Limit checks to a specific milestone (e.g. "M001")'),
    },
    async (args: Record<string, unknown>) => {
      const { projectDir, scope } = args as { projectDir: string; scope?: string };
      try {
        return jsonContent(runDoctorLite(projectDir, scope));
      } catch (err) {
        return errorContent(err instanceof Error ? err.message : String(err));
      }
    },
  );

  // -----------------------------------------------------------------------
  // sf_captures — pending captures and ideas
  // -----------------------------------------------------------------------
  server.tool(
    'sf_captures',
    'Get captured ideas and thoughts from CAPTURES.md with triage status. Filter by pending, actionable, or all. No session required.',
    {
      projectDir: z.string().describe('Absolute path to the project directory'),
      filter: z.enum(['all', 'pending', 'actionable']).optional().describe('Filter captures (default: "all")'),
    },
    async (args: Record<string, unknown>) => {
      const { projectDir, filter } = args as { projectDir: string; filter?: 'all' | 'pending' | 'actionable' };
      try {
        return jsonContent(readCaptures(projectDir, filter ?? 'all'));
      } catch (err) {
        return errorContent(err instanceof Error ? err.message : String(err));
      }
    },
  );

  // -----------------------------------------------------------------------
  // sf_knowledge — project knowledge base
  // -----------------------------------------------------------------------
  server.tool(
    'sf_knowledge',
    'Get the project knowledge base: rules, patterns, and lessons learned accumulated during development. No session required.',
    {
      projectDir: z.string().describe('Absolute path to the project directory'),
    },
    async (args: Record<string, unknown>) => {
      const { projectDir } = args as { projectDir: string };
      try {
        return jsonContent(readKnowledge(projectDir));
      } catch (err) {
        return errorContent(err instanceof Error ? err.message : String(err));
      }
    },
  );

  // -----------------------------------------------------------------------
  // sf_graph — knowledge graph for SF projects
  //
  // Modes:
  //   build   Parse .sf/ artifacts and write graph.json atomically.
  //   query   Search the graph for nodes matching a term (BFS, budget-trimmed).
  //   status  Check whether graph.json exists and whether it is stale (>24h).
  //   diff    Compare graph.json with the last build snapshot.
  // -----------------------------------------------------------------------
  server.tool(
    'sf_graph',
    [
      'Manage the SF project knowledge graph. No session required.',
      '',
      'Modes:',
      '  build   Parse .sf/ artifacts (STATE.md, milestone ROADMAPs, slice PLANs,',
      '          KNOWLEDGE.md) and write .sf/graphs/graph.json atomically.',
      '  query   Search graph nodes by term (BFS from seed matches, budget-trimmed).',
      '          Returns matching nodes and reachable edges within the token budget.',
      '  status  Show whether graph.json exists, its age, node/edge counts, and',
      '          whether it is stale (built more than 24 hours ago).',
      '  diff    Compare current graph.json with .last-build-snapshot.json.',
      '          Returns added, removed, and changed nodes and edges.',
    ].join('\n'),
    {
      projectDir: z.string().describe('Absolute path to the project directory'),
      mode: z.enum(['build', 'query', 'status', 'diff']).describe(
        'Operation: build | query | status | diff',
      ),
      term: z.string().optional().describe('Search term for query mode (case-insensitive)'),
      budget: z.number().optional().describe('Token budget for query mode (default: 4000)'),
      snapshot: z.boolean().optional().describe('Write snapshot before build (for future diff)'),
    },
    async (args: Record<string, unknown>) => {
      const { projectDir, mode, term, budget, snapshot } = args as {
        projectDir: string;
        mode: 'build' | 'query' | 'status' | 'diff';
        term?: string;
        budget?: number;
        snapshot?: boolean;
      };

      try {
        const sfRoot = resolveSFRoot(projectDir);

        switch (mode) {
          case 'build': {
            if (snapshot) {
              await writeSnapshot(sfRoot).catch(() => { /* best-effort */ });
            }
            const graph = await buildGraph(projectDir);
            await writeGraph(sfRoot, graph);
            return jsonContent({
              built: true,
              nodeCount: graph.nodes.length,
              edgeCount: graph.edges.length,
              builtAt: graph.builtAt,
            });
          }

          case 'query': {
            const result = await graphQuery(projectDir, term ?? '', budget);
            return jsonContent(result);
          }

          case 'status': {
            const result = await graphStatus(projectDir);
            return jsonContent(result);
          }

          case 'diff': {
            const result = await graphDiff(projectDir);
            return jsonContent(result);
          }
        }
      } catch (err) {
        return errorContent(err instanceof Error ? err.message : String(err));
      }
    },
  );

  registerWorkflowTools(server);

  return { server };
}
