import { readFile, readdir, stat } from 'node:fs/promises';
import { resolve, basename, relative } from 'node:path';
import { z } from 'zod';
import type { Sql } from '../db.js';
import { pathGuard, PathScopeError } from './path_guard.js';
import { isSecretPath, SecretBlockedError, filterSecretEntries } from './secret_guard.js';
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
import { getGitMeta } from './git_meta.js';
import { findSkills, getSkillBody, getSkillResource } from './skills.js';
import { webSearch } from './web_search.js';
import { webFetch } from './web_fetch.js';
import { readTruncation, truncateIfNeeded } from './truncate.js';
// v1.12 Track B.2: codecontext tools. 8 wrappers re-exported from
// tools/codecontext/index.ts. Each calls into services/codecontext_client.ts
// which talks to the codecontext sidecar at http://codecontext:8080.
import {
  getCodebaseOverview,
  getFileAnalysis,
  getSymbolInfo,
  searchSymbols,
  getDependencies,
  watchChanges,
  getSemanticNeighborhoods,
  getFrameworkAnalysis,
  getBlastRadius,
  getHotFiles,
  getRoutes,
  getMiddleware,
} from './tools/codecontext/index.js';
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
// POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts.
import { requestReadAccess } from './request_read_access.js';
// v2.6.x: read-only tool that reads a tab's transcript by its session-scoped
// tab number. Needs DB/session context (ToolExecCtx 4th arg).
import { readTabByNumber } from './read_tab_by_number.js';

const MAX_FILE_BYTES = 5 * 1024 * 1024;
const DEFAULT_VIEW_LINES = 200;
const MAX_GREP_RESULTS = 200;
const DEFAULT_GREP_RESULTS = 100;
const MAX_FIND_RESULTS = 200;
const DEFAULT_FIND_RESULTS = 100;
const MAX_DIR_ENTRIES = 500;

export interface ToolJsonSchema {
  type: 'function';
  function: {
    name: string;
    description: string;
    parameters: Record<string, unknown>;
  };
}

// v2.6.x: optional DB/session context threaded into a tool's execute(). Only
// tools that need to read session-scoped DB state (e.g. read_tab_by_number)
// use it; every other tool ignores the 4th arg. Kept optional so existing
// 3-arg execute() implementations stay assignable (apps/coder consumes this
// type from the compiled dist — the optional param keeps it backward-compatible).
export interface ToolExecCtx {
  sql: Sql;
  sessionId: string;
}

export interface ToolDef<TInput> {
  name: string;
  description: string;
  inputSchema: z.ZodType<TInput>;
  jsonSchema: ToolJsonSchema;
  // v1.13.17-cross-repo-reads: extraRoots is the session's
  // allowed_read_paths, threaded through executeToolCall in tool-phase.ts.
  // Only the filesystem tools (view_file, list_dir, grep, find_files,
  // view_truncated_output) forward it to pathGuard; other tools accept the
  // arg and ignore it. The execute signature stays compatible with
  // pre-v1.13.17 callsites because the parameter is optional.
  // v2.6.x: optional 4th param toolCtx carries DB/session context for tools
  // that read session-scoped state (read_tab_by_number). Optional so 3-arg
  // implementations remain assignable.
  execute(
    input: TInput,
    projectRoot: string,
    extraRoots?: readonly string[],
    toolCtx?: ToolExecCtx,
  ): Promise<unknown>;
}

const ViewFileInput = z.object({
  path: z.string().min(1),
  start_line: z.number().int().positive().optional(),
  end_line: z.number().int().positive().optional(),
});
type ViewFileInputT = z.infer<typeof ViewFileInput>;

export const viewFile: ToolDef<ViewFileInputT> = {
  name: 'view_file',
  description:
    "Read a file under the project. Returns first 200 lines by default, or a slice via start_line/end_line (1-indexed, inclusive). Files larger than 5MB are refused. Output is truncated if longer than the slice; the response indicates truncation.",
  inputSchema: ViewFileInput,
  jsonSchema: {
    type: 'function',
    function: {
      name: 'view_file',
      description:
        "Read a file under the project. Returns first 200 lines by default, or a slice via start_line/end_line (1-indexed, inclusive). Files larger than 5MB are refused.",
      parameters: {
        type: 'object',
        properties: {
          path: { type: 'string', description: 'absolute or project-relative path' },
          start_line: { type: 'integer', description: 'first line (1-indexed)' },
          end_line: { type: 'integer', description: 'last line (1-indexed, inclusive)' },
        },
        required: ['path'],
        additionalProperties: false,
      },
    },
  },
  async execute(input, projectRoot, extraRoots) {
    const real = await pathGuard(projectRoot, input.path, extraRoots);
    // v1.11.7: secret-file deny check. Test the project-relative path
    // (matches the form continue.dev's patterns expect: basenames + dir
    // segments). Throw a typed error so executeToolCall in inference.ts
    // surfaces a clear "blocked" message to the LLM instead of silently
    // returning content the user wanted hidden.
    // v1.13.17: when the resolved path is outside the primary projectRoot
    // (i.e. via an allowed_read_paths grant), `relative()` returns "../…"
    // which won't match secret-file basename patterns. Re-anchor on the
    // file's basename so the secret deny still fires across all grant roots.
    const rel = relative(projectRoot, real);
    const relPath = rel && !rel.startsWith('..') ? rel : basename(real);
    if (isSecretPath(relPath)) {
      throw new SecretBlockedError(relPath);
    }
    const s = await stat(real);
    if (!s.isFile()) {
      throw new PathScopeError(`not a file: ${input.path}`);
    }
    if (s.size > MAX_FILE_BYTES) {
      throw new Error(`file too large (${s.size} bytes, max ${MAX_FILE_BYTES})`);
    }
    const raw = await readFile(real, 'utf8');
    const lines = raw.split('\n');
    const total = lines.length;
    let start = input.start_line ?? 1;
    let end = input.end_line ?? Math.min(total, start + DEFAULT_VIEW_LINES - 1);
    if (input.start_line == null && input.end_line == null) {
      end = Math.min(total, DEFAULT_VIEW_LINES);
    }
    if (start < 1) start = 1;
    if (end > total) end = total;
    if (end < start) end = start;
    const slice = lines.slice(start - 1, end);
    const content = slice.join('\n');
    const truncated = total > end || start > 1;
    // v1.13.5: stash the full file on tmpfs so the model can retrieve more
    // via view_truncated_output(id) without re-reading the file (which it
    // may not have project-relative-path access to in future agent setups).
    // raw is bounded by MAX_FILE_BYTES (5MB), within truncateIfNeeded's cap.
    const wrapped = await truncateIfNeeded({
      fullContent: raw,
      slicedContent: content,
      wasTruncated: truncated,
    });
    return {
      path: relative(projectRoot, real) || basename(real),
      content: wrapped.content,
      total_lines: total,
      returned_lines: [start, end],
      truncated: wrapped.truncated,
      ...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}),
    };
  },
};

const ListDirInput = z.object({
  path: z.string().min(1),
  show_hidden: z.boolean().optional(),
});
type ListDirInputT = z.infer<typeof ListDirInput>;

export const listDir: ToolDef<ListDirInputT> = {
  name: 'list_dir',
  description: 'List entries in a directory (up to 500). Hidden files excluded unless show_hidden=true.',
  inputSchema: ListDirInput,
  jsonSchema: {
    type: 'function',
    function: {
      name: 'list_dir',
      description:
        'List entries in a directory (up to 500). Hidden files (dot-prefixed) excluded unless show_hidden=true.',
      parameters: {
        type: 'object',
        properties: {
          path: { type: 'string' },
          show_hidden: { type: 'boolean' },
        },
        required: ['path'],
        additionalProperties: false,
      },
    },
  },
  async execute(input, projectRoot, extraRoots) {
    const real = await pathGuard(projectRoot, input.path, extraRoots);
    const s = await stat(real);
    if (!s.isDirectory()) {
      throw new PathScopeError(`not a directory: ${input.path}`);
    }
    const entries = await readdir(real, { withFileTypes: true });
    const filtered = input.show_hidden
      ? entries
      : entries.filter((e) => !e.name.startsWith('.'));
    const total = filtered.length;
    const wasTruncated = total > MAX_DIR_ENTRIES;
    const relDir = relative(projectRoot, real) || '.';
    // v1.13.5: when we'd truncate, render the FULL list to tmpfs so
    // view_truncated_output can serve it. Stat sizes for all entries when
    // truncating so the stored view matches the visible shape; this is the
    // one extra cost for big directories, bounded by total entries (which
    // is itself bounded by filesystem behavior).
    const processOne = async (e: typeof filtered[number]) => {
      const child = resolve(real, e.name);
      let size: number | undefined;
      if (e.isFile()) {
        try {
          const cs = await stat(child);
          size = cs.size;
        } catch { /* ignore */ }
      }
      return {
        name: e.name,
        type: e.isDirectory() ? ('dir' as const) : ('file' as const),
        ...(size != null ? { size } : {}),
      };
    };
    const slice = filtered.slice(0, MAX_DIR_ENTRIES);
    const out = await Promise.all(slice.map(processOne));
    // v1.11.7: filter entries whose project-relative path matches a secret
    // pattern. The same filter applies to the full-list snapshot below so
    // the stashed file never holds entries the slice would have hidden.
    const secretFilter = filterSecretEntries(out, (e) =>
      relDir === '.' ? e.name : `${relDir}/${e.name}`,
    );
    let outputPath: string | undefined;
    if (wasTruncated) {
      const fullProcessed = await Promise.all(filtered.map(processOne));
      const fullFiltered = filterSecretEntries(fullProcessed, (e) =>
        relDir === '.' ? e.name : `${relDir}/${e.name}`,
      );
      // One line per entry, view_truncated_output's line slicing semantics
      // map cleanly. Format: "<type>\t<name>[\tsize=N]". Header documents
      // the shape so the model can grep / regex without prior schema lookup.
      const header = `# list_dir ${relDir} — ${fullFiltered.kept.length} entries`;
      const lines = [header, ...fullFiltered.kept.map((e) => {
        const sz = 'size' in e && e.size != null ? `\tsize=${e.size}` : '';
        return `${e.type}\t${e.name}${sz}`;
      })];
      const wrapped = await truncateIfNeeded({
        fullContent: lines.join('\n'),
        slicedContent: '',
        wasTruncated: true,
      });
      outputPath = wrapped.outputPath;
    }
    return {
      path: relDir,
      entries: secretFilter.kept,
      total: secretFilter.kept.length,
      truncated: wasTruncated,
      ...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}),
      ...(outputPath ? { outputPath } : {}),
    };
  },
};

const GrepInput = z.object({
  pattern: z.string().min(1),
  path: z.string().optional(),
  case_sensitive: z.boolean().optional(),
  max_results: z.number().int().positive().optional(),
  hidden: z.boolean().optional(),
});
type GrepInputT = z.infer<typeof GrepInput>;

export const grep: ToolDef<GrepInputT> = {
  name: 'grep',
  description:
    'Search file contents with ripgrep. Default path is project root. Max 100 results (200 cap).',
  inputSchema: GrepInput,
  jsonSchema: {
    type: 'function',
    function: {
      name: 'grep',
      description:
        'Search file contents with ripgrep. Returns up to 100 matches (cap 200). Set hidden=true to include dot-prefixed files.',
      parameters: {
        type: 'object',
        properties: {
          pattern: { type: 'string' },
          path: { type: 'string' },
          case_sensitive: { type: 'boolean' },
          max_results: { type: 'integer' },
          hidden: { type: 'boolean' },
        },
        required: ['pattern'],
        additionalProperties: false,
      },
    },
  },
  async execute(input, projectRoot, extraRoots) {
    const limit = Math.min(
      Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1),
      MAX_GREP_RESULTS
    );
    // Delegate to file_ops.grep; reshape match objects to preserve LLM output format
    // (file_ops uses {path, line, text}; tool output uses {path, line, content})
    const result = await fileOpsGrep(projectRoot, input.pattern, {
      path: input.path,
      max_matches: limit,
      case_sensitive: input.case_sensitive,
      hidden: input.hidden,
      extra_roots: extraRoots,
    });
    const reshaped = result.matches.map((m) => ({
      path: m.path,
      line: m.line,
      content: m.text,
    }));
    // v1.11.7: drop matches whose source file is a known-secret pattern.
    // file_ops.grep returns project-relative paths, so we feed them straight
    // into isSecretPath. Multiple matches in the same secret file each get
    // dropped individually — they all count in the hidden tally.
    const secretFilter = filterSecretEntries(reshaped, (m) => m.path);
    return {
      matches: secretFilter.kept,
      total: secretFilter.kept.length,
      truncated: result.truncated,
      ...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}),
    };
  },
};

const FindFilesInput = z.object({
  pattern: z.string().min(1),
  path: z.string().optional(),
  max_results: z.number().int().positive().optional(),
});
type FindFilesInputT = z.infer<typeof FindFilesInput>;

export const findFiles: ToolDef<FindFilesInputT> = {
  name: 'find_files',
  description: 'Glob for filenames. Default path is project root. Max 100 results (200 cap).',
  inputSchema: FindFilesInput,
  jsonSchema: {
    type: 'function',
    function: {
      name: 'find_files',
      description:
        'Glob for filenames under a directory. Default path is project root. Max 100 results (cap 200). Pattern uses standard glob (e.g. "**/*.ts").',
      parameters: {
        type: 'object',
        properties: {
          pattern: { type: 'string' },
          path: { type: 'string' },
          max_results: { type: 'integer' },
        },
        required: ['pattern'],
        additionalProperties: false,
      },
    },
  },
  async execute(input, projectRoot, extraRoots) {
    const limit = Math.min(
      Math.max(input.max_results ?? DEFAULT_FIND_RESULTS, 1),
      MAX_FIND_RESULTS
    );
    // Delegate to file_ops.findFiles; reshape { files, total, truncated } to
    // preserve the LLM-visible output format { paths, total, truncated }
    const result = await fileOpsFindFiles(projectRoot, input.pattern, {
      path: input.path,
      max_results: limit,
      extra_roots: extraRoots,
    });
    // v1.11.7: drop paths matching secret patterns. The original `total`
    // from file_ops includes pre-truncation count; we report the visible
    // count post-filter so the LLM can't infer hidden-count by subtraction.
    const secretFilter = filterSecretEntries(result.files, (p) => p);
    return {
      paths: secretFilter.kept,
      total: secretFilter.kept.length,
      truncated: result.truncated,
      ...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}),
    };
  },
};

// v1.13.5: retrieves the full content of a previously-truncated tool output
// via the opaque id stamped on the original tool_result. Line-based slicing
// matches view_file's mental model so the model uses the same affordances.
// Tmpfs-backed, 7-day TTL (see services/truncate.ts).
const VIEW_TRUNCATED_DEFAULT_LINES = 200;

const ViewTruncatedOutputInput = z.object({
  id: z.string().regex(/^tr_[0-9a-v]{12}$/),
  start_line: z.number().int().positive().optional(),
  end_line: z.number().int().positive().optional(),
});
type ViewTruncatedOutputInputT = z.infer<typeof ViewTruncatedOutputInput>;

export const viewTruncatedOutput: ToolDef<ViewTruncatedOutputInputT> = {
  name: 'view_truncated_output',
  description: `Retrieve the full content of a previously-truncated tool output by its outputPath id. When a tool returns { truncated: true, outputPath: "tr_..." }, call this to view the full content. Defaults to the first ${VIEW_TRUNCATED_DEFAULT_LINES} lines. Use start_line and end_line (1-indexed, inclusive) to slice. Stored for 7 days.`,
  inputSchema: ViewTruncatedOutputInput,
  jsonSchema: {
    type: 'function',
    function: {
      name: 'view_truncated_output',
      description: `Retrieve the full content of a previously-truncated tool output by its outputPath id. Returns the first ${VIEW_TRUNCATED_DEFAULT_LINES} lines by default; use start_line/end_line to slice. Stored for 7 days.`,
      parameters: {
        type: 'object',
        properties: {
          id: { type: 'string', description: 'The outputPath value from an earlier truncated tool result (e.g. "tr_abc123def456").' },
          start_line: { type: 'integer', description: 'First line (1-indexed). Default 1.' },
          end_line: { type: 'integer', description: `Last line (1-indexed, inclusive). Default ${VIEW_TRUNCATED_DEFAULT_LINES} lines past start.` },
        },
        required: ['id'],
        additionalProperties: false,
      },
    },
  },
  // view_truncated_output doesn't touch the filesystem — it pulls from tmpfs
  // by opaque id. extraRoots is irrelevant here; declared for signature parity
  // with the v1.13.17 ToolDef contract.
  async execute(input, _projectRoot, _extraRoots) {
    const content = await readTruncation(input.id);
    if (content === null) {
      return {
        id: input.id,
        content: '',
        truncated: false,
        error: `No truncation found for id "${input.id}". It may have been pruned (7-day TTL) or never existed.`,
      };
    }
    const lines = content.split('\n');
    const total = lines.length;
    let start = input.start_line ?? 1;
    let end = input.end_line ?? Math.min(total, start + VIEW_TRUNCATED_DEFAULT_LINES - 1);
    if (start < 1) start = 1;
    if (end > total) end = total;
    if (end < start) end = start;
    const slice = lines.slice(start - 1, end).join('\n');
    // Re-slicing this view isn't truncation in the dual-write sense — the
    // model already has the id; no point stashing the slice again.
    const truncated = total > end || start > 1;
    return {
      id: input.id,
      content: slice,
      total_lines: total,
      returned_lines: [start, end],
      truncated,
    };
  },
};

// v1.8 Level 1 branch awareness: gives the model a read-only view of the
// project's git state. No path input — operates on the inference-resolved
// project root via getGitMeta. Subprocess runs with a 2s timeout (see git_meta).
const GitStatusInput = z.object({}).strict();
type GitStatusInputT = z.infer<typeof GitStatusInput>;

export const gitStatus: ToolDef<GitStatusInputT> = {
  name: 'git_status',
  description:
    "Returns the current git branch, whether the working tree is dirty, and ahead/behind counts vs upstream. Read-only. Use when you need to know which branch the user is currently working on.",
  inputSchema: GitStatusInput,
  jsonSchema: {
    type: 'function',
    function: {
      name: 'git_status',
      description:
        'Returns the current git branch, dirty flag, and ahead/behind counts vs upstream. Read-only.',
      parameters: {
        type: 'object',
        properties: {},
        additionalProperties: false,
      },
    },
  },
  async execute(_input, projectRoot) {
    const meta = await getGitMeta(projectRoot);
    if (meta === null) {
      return { repo: false, branch: null, is_dirty: false, ahead: 0, behind: 0 };
    }
    return { repo: true, ...meta };
  },
};

// Batch 9.6: skill_find, skill_use, skill_resource. Lazy-loaded markdown
// playbooks at /data/skills/. Three tools rather than one to keep each call
// cheap — the model lists, then loads, then optionally pulls support files.

const SkillFindInput = z.object({
  query: z.string().optional(),
});
type SkillFindInputT = z.infer<typeof SkillFindInput>;

export const skillFind: ToolDef<SkillFindInputT> = {
  name: 'skill_find',
  description:
    'Find skills (markdown playbooks under /data/skills) by name or description. Returns up to 5 matches. Empty query or "*" returns all available skills. Call this first to discover what skills are available.',
  inputSchema: SkillFindInput,
  jsonSchema: {
    type: 'function',
    function: {
      name: 'skill_find',
      description:
        'Find skills by name or description. Returns up to 5 matches. Empty or "*" returns all.',
      parameters: {
        type: 'object',
        properties: {
          query: { type: 'string', description: 'substring matched against skill name and description' },
        },
        additionalProperties: false,
      },
    },
  },
  async execute(input) {
    return await findSkills(input.query ?? '');
  },
};

const SkillUseInput = z.object({
  name: z.string().min(1),
});
type SkillUseInputT = z.infer<typeof SkillUseInput>;

export const skillUse: ToolDef<SkillUseInputT> = {
  name: 'skill_use',
  description:
    "Load the full body of a skill's SKILL.md by name. Returns the markdown playbook to follow. Discover names via skill_find. Errors: unknown_skill.",
  inputSchema: SkillUseInput,
  jsonSchema: {
    type: 'function',
    function: {
      name: 'skill_use',
      description: "Load the full body of a skill's SKILL.md by name.",
      parameters: {
        type: 'object',
        properties: {
          name: { type: 'string', description: 'skill name from skill_find' },
        },
        required: ['name'],
        additionalProperties: false,
      },
    },
  },
  async execute(input) {
    const body = await getSkillBody(input.name);
    if (body === null) {
      return { error: 'unknown_skill', message: `unknown skill: ${input.name}` };
    }
    return { body };
  },
};

const SkillResourceInput = z.object({
  name: z.string().min(1),
  path: z.string().min(1),
});
type SkillResourceInputT = z.infer<typeof SkillResourceInput>;

export const skillResource: ToolDef<SkillResourceInputT> = {
  name: 'skill_resource',
  description:
    "Read a support file inside a skill's folder (e.g. references/root-cause-tracing.md). Path is relative to the skill folder. Use skill_use to read SKILL.md itself. Errors: unknown_skill, unknown_resource, path_escape.",
  inputSchema: SkillResourceInput,
  jsonSchema: {
    type: 'function',
    function: {
      name: 'skill_resource',
      description: "Read a support file inside a skill's folder. Path is relative to the skill folder.",
      parameters: {
        type: 'object',
        properties: {
          name: { type: 'string', description: 'skill name' },
          path: { type: 'string', description: 'relative path under the skill folder' },
        },
        required: ['name', 'path'],
        additionalProperties: false,
      },
    },
  },
  async execute(input) {
    const result = await getSkillResource(input.name, input.path);
    if (!result.ok) {
      return { error: result.code, message: result.message };
    }
    return { content: result.content };
  },
};

// Batch 9.7: ask_user_input. Interactive elicitation. The model emits a tool
// call with 1-3 structured questions; the inference loop PAUSES (does not
// execute the tool server-side, does not recurse) and waits for the frontend
// to POST /api/chats/:id/answer_user_input with the user's selections. See
// routes/messages.ts for the resume path and services/inference.ts for the
// pause branch in executeToolPhase.
const AskUserInputInput = z.object({
  questions: z
    .array(
      z.object({
        question: z.string().min(1).max(200),
        type: z.enum(['single_select', 'multi_select']),
        options: z.array(z.string().min(1).max(80)).min(2).max(6),
      }),
    )
    .min(1)
    .max(3),
});
type AskUserInputInputT = z.infer<typeof AskUserInputInput>;

export const askUserInput: ToolDef<AskUserInputInputT> = {
  name: 'ask_user_input',
  description:
    "Ask the user 1-3 structured questions through an inline picker UI. Use when you genuinely need a choice the user must make (e.g. scope, options, preferences) before continuing. Each question has 2-6 options and accepts free-text answers in addition. The tool call pauses the conversation until the user submits — the next assistant turn sees their answers as the tool result. Do not use for trivial yes/no clarifications you could infer; prefer it over multi-paragraph speculation about what the user might want.",
  inputSchema: AskUserInputInput,
  jsonSchema: {
    type: 'function',
    function: {
      name: 'ask_user_input',
      description:
        'Ask the user 1-3 structured questions through an inline picker. Pauses the conversation until the user answers; the next turn sees their selections.',
      parameters: {
        type: 'object',
        properties: {
          questions: {
            type: 'array',
            minItems: 1,
            maxItems: 3,
            items: {
              type: 'object',
              properties: {
                question: { type: 'string', description: '<=200 chars, shown to the user' },
                type: {
                  type: 'string',
                  enum: ['single_select', 'multi_select'],
                  description: 'single_select = at most one option; multi_select = any subset',
                },
                options: {
                  type: 'array',
                  minItems: 2,
                  maxItems: 6,
                  items: { type: 'string' },
                  description: '2-6 strings, each <=80 chars; free-text input is always available alongside',
                },
              },
              required: ['question', 'type', 'options'],
              additionalProperties: false,
            },
          },
        },
        required: ['questions'],
        additionalProperties: false,
      },
    },
  },
  // Server-side no-op. The "execution" of ask_user_input is the user's
  // response, captured client-side and posted to /api/chats/:id/answer_user_input.
  // The inference loop detects this tool by name and pauses before reaching
  // executeToolCall — this fallback only runs if something bypasses that
  // branch, in which case the pending sentinel matches the pause-path shape.
  async execute(input) {
    return { _pending: true, questions: input.questions };
  },
};

// v1.13.3: alpha-sorted by tool.name at module load. llama.cpp's prompt
// cache hits on byte-identical prefixes; the tool list lives near the top
// of the system prompt, so any order drift would invalidate every cached
// turn. Single source of truth for ordering lives here — toolJsonSchemas()
// and TOOLS_BY_NAME inherit it.
// v1.14.1-mcp-poc: changed from ReadonlyArray to let-bound mutable array
// so appendMcpTools() can push MCP-discovered tools at startup.
export let ALL_TOOLS: ToolDef<unknown>[] = [
  viewFile as ToolDef<unknown>,
  viewTruncatedOutput as ToolDef<unknown>,
  listDir as ToolDef<unknown>,
  grep as ToolDef<unknown>,
  findFiles as ToolDef<unknown>,
  gitStatus as ToolDef<unknown>,
  skillFind as ToolDef<unknown>,
  skillUse as ToolDef<unknown>,
  skillResource as ToolDef<unknown>,
  askUserInput as ToolDef<unknown>,
  // v1.11.8: web tools. Gated per-chat via session.web_search_enabled
  // (with project default fallback) — see effectiveTools filter in
  // services/inference.ts.
  webSearch as ToolDef<unknown>,
  webFetch as ToolDef<unknown>,
  // v1.12 Track B.2: codecontext tools. Backed by the codecontext sidecar
  // container. All read-only. target_dir is resolved server-side from the
  // project root in codecontext_client.ts (the LLM never supplies it).
  getCodebaseOverview as ToolDef<unknown>,
  getFileAnalysis as ToolDef<unknown>,
  getSymbolInfo as ToolDef<unknown>,
  searchSymbols as ToolDef<unknown>,
  getDependencies as ToolDef<unknown>,
  watchChanges as ToolDef<unknown>,
  getSemanticNeighborhoods as ToolDef<unknown>,
  getFrameworkAnalysis as ToolDef<unknown>,
  // v1.16: codesight-merge tools. Backed by the same codecontext sidecar.
  getBlastRadius as ToolDef<unknown>,
  getHotFiles as ToolDef<unknown>,
  getRoutes as ToolDef<unknown>,
  getMiddleware as ToolDef<unknown>,
  // v1.13.17-cross-repo-reads: paired with the pause-on-pending-grant
  // branch in tool-phase.ts. Read-only — only ever READS files; the only
  // state change is appending to sessions.allowed_read_paths via the
  // grant endpoint, gated by user consent.
  requestReadAccess as ToolDef<unknown>,
  // v2.6.x: read a tab's transcript by its session-scoped tab number.
  // Read-only; uses the ToolExecCtx 4th arg for DB/session access.
  readTabByNumber as ToolDef<unknown>,
].sort((a, b) => a.name.localeCompare(b.name));

// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
// fully contained in this set gets a generous default tool budget (30);
// anything outside means the agent can mutate state and gets a tighter
// default (10). Every tool in v1.8.2 happens to be read-only, so the
// non-RO branch only takes effect once BooCoder lands write tools.
// Batch 9.6: skill_* added; all still read-only.
// Batch 9.7: ask_user_input added — it pauses execution but doesn't mutate
// project state, so it belongs in the read-only set for budget purposes.
export const READ_ONLY_TOOL_NAMES = [
  'view_file',
  'view_truncated_output',
  'list_dir',
  'grep',
  'find_files',
  'git_status',
  'skill_find',
  'skill_use',
  'skill_resource',
  'ask_user_input',
  // v1.11.8: web tools don't mutate project state; counted as read-only
  // for the budget-tier calculation (BUDGET_READ_ONLY=30) when an agent's
  // toolset is fully contained in this list.
  'web_search',
  'web_fetch',
  // v1.12 Track B.2: codecontext tools. Read-only — they call the
  // codecontext sidecar which only analyzes files (never writes).
  'get_codebase_overview',
  'get_file_analysis',
  'get_symbol_info',
  'search_symbols',
  'get_dependencies',
  'watch_changes',
  'get_semantic_neighborhoods',
  'get_framework_analysis',
  // v1.13.17-cross-repo-reads: pauses execution but doesn't mutate project
  // state directly (the grant endpoint appends to sessions.allowed_read_paths
  // only with user consent). Belongs in the read-only budget tier.
  'request_read_access',
  // v2.6.x: reads a tab's transcript from session-scoped DB state; never
  // writes. Belongs in the read-only budget tier.
  'read_tab_by_number',
] as const;

export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
  ALL_TOOLS.map((t) => [t.name, t])
);

// v1.14.1-mcp-poc: append MCP-discovered tools at startup. Called once
// from index.ts after mcpClient.initialize(). Re-sorts ALL_TOOLS and
// rebuilds TOOLS_BY_NAME. READ_ONLY_TOOL_NAMES is not rebuilt because
// it's a const tuple used only for budget-tier checks; MCP tools are
// individually checked via their category at budget resolution time —
// they are all read_only by construction (the read-only guard in
// mcp-client.ts rejects any tool with readOnlyHint: false).
export function appendMcpTools(mcpTools: ToolDef<unknown>[]): void {
  if (mcpTools.length === 0) return;
  ALL_TOOLS = [...ALL_TOOLS, ...mcpTools].sort((a, b) => a.name.localeCompare(b.name));
  TOOLS_BY_NAME = Object.fromEntries(ALL_TOOLS.map((t) => [t.name, t]));
}

// v1.13.15-tools: tiered tool loading. BOOCODE_TOOLS env var (`core` |
// `standard` | `all`) filters the agent's tool whitelist before LLM dispatch.
// Daily-driver token win on qwen3.6-35b-a3b — the 35B-A3B MoE benefits from
// any prompt-cache stability win (fewer tools = shorter, more stable tool
// schemas in the system prompt). Pattern lift from eyaltoledano/claude-task-
// master (MIT + Commons Clause — pattern only, no code lift).
//
// The env var is a CEILING. It only narrows; never expands an agent's
// declared whitelist. Default behavior (var unset) is unchanged: all tools.
export const CORE_TOOL_NAMES = [
  'view_file',
  'list_dir',
  'grep',
  'find_files',
] as const;

export const STANDARD_TOOL_NAMES = [
  ...CORE_TOOL_NAMES,
  'web_search',
  'web_fetch',
  'git_status',
  'get_codebase_overview',
  'get_file_analysis',
  'get_symbol_info',
  'search_symbols',
  'get_dependencies',
  'watch_changes',
  'get_semantic_neighborhoods',
  'get_framework_analysis',
] as const;

// Module-load validation: every name in CORE / STANDARD must exist in
// TOOLS_BY_NAME. Catches typos and stale tier definitions before they reach
// production; server boot fails loudly rather than silently filtering valid
// tools out of agent whitelists.
for (const name of CORE_TOOL_NAMES) {
  if (!TOOLS_BY_NAME[name]) {
    throw new Error(`CORE_TOOL_NAMES references unknown tool: '${name}'`);
  }
}
for (const name of STANDARD_TOOL_NAMES) {
  if (!TOOLS_BY_NAME[name]) {
    throw new Error(`STANDARD_TOOL_NAMES references unknown tool: '${name}'`);
  }
}

export function resolveToolTier(tier: string | undefined): readonly string[] {
  switch ((tier ?? 'all').toLowerCase()) {
    case 'core':
      return CORE_TOOL_NAMES;
    case 'standard':
      return STANDARD_TOOL_NAMES;
    case 'all':
    default:
      return ALL_TOOLS.map((t) => t.name);
  }
}

export function toolJsonSchemas(): ToolJsonSchema[] {
  return ALL_TOOLS.map((t) => t.jsonSchema);
}
