/**
 * Tool dispatcher — routes an LLM tool call to the right executor.
 *
 *   <plugin_id>.<tool_name>  →  connector tool
 *       browser protocol → dispatched to extension via bridge.connector_run
 *       (http/ssh/mcp/etc. → reserved for Phase 3 runtimes)
 *   builtin name              →  in-process function
 *
 * Returns the tool_result block content (string) + is_error flag.
 */

import { bridgeRpc } from './bridge-client';
import { runHttp } from './protocols/http';
import { runShell } from './protocols/shell';
import { runSsh } from './protocols/ssh';
import {
  getConnector,
  getInstalledConnector,
  getConnectorEntries,
} from '../connectors/registry';
import { expandSettingsTokens } from '../plugins/templates';
import { resolveWizardTemplate } from '../connectors/wizard-template';
import type { ConnectorEntry, ConnectorTool, ConnectorDefinition } from '../connectors/types';

export interface ToolCall {
  id: string;
  name: string;
  input: unknown;
}

export interface ToolResult {
  content: string;
  is_error?: boolean;
}

// ─── Builtins ─────────────────────────────────────────────

export type BuiltinHandler = (input: unknown) => Promise<string>;

// ─── Data-dir file helpers ────────────────────────────────
// resolveTmpPath: scoped to <dataDir>/tmp/ — where save_tmp_file drops
// its files. Kept separate from scratch/ (the synthetic "scratch
// project" task workspace) so the cache janitor can wipe tmp/ without
// disturbing in-progress task workdirs.
async function resolveTmpPath(rel: string): Promise<string> {
  const { getDataDir } = await import('../dirs');
  const { join, resolve } = await import('node:path');
  const clean = rel.replace(/^\/+/, '');
  if (!clean || clean.includes('..') || clean.includes('\0')) throw new Error('invalid tmp path');
  const tmpRoot = join(getDataDir(), 'tmp');
  const target = resolve(tmpRoot, clean);
  if (!target.startsWith(tmpRoot + '/') && target !== tmpRoot) {
    throw new Error('resolved path escapes tmp dir');
  }
  return target;
}

// resolveDataPath: scoped to <dataDir>/ — the read/list tools' broader
// surface. Blocks known sensitive files (encrypt key, enterprise keys,
// sqlite DBs, server log, anything starting with a dot at the dataDir
// root). Subpaths under known-safe subdirs (scratch/, flows/, prompts/,
// connectors/, etc.) are accepted as-is. The agent can use this to
// inspect its OWN config — but secrets stay invisible.
function isSensitiveDataPath(target: string, dataRoot: string): string | null {
  if (target === dataRoot) return null;
  const rel = target.slice(dataRoot.length + 1); // drop "<root>/"
  const top = rel.split('/')[0] || '';
  // Top-level dotfiles (.encrypt-key, .enterprise-keys.json, …) — secrets.
  if (top.startsWith('.')) return `top-level dotfile "${top}" is sensitive (encryption keys, session state)`;
  // SQLite databases are binary, large, and contain raw chat / task history.
  if (/\.(db|db-wal|db-shm)$/i.test(top)) return `sqlite database "${top}" — read via Forge APIs instead`;
  // forge.log / forge.log.old — large + may contain tokens that leaked into errors.
  if (/^forge\.log/i.test(top)) return 'forge.log is server diagnostics — ask the user to grep it directly';
  // Token caches at root.
  if (/-tokens\.json$/i.test(top)) return `"${top}" stores auth tokens — sensitive`;
  return null;
}
async function resolveDataPath(rel: string): Promise<string> {
  const { getDataDir } = await import('../dirs');
  const { resolve } = await import('node:path');
  const clean = String(rel || '').replace(/^\/+/, '');
  if (clean.includes('..') || clean.includes('\0')) throw new Error('invalid path');
  const dataRoot = getDataDir();
  const target = resolve(dataRoot, clean);
  if (!target.startsWith(dataRoot + '/') && target !== dataRoot) {
    throw new Error('resolved path escapes data dir');
  }
  const blocked = isSensitiveDataPath(target, dataRoot);
  if (blocked) throw new Error(`refused: ${blocked}`);
  return target;
}

// A connector arg whose WHOLE value is one of these forms is a reference
// to a Forge-managed file. Path inside the prefix is dataDir-relative —
// e.g. "scratch://tmp/foo.md", "scratch://flows/x.yaml". The "scratch:"
// prefix is historical (originally only pointed at <dataDir>/scratch/);
// kept for compatibility.
const SCRATCH_REF_RE = /^(?:scratch:\/\/|scratch:|\/api\/scratch\/)(.+)$/;

// Replace any `scratch://<file>` arg with the file's base64 content,
// server-side. This is the whole point: the LLM can hand a connector a
// saved file (e.g. onedrive.upload_file content_base64) without ever
// hand-encoding it — hand-encoding large/non-ASCII content is unreliable
// and is exactly what wedged the OneDrive upload in an endless retry loop.
async function resolveScratchRefsInArgs(args: Record<string, unknown>): Promise<void> {
  const { readFile } = await import('node:fs/promises');
  for (const [k, v] of Object.entries(args)) {
    if (typeof v !== 'string') continue;
    const m = v.match(SCRATCH_REF_RE);
    if (!m) continue;
    // Path inside the ref is dataDir-relative. Goes through the same
    // sensitive-file guard as read_forge_file (no leaking encrypt keys
    // / DBs into a connector upload).
    const buf = await readFile(await resolveDataPath(m[1])); // ENOENT → tool error
    args[k] = buf.toString('base64');
  }
}

const BUILTINS: Record<string, BuiltinHandler> = {
  get_current_time: async () => new Date().toISOString(),

  // Register an enterprise marketplace key so subsequent connector /
  // workflow lookups overlay the named tenant's private repo on top of
  // the public marketplace. The user typically pastes a key string like
  // `fortinet:github_pat_…` directly into chat — see lib/enterprise.ts for
  // the two accepted formats. Triggers an immediate sync so the new
  // source is visible without manual Refresh.
  add_enterprise_key: async (input) => {
    const { key } = (input as { key?: string } | undefined) || {};
    if (!key || typeof key !== 'string' || !key.trim()) {
      return 'Error: key is required (paste the full enterprise key string).';
    }
    const { addEnterpriseKey } = await import('../enterprise');
    const r = addEnterpriseKey(key.trim());
    if (!r.ok) return `Could not add key: ${r.reason}`;

    // Best-effort sync — failures here don't undo the key add; user can
    // hit Refresh in Settings later. Connector first (since enterprise
    // registry.json lives in the connector cache), then workflow
    // (re-reads that same cache for recipes/pipelines).
    const syncDetail: string[] = [];
    try {
      const { syncRegistry } = await import('../connectors/sync');
      const s = await syncRegistry({ refreshInstalled: false });
      syncDetail.push(`connectors: ${s.ok ? `${s.registry_count} entries` : `failed (${s.error})`}`);
    } catch (e) {
      syncDetail.push(`connectors: failed (${(e as Error).message})`);
    }
    try {
      const { syncMarketplace } = await import('../workflow-marketplace');
      const w = await syncMarketplace();
      syncDetail.push(`workflows: ${w.ok ? `${w.recipes} recipes + ${w.pipelines} pipelines` : `failed (${w.error})`}`);
    } catch (e) {
      syncDetail.push(`workflows: failed (${(e as Error).message})`);
    }

    return `✓ Added enterprise key for tenant "${r.tenant_id}". Sync: ${syncDetail.join('; ')}. The new source will surface in Settings → Marketplace Providers and in any marketplace listing.`;
  },

  // Trigger a pipeline workflow defined under flows/<name>.yaml. Mirrors
  // the same MCP tool used by Claude Code tasks (forge-mcp-server.ts), but
  // available directly inside the chat agent so users can say "run the
  // mantis-bug-fix pipeline for bug 1234" and have the agent dispatch it.
  //
  // Call without args first to see workflows + their input schemas — saves
  // a round-trip of guessing field names. Schema includes which fields are
  // required (no default) vs optional (have default) so the agent can omit
  // optional ones rather than passing wrong placeholder values.
  trigger_pipeline: async (input) => {
    const params = (input as { workflow?: string; input?: Record<string, unknown>; skills?: unknown } | undefined) || {};
    const { listWorkflows, startPipeline, getPipeline, getWorkflow } = await import('../pipeline');
    if (!params.workflow) {
      const workflows = listWorkflows();
      if (workflows.length === 0) return 'No workflows found. Create one in <dataDir>/flows/.';
      const lines: string[] = ['Available workflows (only pass the marked-required inputs; optional ones use their default):', ''];
      for (const w of workflows) {
        lines.push(`• ${w.name}${w.description ? ' — ' + w.description.split('\n')[0].slice(0, 160) : ''}`);
        const entries = Object.entries(w.input || {});
        if (entries.length === 0) {
          lines.push('  Inputs: (none)');
        } else {
          for (const [name, spec] of entries) {
            // spec may be a string (legacy description-only) or an object.
            const isObj = spec && typeof spec === 'object';
            const desc = isObj ? (spec as any).description : (typeof spec === 'string' ? spec : '');
            const type = isObj ? ((spec as any).type || 'string') : 'string';
            const required = isObj ? !!(spec as any).required : false;
            const hasDefault = isObj && Object.prototype.hasOwnProperty.call(spec, 'default');
            const def = hasDefault ? (spec as any).default : undefined;
            const star = required ? '*' : '';
            const defNote = hasDefault
              ? ` [default: ${String(def).split('\n')[0].slice(0, 60)}${String(def).length > 60 ? '…' : ''}]`
              : '';
            const descShort = desc ? ` — ${String(desc).split('\n')[0].slice(0, 120)}` : '';
            lines.push(`    - ${name}${star} (${type})${defNote}${descShort}`);
          }
        }
        lines.push('');
      }
      lines.push('Required inputs are marked with *. Optional ones with defaults will use the default if you omit them — DO NOT pass empty strings or guesses for those.');
      return lines.join('\n');
    }
    // Pre-flight schema validation. The pipeline orchestrator currently
    // silently accepts unknown input keys and lets required fields default to
    // empty — leading to "status: done" no-op runs. Surface those mistakes
    // here so the LLM (or any caller) gets a structured error instead of a
    // misleading success. Workflow-side validation (B in the plan) is deferred
    // because it'd affect schedules.
    const workflows = listWorkflows();
    const wf = workflows.find((w: any) => w.name === params.workflow);
    if (!wf) {
      return `Unknown workflow: "${params.workflow}". Available: ${workflows.map((w: any) => w.name).join(', ') || '(none)'}. Call trigger_pipeline() with no args to see schemas.`;
    }
    const declared = wf.input || {};
    const declaredKeys = new Set(Object.keys(declared));
    const requiredKeys: string[] = [];
    for (const [k, spec] of Object.entries(declared)) {
      if (spec && typeof spec === 'object') {
        const s = spec as any;
        const hasDefault = Object.prototype.hasOwnProperty.call(s, 'default');
        if (s.required && !hasDefault) requiredKeys.push(k);
      }
    }
    const givenKeys = Object.keys(params.input || {});
    const unknown = givenKeys.filter((k) => !declaredKeys.has(k));
    if (unknown.length > 0) {
      return `Unknown input fields for "${params.workflow}": ${unknown.join(', ')}. This workflow only accepts: ${[...declaredKeys].join(', ')}. NOTE: input field names are snake_case (e.g. bug_id) — they are NOT the same as the bash variable names inside pipeline scripts (e.g. BUG_ID). Consider memory_remember_block({key: "rule.pipeline_input_naming", value: "Forge pipeline input fields are exact, snake_case, declared in the workflow yaml. Never invent uppercase / bash-variable names like BUG_ID, BASE, PROJECT_PATH.", pinned: true}) to save this lesson.`;
    }
    const missing = requiredKeys.filter((k) => {
      const v = (params.input || {})[k];
      return v == null || (typeof v === 'string' && v.trim() === '');
    });
    if (missing.length > 0) {
      return `Missing required input fields for "${params.workflow}": ${missing.join(', ')}. All required fields (no defaults): ${requiredKeys.join(', ') || '(none)'}. Re-call with these provided.`;
    }

    // Coerce numbers/booleans to strings (pipeline orchestrator expects Record<string, string>).
    const stringInput: Record<string, string> = {};
    for (const [k, v] of Object.entries(params.input || {})) {
      stringInput[k] = v == null ? '' : typeof v === 'string' ? v : String(v);
    }

    // Skills — same plumbing as Schedule / Job: pre-install each named
    // skill into every project the workflow's nodes target, then thread
    // the list through to startPipeline so per-task --append-system-prompt
    // gets the /skill-name lines. Unknown skill names are warned (not
    // blocked) — startPipeline ignores any that aren't installed by then.
    let skills: string[] = [];
    if (Array.isArray(params.skills)) {
      skills = (params.skills as unknown[]).filter((s): s is string => typeof s === 'string' && s.trim() !== '');
    }
    if (skills.length > 0) {
      try {
        const { listSkills } = await import('../skills');
        const known = new Set(listSkills().map((s: any) => s.name));
        const unknown = skills.filter((s) => !known.has(s));
        if (unknown.length > 0) {
          return `Unknown skills: ${unknown.join(', ')}. Available: ${[...known].slice(0, 40).join(', ')}. Call list_forge_context to see all skills.`;
        }
        const { ensureInstalledInProject } = await import('../skills');
        const { getProjectInfo } = await import('../projects');
        const projectNames = new Set<string>();
        for (const n of Object.values(wf.nodes || {})) {
          if ((n as any).project) projectNames.add((n as any).project as string);
        }
        const seenPaths = new Set<string>();
        for (const pName of projectNames) {
          const resolved = pName.replace(/\{\{\s*input\.([\w-]+)\s*\}\}/g, (_, k: string) => stringInput[k] ?? '');
          if (!resolved) continue;
          const pInfo = getProjectInfo(resolved);
          if (!pInfo || seenPaths.has(pInfo.path)) continue;
          seenPaths.add(pInfo.path);
          for (const skill of skills) {
            try { await ensureInstalledInProject(skill, pInfo.path); }
            catch (e) { console.warn(`[chat] skill "${skill}" install failed in ${pInfo.path}: ${(e as Error).message}`); }
          }
        }
      } catch (e) {
        console.warn(`[chat] skills preflight failed: ${(e as Error).message}`);
      }
    }

    const pipeline = startPipeline(params.workflow, stringInput, { skills: skills.length ? skills : undefined });
    const fresh = getPipeline(pipeline.id) || pipeline;
    const errors: string[] = [];
    if (pipeline.status === 'failed') {
      for (const [nid, n] of Object.entries(fresh.nodes || {})) {
        if ((n as any).error) errors.push(`${nid}: ${(n as any).error}`);
      }
    }
    let warning: string | undefined;
    if (pipeline.status === 'done') {
      const forEach = (fresh as any).forEach;
      if (forEach && typeof forEach === 'object') {
        const iters = Array.isArray(forEach.iterations) ? forEach.iterations.length : 0;
        const total = typeof forEach.total === 'number' ? forEach.total : iters;
        if (total === 0) {
          warning = 'Pipeline finished with 0 iterations — likely empty source list. This is NOT a success; the work the user asked for did NOT happen. Re-check input fields (especially the one feeding for_each.source) and retry.';
        }
      }
    }
    return JSON.stringify({
      ok: pipeline.status !== 'failed',
      pipeline_id: pipeline.id,
      workflow: params.workflow,
      status: pipeline.status,
      terminal: pipeline.status !== 'running',
      ...(errors.length ? { errors: errors.join(' | ').slice(0, 500) } : {}),
      ...(warning ? { warning } : {}),
      hint: pipeline.status === 'running'
        ? 'To wait for completion: start_watch poll="get_pipeline_status" poll_args={pipeline_id:"' + pipeline.id + '"} done_match={path:"status",equals:"done"} fail_path="status==failed". Do NOT poll get_pipeline_status in this conversation.'
        : undefined,
    });
  },

  // Query a pipeline run's status + per-node results by id (pairs with
  // trigger_pipeline's returned id). Returns JSON so start_watch's
  // done_match={path:"status",equals:"done"} can resolve — a string-shaped
  // response leaves watches polling forever.
  get_pipeline_status: async (input) => {
    const params = (input as { pipeline_id?: string } | undefined) || {};
    if (!params.pipeline_id) return JSON.stringify({ ok: false, error: 'pipeline_id is required (returned by trigger_pipeline)' });
    const { getPipeline } = await import('../pipeline');
    const pipeline = getPipeline(params.pipeline_id);
    if (!pipeline) return JSON.stringify({ ok: false, error: `Pipeline "${params.pipeline_id}" not found` });
    const nodes = Object.entries(pipeline.nodes || {}).map(([id, n]) => ({
      id,
      status: n.status,
      ...(n.error ? { error: n.error } : {}),
      outputs: Object.fromEntries(Object.entries(n.outputs || {}).map(([k, v]) => [k, String(v).slice(0, 500)])),
    }));
    return JSON.stringify({
      id: pipeline.id,
      status: pipeline.status,
      terminal: pipeline.status !== 'running',
      workflowName: pipeline.workflowName,
      nodes,
    });
  },

  // Surface Forge's local context (projects + agents + skills) so the chat
  // agent can pick valid values for inputs like trigger_pipeline.input.project
  // without guessing. Cheap call — read-only directory + DB lookups.
  list_forge_context: async () => {
    const out: string[] = [];
    try {
      const { scanProjects } = await import('../projects');
      const projects = scanProjects();
      out.push('Forge projects (use the name as input.project for tasks / pipelines):');
      if (projects.length === 0) out.push('  (none — configure project roots in Settings)');
      for (const p of projects) {
        out.push(`  - ${p.name}${p.hasClaudeMd ? ' [has CLAUDE.md]' : ''}${p.language ? ' [' + p.language + ']' : ''}`);
      }
    } catch (e) {
      out.push(`(failed to list projects: ${(e as Error).message})`);
    }
    out.push('');
    try {
      const { loadSettings } = await import('../settings');
      const settings = loadSettings();
      const agents = settings.agents || {};
      out.push('Agent profiles (CLI agents; use the id as dispatch_task.agent):');
      const ids = Object.keys(agents);
      if (ids.length === 0) out.push('  (none configured)');
      for (const id of ids) {
        const a = agents[id] as any;
        out.push(`  - ${id}${a?.name ? ' (' + a.name + ')' : ''}`);
      }
    } catch (e) {
      out.push(`(failed to list agents: ${(e as Error).message})`);
    }
    out.push('');
    try {
      const { listSkills } = await import('../skills');
      const skills = listSkills();
      out.push('Skills (available to dispatch_task / pipeline via auto-install):');
      if (skills.length === 0) out.push('  (none)');
      for (const s of skills) {
        out.push(`  - ${s.name}${s.description ? ' — ' + s.description.slice(0, 80) : ''}`);
      }
    } catch (e) {
      out.push(`(failed to list skills: ${(e as Error).message})`);
    }
    return out.join('\n');
  },

  // Save raw content to <dataDir>/scratch/<filename>. The chat UI auto-links
  // any prose mention of `scratch/<filename>` (link-patterns.ts) so the user
  // gets a download link in the response. Prefer this over dispatch_task for
  // "save / create / export a file" asks — the LLM already has the content
  // in context, no need to spawn a CLI task that may write elsewhere.
  save_tmp_file: async (input) => {
    const params = (input as { filename?: string; content?: string } | undefined) || {};
    const filename = (params.filename || '').trim();
    const content = params.content == null ? '' : String(params.content);
    if (!filename) return JSON.stringify({ ok: false, error: 'filename is required' });
    // Bare filename only — no slashes anywhere. The cache janitor
    // (lib/scratch-cleanup.ts) only sweeps top-level files in tmp/;
    // allowing nested writes would create files that never expire.
    // Also keeps the path returned in `path: "tmp/<filename>"` simple.
    if (filename.includes('..') || filename.includes('/') || filename.includes('\\') || filename.includes('\0')) {
      return JSON.stringify({ ok: false, error: 'filename must be a bare relative name (no slashes, no ".." — use date-stamped flat names like "report-2026-06-09.md")' });
    }
    // Land under <dataDir>/tmp/ — cache-managed location the user can
    // sweep via "forge clean cache" / the user-menu Cache panel. Keeps
    // <dataDir>/scratch/ for the synthetic "scratch project" workspace
    // (where dispatched tasks live + edit their own files).
    let target: string;
    try { target = await resolveTmpPath(filename); }
    catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
    const { mkdir, writeFile } = await import('node:fs/promises');
    const { dirname } = await import('node:path');
    // Bare filename → dirname is always <dataDir>/tmp/. mkdir -p idempotent.
    await mkdir(dirname(target), { recursive: true });
    await writeFile(target, content, 'utf8');
    const sizeBytes = Buffer.byteLength(content, 'utf8');
    const fileUrl = `file://${target}`;
    // Pre-format the user-facing message. Tell agent to OUTPUT this
    // verbatim — agents follow "echo this string" instructions far
    // more reliably than abstract "include the URL" hints. Past
    // attempts with hint-only kept producing prose that lost the
    // file:// URL or wrapped it in markdown the chat UI couldn't
    // render. This sidesteps the entire link-pattern question.
    const userMessage = `✓ Saved (${sizeBytes} bytes) — ${fileUrl}`;
    return JSON.stringify({
      ok: true,
      path: `tmp/${filename}`,
      local_path: target,
      file_url: fileUrl,
      bytes: sizeBytes,
      user_message: userMessage,
      hint: 'OUTPUT THE `user_message` FIELD VERBATIM as your reply to the user — copy the exact characters, no markdown wrapping, no rephrasing, no shortening, no extra prose. That string contains the file:// URL Chrome can open directly on localhost (and that the user can paste into Finder/Explorer). For follow-up asks like "give me the link" / "where is it" / "提供链接" / "再给我一次" → output the same file:// URL again (' + fileUrl + '). To UPLOAD this file to a connector (e.g. onedrive.upload_file), pass "scratch://tmp/' + filename + '" as the content_base64 arg — Forge base64-encodes server-side; do NOT hand-encode. To read it back later, use read_forge_file with filename:"tmp/' + filename + '". tmp/ is auto-swept by the cache janitor.',
    });
  },

  // Read any Forge-managed file under <dataDir>/ — tmp/, scratch/,
  // flows/, prompts/, connectors/, etc. Sensitive files (encrypt key,
  // sqlite DBs, log, *-tokens.json) are refused. Text returns decoded
  // text (capped 256KB); pass as_base64:true for binary. To upload a
  // file to a connector, prefer passing "scratch://<dataDir-rel>" as
  // the arg — base64-encoded server-side; no bytes through the model.
  read_forge_file: async (input) => {
    const params = (input as { filename?: string; as_base64?: boolean } | undefined) || {};
    // Normalize: strip the scratch:// / /api/scratch/ prefixes the
    // agent might have copied from save_tmp_file's response. The
    // remaining string is a dataDir-relative path (e.g. "tmp/foo.md",
    // "flows/x.yaml", "prompts/y.yaml").
    const filename = (params.filename || '').trim().replace(/^(?:scratch:\/\/|scratch:|\/api\/scratch\/)/, '');
    if (!filename) return JSON.stringify({ ok: false, error: 'filename is required' });
    let target: string;
    try { target = await resolveDataPath(filename); }
    catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
    const { readFile } = await import('node:fs/promises');
    let buf: Buffer;
    try { buf = await readFile(target); }
    catch { return JSON.stringify({ ok: false, error: `Forge file not found: ${filename}` }); }
    if (params.as_base64) {
      return JSON.stringify({
        ok: true, path: filename,
        local_path: target, file_url: `file://${target}`,
        encoding: 'base64', size_bytes: buf.length, content: buf.toString('base64'),
        // "scratch://" is the connector-ref protocol name (historical —
        // not the scratch/ dir). Path after it is dataDir-relative.
        note: `To upload to a connector, prefer passing "scratch://${filename}" directly as the connector arg — Forge resolves + base64-encodes it server-side, no need to round-trip these bytes through the model.`,
      });
    }
    const MAX = 256 * 1024;
    const truncated = buf.length > MAX;
    return JSON.stringify({
      ok: true, path: filename,
      local_path: target, file_url: `file://${target}`,
      encoding: 'utf-8', size_bytes: buf.length, truncated,
      content: buf.subarray(0, MAX).toString('utf8'),
      ...(truncated ? { note: `content truncated to ${MAX} bytes — use as_base64 for the full file` } : {}),
    });
  },

  // List files anywhere under <dataDir>/ (Forge's own data dir).
  // Replaces the "dispatch a task just to `ls`" workaround. The `dir`
  // param is dataDir-relative — pass "tmp" / "scratch" / "flows" /
  // "connectors" / "prompts" / etc. No dir → lists dataDir root.
  // Sensitive items (encrypt key, sqlite DBs, log, *-tokens.json at
  // root) are skipped — see resolveDataPath.
  list_forge_files: async (input) => {
    const params = (input as { dir?: string; ext?: string; limit?: number } | undefined) || {};
    const sub = String(params.dir || '').trim().replace(/^\/+|\/+$/g, '');
    const ext = String(params.ext || '').trim().toLowerCase();
    const limit = Math.max(1, Math.min(500, Number(params.limit) || 200));
    const { getDataDir } = await import('../dirs');
    const { join, relative } = await import('node:path');
    const { readdir, stat } = await import('node:fs/promises');
    const dataRoot = getDataDir();
    let target: string;
    try { target = sub ? await resolveDataPath(sub) : dataRoot; }
    catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
    let dirents;
    try { dirents = await readdir(target, { withFileTypes: true }); }
    catch (e) { return JSON.stringify({ ok: false, error: `cannot list ${sub || 'data/'}: ${(e as Error).message}` }); }
    const entries: Array<{ path: string; kind: 'file' | 'dir'; size?: number; mtime?: string; file_url?: string }> = [];
    for (const d of dirents) {
      const full = join(target, d.name);
      // Apply the same sensitive-file filter to entries we list — so the
      // agent doesn't even see .encrypt-key / *.db etc.
      if (isSensitiveDataPath(full, dataRoot)) continue;
      if (ext && d.isFile() && !d.name.toLowerCase().endsWith(ext)) continue;
      let size: number | undefined;
      let mtime: string | undefined;
      if (d.isFile()) {
        try { const s = await stat(full); size = s.size; mtime = s.mtime.toISOString(); } catch {}
      }
      entries.push({
        path: relative(dataRoot, full),
        kind: d.isDirectory() ? 'dir' : 'file',
        ...(size !== undefined ? { size } : {}),
        ...(mtime ? { mtime } : {}),
        // file:// URL the user can paste into Chrome (works on localhost).
        // Provide on dirs too — opening a dir in Chrome shows the listing.
        file_url: `file://${full}`,
      });
      if (entries.length >= limit) break;
    }
    entries.sort((a, b) => (b.mtime || '').localeCompare(a.mtime || '') || a.path.localeCompare(b.path));
    return JSON.stringify({
      ok: true,
      root: relative(dataRoot, target) || '.',
      local_root: target,
      count: entries.length,
      entries,
      hint: 'When the user asks to show / list files, surface each entry\'s `path` (dataDir-relative, e.g. "tmp/foo.md") and `file_url` (file://… — opens in Chrome on localhost). Use read_forge_file with the same `path` to inspect content. PREFER this over dispatch_task with `ls`.',
    });
  },

  // Dispatch a one-shot background task. Agent + skills optional; project is
  // required (defaults to 'scratch' if not given). Returns the task id; the
  // caller can ask "what's the status of task <id>?" later — we don't block.
  dispatch_task: async (input) => {
    const params = (input as { project?: string; prompt?: string; agent?: string } | undefined) || {};
    if (!params.prompt) return JSON.stringify({ ok: false, error: 'prompt is required' });
    const { getProjectInfo, SCRATCH_PROJECT_NAME } = await import('../projects');
    const projectName = params.project?.trim() || SCRATCH_PROJECT_NAME;
    const project = getProjectInfo(projectName);
    if (!project) return JSON.stringify({ ok: false, error: `project "${projectName}" not found` });
    const { createTask } = await import('../task-manager');
    const task = createTask({
      projectName: project.name,
      projectPath: project.path,
      prompt: params.prompt,
      conversationId: '',
      agent: params.agent || undefined,
    });
    return JSON.stringify({
      ok: true,
      task_id: task.id,
      project: project.name,
      status: task.status,
      hint: 'To wait for completion: start_watch poll="get_task_status" poll_args={task_id:"' + task.id + '"} done_path="terminal". Do NOT poll get_task_status in this conversation.',
    });
  },

  // Stop a still-running dispatched task. Use when the user says "停止
  // / cancel / kill the task" — much better than telling them to open
  // the Tasks UI. No-op (returns terminal:true) if the task already
  // finished, so it's safe to call without checking status first.
  cancel_task: async (input) => {
    const params = (input as { task_id?: string } | undefined) || {};
    if (!params.task_id) return JSON.stringify({ ok: false, error: 'task_id is required' });
    const { getTask, cancelTask } = await import('../task-manager');
    const task = getTask(params.task_id);
    if (!task) return JSON.stringify({ ok: false, error: `Task "${params.task_id}" not found` });
    if (task.status !== 'running' && task.status !== 'queued') {
      return JSON.stringify({ ok: true, task_id: task.id, status: task.status, terminal: true, note: 'already terminal — no cancel needed' });
    }
    const cancelled = cancelTask(params.task_id);
    return JSON.stringify({ ok: cancelled, task_id: task.id, status: cancelled ? 'cancelled' : task.status, terminal: cancelled });
  },

  // Companion to dispatch_task — read a task's status + result. Returns JSON
  // so start_watch can poll via done_path="terminal" or done_match
  // {path:"status", equals:"done"}.
  get_task_status: async (input) => {
    const params = (input as { task_id?: string } | undefined) || {};
    if (!params.task_id) return JSON.stringify({ ok: false, error: 'task_id is required (returned by dispatch_task)' });
    const { getTask } = await import('../task-manager');
    const task = getTask(params.task_id);
    if (!task) return JSON.stringify({ ok: false, error: `Task "${params.task_id}" not found` });
    return JSON.stringify({
      id: task.id,
      status: task.status,
      terminal: task.status === 'done' || task.status === 'failed' || task.status === 'cancelled',
      project: task.projectName,
      ...(task.resultSummary ? { result_summary: String(task.resultSummary).slice(0, 1000) } : {}),
      ...(task.error ? { error: String(task.error).slice(0, 500) } : {}),
      ...(task.completedAt ? { completed_at: task.completedAt } : {}),
    });
  },

  // List Forge's own help/documentation files so the chat agent can answer
  // questions about Forge itself (features, config, troubleshooting) without
  // the user opening the separate Help AI. Pair with read_help_doc.
  list_help_docs: async () => {
    const { listHelpDocs } = await import('../help-content');
    const docs = listHelpDocs();
    if (docs.length === 0) return 'No Forge help docs found.';
    return 'Forge help docs (read one with read_help_doc):\n' + docs.map((d) => `- ${d}`).join('\n');
  },

  // Read one Forge help doc in full. Use to answer questions about Forge's
  // own features / settings / troubleshooting.
  read_help_doc: async (input) => {
    const { doc } = (input as { doc?: string } | undefined) || {};
    if (!doc) return 'read_help_doc failed: doc is required (a filename from list_help_docs, e.g. "05-pipelines.md").';
    const { readHelpDoc } = await import('../help-content');
    const content = readHelpDoc(doc);
    if (content == null) return `Help doc "${doc}" not found. Call list_help_docs to see available filenames.`;
    const MAX = 30000;
    return content.length > MAX
      ? content.slice(0, MAX) + `\n\n…[truncated — doc is ${content.length} chars]`
      : content;
  },

  // ── Schedules CRUD ─────────────────────────────────────────
  // All five direct in-process via lib/schedules/store — no HTTP, no auth.
  // Use these instead of dispatch_task + curl: cleaner, no token shenanigans.

  create_schedule: async (input) => {
    const p = (input as any) || {};
    const name = String(p.name || '').trim();
    const workflow = String(p.workflow || p.body_ref || '').trim();
    if (!name) return JSON.stringify({ ok: false, error: 'name is required' });
    if (!workflow) return JSON.stringify({ ok: false, error: 'workflow (pipeline name) is required' });

    // Trigger normalization: prefer every_minutes; accept at (once) or cron.
    let schedule_kind: 'period' | 'once' | 'cron' = 'period';
    let schedule_interval_minutes: number | undefined;
    let schedule_at: string | null | undefined;
    let schedule_cron: string | null | undefined;
    if (p.every_minutes != null) {
      schedule_kind = 'period';
      schedule_interval_minutes = Number(p.every_minutes);
    } else if (p.at) {
      schedule_kind = 'once';
      schedule_at = String(p.at);
    } else if (p.cron) {
      schedule_kind = 'cron';
      schedule_cron = String(p.cron);
    } else {
      return JSON.stringify({ ok: false, error: 'one of every_minutes / at / cron is required' });
    }

    const { createSchedule, seedNextRunAt } = await import('../schedules/store');
    try {
      const s = createSchedule({
        name,
        body_kind: 'pipeline',
        body_ref: workflow,
        input: (p.input && typeof p.input === 'object') ? p.input : {},
        skills: Array.isArray(p.skills) ? p.skills : undefined,
        enabled: p.enabled !== false,
        schedule_kind,
        schedule_interval_minutes,
        schedule_at: schedule_at ?? null,
        schedule_cron: schedule_cron ?? null,
        action_kind: p.action || 'none',
      });
      seedNextRunAt(s.id);
      return JSON.stringify({
        ok: true,
        schedule_id: s.id,
        name: s.name,
        enabled: s.enabled,
        kind: s.schedule_kind,
        next_run_at: s.next_run_at,
        message: `Schedule "${s.name}" created. ${schedule_kind === 'period' ? `Fires every ${schedule_interval_minutes} minutes.` : schedule_kind === 'once' ? `Fires once at ${schedule_at}.` : `Fires on cron "${schedule_cron}".`}`,
      });
    } catch (e: any) {
      return JSON.stringify({ ok: false, error: e?.message || String(e) });
    }
  },

  list_schedules: async () => {
    const { listSchedules } = await import('../schedules/store');
    const { decorateSchedule } = await import('../schedules/state');
    const all = listSchedules().map(decorateSchedule);
    return JSON.stringify({
      schedules: all.map((s) => ({
        id: s.id,
        name: s.name,
        enabled: s.enabled,
        active_state: s.active_state,
        schedule_kind: s.schedule_kind,
        body_ref: s.body_ref,
        next_run_at: s.next_run_at,
        last_run_at: s.last_run_at,
      })),
      total: all.length,
    });
  },

  delete_schedule: async (input) => {
    const id = String((input as any)?.id || '').trim();
    if (!id) return JSON.stringify({ ok: false, error: 'id is required' });
    const { deleteSchedule } = await import('../schedules/store');
    const ok = deleteSchedule(id);
    return JSON.stringify({ ok, message: ok ? `Schedule ${id} deleted.` : `Schedule ${id} not found.` });
  },

  run_schedule_now: async (input) => {
    const id = String((input as any)?.id || '').trim();
    if (!id) return JSON.stringify({ ok: false, error: 'id is required' });
    const { getSchedule } = await import('../schedules/store');
    const s = getSchedule(id);
    if (!s) return JSON.stringify({ ok: false, error: `Schedule ${id} not found` });
    const { executeSchedule } = await import('../schedules/scheduler');
    try {
      const runId = await executeSchedule(s, 'manual');
      return JSON.stringify({ ok: true, schedule_id: id, run_id: runId, message: `Schedule "${s.name}" fired. Run id: ${runId}.` });
    } catch (e: any) {
      return JSON.stringify({ ok: false, error: e?.message || String(e) });
    }
  },

  update_schedule: async (input) => {
    const p = (input as any) || {};
    const id = String(p.id || '').trim();
    if (!id) return JSON.stringify({ ok: false, error: 'id is required' });
    const patch: Record<string, unknown> = {};
    if (typeof p.enabled === 'boolean') patch.enabled = p.enabled;
    if (typeof p.name === 'string') patch.name = p.name;
    if (p.input && typeof p.input === 'object') patch.input = p.input;
    if (Array.isArray(p.skills)) patch.skills = p.skills;
    if (typeof p.every_minutes === 'number') {
      patch.schedule_kind = 'period';
      patch.schedule_interval_minutes = p.every_minutes;
    } else if (typeof p.at === 'string') {
      patch.schedule_kind = 'once';
      patch.schedule_at = p.at;
    } else if (typeof p.cron === 'string') {
      patch.schedule_kind = 'cron';
      patch.schedule_cron = p.cron;
    }
    if (Object.keys(patch).length === 0) {
      return JSON.stringify({ ok: false, error: 'no fields to update (try enabled / name / input / skills / every_minutes / at / cron)' });
    }
    const { updateSchedule, seedNextRunAt } = await import('../schedules/store');
    const ok = updateSchedule(id, patch as any);
    if (ok && (patch.schedule_kind || patch.enabled === true)) seedNextRunAt(id);
    return JSON.stringify({ ok, message: ok ? `Schedule ${id} updated.` : `Schedule ${id} not found.` });
  },

  // Namespace gating meta-tool. Connector tools (mantis.*, gitlab.*, etc.)
  // are NOT in the active tools list by default — only their catalog entry
  // is visible in the system prompt. Calling connector_open({name}) makes
  // that connector's tools available for the rest of this user-task (until
  // the next user message). The handler returns a tiny confirmation; the
  // actual side-effect is that agent-loop scans assistant history for
  // these calls (and direct namespaced calls) to compute the "open set"
  // each turn.
  connector_open: async (input) => {
    const { name } = (input as { name?: string } | undefined) || {};
    if (!name) return JSON.stringify({ ok: false, error: 'name is required (e.g. "mantis", "gitlab", "nac")' });
    const def = getConnector(String(name));
    if (!def) return JSON.stringify({ ok: false, error: `unknown connector: ${name}. Call connector_open with a name from the catalog in the system prompt.` });
    let toolCount = 0;
    for (const entry of getConnectorEntries(def)) {
      toolCount += Object.keys(entry.tools || {}).length;
    }
    return JSON.stringify({
      ok: true,
      connector: name,
      tool_count: toolCount,
      message: `${name} loaded — ${toolCount} tools available next turn. Call them as ${name}.<tool_name>.`,
    });
  },
};

export interface BuiltinToolDef {
  name: string;
  description: string;
  input_schema: { type: 'object'; properties: Record<string, unknown>; required?: string[] };
}

export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
  {
    name: 'get_current_time',
    description: 'Get the current local time as an ISO 8601 string. Use whenever the user asks about "now" or "today".',
    input_schema: { type: 'object', properties: {} },
  },
  {
    name: 'add_enterprise_key',
    description: 'Register an enterprise marketplace key the user has pasted (e.g. "fortinet:github_pat_…" short form, or "github_pat_…@github.com/owner/forge-enterprise-agent-name" long form). Adds the tenant\'s private repo as a higher-priority marketplace source over public forge-connectors / forge-workflow. Call this when the user says something like "here\'s my enterprise key", "我有 fortinet 的 key 给你", or pastes a string matching either format. Triggers an immediate marketplace sync; the new source becomes visible in Settings → Marketplace Providers and in any marketplace listing. DO NOT invent or hallucinate keys — only call with the exact string the user provided.',
    input_schema: {
      type: 'object',
      properties: {
        key: { type: 'string', description: 'The enterprise key string verbatim, as pasted by the user.' },
      },
      required: ['key'],
    },
  },
  {
    name: 'trigger_pipeline',
    description: 'Trigger a Forge pipeline workflow (YAML under flows/). Two-step usage: (1) call with NO args first — returns every workflow + its input schema (which fields are required vs have defaults). (2) call again with workflow=<name> and input={...} passing ONLY required fields and any optional fields the user explicitly specified. NEVER pass invented placeholder values for optional fields with defaults — omit them and the default is used. On success returns JSON: {ok, pipeline_id, workflow, status, terminal, errors?, warning?, hint?}. If the run is still running, follow the hint — call start_watch on get_pipeline_status and STOP polling in this conversation.',
    input_schema: {
      type: 'object',
      properties: {
        workflow: {
          type: 'string',
          description: 'Workflow name (file basename of flows/<name>.yaml). Omit to list workflows + schemas.',
        },
        input: {
          type: 'object',
          description: 'Pipeline input fields as a flat object. Pass ONLY required fields (marked * in the list response) and optional fields the user explicitly named. Omit optional fields to use their defaults.',
        },
        skills: {
          type: 'array',
          items: { type: 'string' },
          description: 'Forge skills (by name) to make available to every Claude task inside the pipeline — injected via --append-system-prompt. Pass when the user mentions skill names ("用 git-savvy", "with the code-reviewer skill"). Call list_forge_context to validate names. Omit if the user didn\'t mention any.',
        },
      },
    },
  },
  {
    name: 'get_pipeline_status',
    description: "Check a Forge pipeline run's live status + per-node results by id. Pass pipeline_id (returned by trigger_pipeline) to get a JSON object: {id, status: 'running'|'done'|'failed'|'cancelled', terminal: bool, workflowName, nodes: [{id, status, error?, outputs}]}. Use whenever the user asks how a running or finished pipeline is doing. For start_watch, pair this with done_match={path:\"status\",equals:\"done\"} (or done_path=\"terminal\" to fire on ANY terminal state) and fail_path=... per your needs.",
    input_schema: {
      type: 'object',
      properties: {
        pipeline_id: {
          type: 'string',
          description: 'Pipeline run id (returned by trigger_pipeline).',
        },
      },
      required: ['pipeline_id'],
    },
  },
  {
    name: 'list_forge_context',
    description: "Return the current Forge instance's local context: project names (use these as input.project for pipelines / dispatch_task), agent profile ids, and installed skills. Call this whenever the user references a project / agent / skill by name and you need to validate the name OR when picking defaults for trigger_pipeline / dispatch_task. No arguments.",
    input_schema: { type: 'object', properties: {} },
  },
  {
    name: 'dispatch_task',
    description: 'Dispatch a one-shot background Claude CLI task in a Forge project. EXPENSIVE — spawns a fresh Claude subprocess that reads/edits code in the target project. Use ONLY for genuine codebase work: "analyze X repo and write findings", "run the test suite and summarize", "fix bug in <project>", "refactor module Y". \n\nDO NOT use for: \n  • Saving a file with content YOU already have → use save_tmp_file. \n  • Reading a Forge-owned file (tmp/scratch/flows/prompts/...) → use read_forge_file. \n  • Listing files in <dataDir>/ → use list_forge_files. \n  • Running a pipeline → use trigger_pipeline. \n  • Inspecting a saved task → use get_task_status. \n\nFor "create / write / save a file with this content" the right tool is ALWAYS save_tmp_file — the LLM has the content, no CLI subprocess needed. \n\nIf the user\'s ask is ambiguous (might be a quick save vs a real codebase task), STOP and ask before dispatching — a user reporting "I just wanted a file" after seeing a task spawn is a clear signal you misclassified. \n\nReturns JSON: {ok, task_id, project, status, hint}. The task runs in the background; if the user wants completion notification, follow the hint — call start_watch on get_task_status and STOP polling in this conversation.',
    input_schema: {
      type: 'object',
      properties: {
        prompt: {
          type: 'string',
          description: 'The full instruction text Claude should execute. Be specific about what files to read, what to produce.',
        },
        project: {
          type: 'string',
          description: 'Forge project name (the working directory). Defaults to "scratch" if omitted. Use "scratch" for connector-only / no-filesystem tasks.',
        },
        agent: {
          type: 'string',
          description: 'Optional agent id override. Omit to use the project default.',
        },
      },
      required: ['prompt'],
    },
  },
  {
    name: 'save_tmp_file',
    description: 'Save text content to <dataDir>/tmp/<filename>. USE THIS — never dispatch_task — when the user says "save this to a file", "create a file with X", "export the results", "give me a downloadable copy". The LLM already has the content; no CLI task needed. Returns `file_url` (file://… that opens directly in Chrome on localhost) plus `path` ("tmp/<filename>") + `local_path`. tmp/ is cache-managed and gets swept by the cache janitor / "forge clean cache" — tell the user it\'s ephemeral. To UPLOAD this file to a connector (e.g. onedrive.upload_file content_base64), pass "scratch://tmp/<filename>" as the arg — Forge base64-encodes server-side; do NOT hand-encode. To read it back: read_forge_file with filename:"tmp/<filename>". Filename must be a bare relative name (no .. or /).',
    input_schema: {
      type: 'object',
      properties: {
        filename: {
          type: 'string',
          description: 'Bare filename like "top5_bugs_20260608.md" — relative, no path traversal. Prefer descriptive names with a date stamp.',
        },
        content: {
          type: 'string',
          description: 'Full file content as a UTF-8 string.',
        },
      },
      required: ['filename', 'content'],
    },
  },
  {
    name: 'read_forge_file',
    description: 'Read any file Forge manages under <dataDir>/ — tmp/ (chat-saved), scratch/ (task workspaces), flows/, prompts/, connectors/, etc. PREFER THIS over dispatch_task when the user wants to inspect a Forge-owned file. filename is a dataDir-relative path: "tmp/report.md", "flows/x.yaml", "connectors/onedrive/manifest.yaml". Sensitive files (encrypt key, sqlite DBs, server log, *-tokens.json at root) are refused. Text returns decoded UTF-8 (capped 256KB); pass as_base64:true for binary (pdf, images, zip). To UPLOAD to a connector, prefer "scratch://<dataDir-rel>" arg — bytes never traverse the model.',
    input_schema: {
      type: 'object',
      properties: {
        filename: { type: 'string', description: 'dataDir-relative path, e.g. "tmp/report.md", "flows/mantis-bug-fix.yaml", "connectors/mantis/manifest.yaml".' },
        as_base64: { type: 'boolean', description: 'Return raw bytes base64-encoded instead of decoded UTF-8 text. Use for binary files.' },
      },
      required: ['filename'],
    },
  },
  {
    name: 'list_forge_files',
    description: 'List files / subdirs anywhere under <dataDir>/ (Forge\'s own data dir). PREFER THIS over `dispatch_task` with `ls` — it\'s sync, in-process, no LLM detour. `dir` is dataDir-relative: pass "tmp" for chat-saved temp files, "scratch" for task workspaces, "flows" / "prompts" / "connectors" for configs. Omit `dir` to see the dataDir root. Each entry has `path` (dataDir-relative), `kind` (file/dir), and `file_url` (file://… opens in Chrome on localhost). Sensitive items (encrypt key, sqlite DBs, server log, *-tokens.json) are filtered out automatically.',
    input_schema: {
      type: 'object',
      properties: {
        dir: { type: 'string', description: 'dataDir-relative directory. Examples: "tmp", "scratch", "flows", "prompts", "connectors/mantis". Omit to list dataDir root.' },
        ext: { type: 'string', description: 'Optional file-extension filter (e.g. ".md", ".yaml"). Lowercase match on suffix.' },
        limit: { type: 'number', description: 'Max entries (default 200, capped at 500).' },
      },
    },
  },
  {
    name: 'cancel_task',
    description: 'Cancel a still-running dispatched task by id. Use when the user says "停止 / cancel / kill the task" — much cleaner than telling them to open the Tasks UI. Safe to call on an already-terminal task (returns ok with note). task_id comes from dispatch_task\'s response.',
    input_schema: {
      type: 'object',
      properties: {
        task_id: { type: 'string', description: 'Task id from dispatch_task.' },
      },
      required: ['task_id'],
    },
  },
  {
    name: 'get_task_status',
    description: "Check a dispatched Forge task's status + result by id. Pass task_id (returned by dispatch_task). Returns JSON: {id, status: 'queued'|'running'|'done'|'failed'|'cancelled', terminal: bool, project, result_summary?, error?, completed_at?}. For start_watch, use done_path=\"terminal\" (fires on done/failed/cancelled) or done_match={path:\"status\",equals:\"done\"}.",
    input_schema: {
      type: 'object',
      properties: {
        task_id: {
          type: 'string',
          description: 'Task id (returned by dispatch_task).',
        },
      },
      required: ['task_id'],
    },
  },
  {
    name: 'list_help_docs',
    description: "List Forge's own documentation files. Call this FIRST whenever the user asks how Forge itself works — its features, settings/config, setup, or troubleshooting (e.g. pipelines, schedules, connectors, telegram, tunnel, workspace/smiths, skills, crafts, usage/cost, agents/models). Returns doc filenames; then read the relevant one(s) with read_help_doc and answer from their content. No arguments.",
    input_schema: { type: 'object', properties: {} },
  },
  {
    name: 'read_help_doc',
    description: 'Read one Forge help doc in full (filename from list_help_docs, e.g. "05-pipelines.md"). Use its content to answer the user\'s question about Forge. Read more than one doc if the question spans topics.',
    input_schema: {
      type: 'object',
      properties: {
        doc: {
          type: 'string',
          description: 'Help doc filename, e.g. "05-pipelines.md" or "10-troubleshooting.md". Get exact names from list_help_docs.',
        },
      },
      required: ['doc'],
    },
  },
  {
    name: 'create_schedule',
    description: 'Create a recurring (or one-off) schedule that fires a Forge pipeline on a timer. NO HTTP, NO auth — runs in-process. Use this when the user says "every N minutes/hours" / "watch X" / "monitor Y" / "auto-run pipeline on schedule". REQUIRED args: name + workflow + ONE of {every_minutes, at, cron}. Returns { ok, schedule_id, next_run_at }.',
    input_schema: {
      type: 'object',
      properties: {
        name: { type: 'string', description: 'Human-readable name shown in the Schedules UI.' },
        workflow: { type: 'string', description: 'Pipeline workflow name (file basename of flows/<name>.yaml). Run trigger_pipeline() with NO args first if unsure what names are available.' },
        input: { type: 'object', description: 'Pipeline input fields. Same shape as trigger_pipeline.input. OMIT optional fields to use defaults.' },
        skills: { type: 'array', items: { type: 'string' }, description: 'Skill names to inject into every Claude task this schedule spawns.' },
        every_minutes: { type: 'number', description: 'Period in minutes (e.g. 60 = hourly). Most common trigger.' },
        at: { type: 'string', description: 'ISO timestamp for one-shot run (e.g. "2026-06-05T09:00:00Z"). Mutually exclusive with every_minutes / cron.' },
        cron: { type: 'string', description: 'Cron expression for complex schedules (e.g. "0 9 * * 1-5" = weekdays 9am). Mutually exclusive with every_minutes / at.' },
        enabled: { type: 'boolean', description: 'Whether to start enabled. Default true.' },
        action: { type: 'string', enum: ['none', 'chat', 'email', 'telegram'], description: 'Post-run notification action. Default "none".' },
      },
      required: ['name', 'workflow'],
    },
  },
  {
    name: 'list_schedules',
    description: 'List all configured schedules with status (active_state: idle / running / last_failed / paused), kind, next_run_at, last_run_at. Use to find a schedule\'s id before update/delete/run.',
    input_schema: { type: 'object', properties: {} },
  },
  {
    name: 'delete_schedule',
    description: 'Permanently delete a schedule by id. Cannot be undone. Find id via list_schedules first.',
    input_schema: {
      type: 'object',
      properties: { id: { type: 'string', description: 'Schedule id from list_schedules.' } },
      required: ['id'],
    },
  },
  {
    name: 'run_schedule_now',
    description: 'Fire a schedule\'s configured pipeline immediately (manual trigger), regardless of when it would next fire on its timer. Returns the run_id.',
    input_schema: {
      type: 'object',
      properties: { id: { type: 'string', description: 'Schedule id from list_schedules.' } },
      required: ['id'],
    },
  },
  {
    name: 'update_schedule',
    description: 'Patch fields on an existing schedule (enable/disable, rename, change input, swap trigger). Only the fields you pass are changed.',
    input_schema: {
      type: 'object',
      properties: {
        id: { type: 'string', description: 'Schedule id from list_schedules.' },
        enabled: { type: 'boolean', description: 'true to enable, false to pause without deleting.' },
        name: { type: 'string' },
        input: { type: 'object', description: 'New pipeline input fields (replaces existing).' },
        skills: { type: 'array', items: { type: 'string' } },
        every_minutes: { type: 'number', description: 'Switch trigger to interval.' },
        at: { type: 'string', description: 'Switch trigger to one-shot at this ISO time.' },
        cron: { type: 'string', description: 'Switch trigger to cron expression.' },
      },
      required: ['id'],
    },
  },
  {
    name: 'connector_open',
    description: 'Load a connector to make its tools (e.g. mantis.search_bugs, gitlab.list_my_todos) available for use. REQUIRED before calling any connector tool — the catalog block in the system prompt shows what each connector can do. Tools stay loaded only for the current user task; the next user message resets the open set, so re-open as needed.',
    input_schema: {
      type: 'object',
      properties: {
        name: {
          type: 'string',
          description: 'Connector id from the catalog (e.g. "mantis", "gitlab", "nac", "tp", "jenkins", "teams", "pmdb", "github-api").',
        },
      },
      required: ['name'],
    },
  },
];

// ─── Connector dispatch ──────────────────────────────────

/** Look up a connector tool by `<connector_id>.<tool_name>` form. */
function findConnectorTool(qualified: string): {
  connectorId: string;
  toolName: string;
  entry: ConnectorEntry;
  tool: ConnectorTool;
  settings: Record<string, any>;
} | null {
  const dot = qualified.indexOf('.');
  if (dot < 1) return null;
  const connectorId = qualified.slice(0, dot);
  const toolName = qualified.slice(dot + 1);
  const def = getConnector(connectorId);
  if (!def) return null;
  const entries = getConnectorEntries(def);
  for (const entry of entries) {
    const tool = entry.tools?.[toolName];
    if (tool) {
      const inst = getInstalledConnector(connectorId);
      return { connectorId, toolName, entry, tool, settings: (inst?.config as Record<string, any>) || {} };
    }
  }
  return null;
}

/**
 * Build the ConnectorPayload-ish object the extension's runner expects.
 * Expands {base_url}/{settings.*} tokens here; {args.*} stays literal —
 * the extension's runner finishes those at execution time.
 */
function buildConnectorPayload(
  def: { id: string; name: string; runner?: 'main' | 'isolated'; host_match?: string; login_redirect?: string; tab_strategy?: 'reuse' | 'ephemeral' },
  entry: ConnectorEntry,
  settings: Record<string, any>,
) {
  const expand = (s: string | undefined) => (s ? expandSettingsTokens(s, settings) : s);
  const hostMatch = expand(entry.host_match || def.host_match);
  const loginRedirect = expand(entry.login_redirect || def.login_redirect);

  // Build entries with expanded page URLs
  const expandedEntry: any = {
    ...entry,
    host_match: hostMatch,
    login_redirect: loginRedirect,
    tools: Object.fromEntries(
      Object.entries(entry.tools || {}).map(([n, t]: [string, any]) => [
        n,
        {
          ...t,
          page: t.page
            ? {
                url: expand(t.page.url),
                ...(t.page.on_target ? { on_target: expand(t.page.on_target) } : {}),
                ...(t.page.settle_after_load_ms != null ? { settle_after_load_ms: t.page.settle_after_load_ms } : {}),
              }
            : undefined,
        },
      ]),
    ),
  };

  return {
    plugin_id: def.id,             // legacy wire-name; extension reads this
    name: def.name,
    mode: 'browser-side',
    installed: true,
    host_match: hostMatch,
    login_redirect: loginRedirect,
    runner: def.runner || entry.runner || 'main',
    // Connector-level opt-in: 'ephemeral' = always open a fresh background
    // tab, close after. Missing/'reuse' = pick an existing matching tab.
    // Was being silently dropped — extension never saw it, so manifests
    // with tab_strategy:ephemeral (mantis, tp) ran in reuse mode.
    ...(def.tab_strategy ? { tab_strategy: def.tab_strategy } : {}),
    entries: [expandedEntry],
  };
}

/**
 * Walk the active wizard template's `_prompts` and find the one whose
 * `${key}` is substituted into `<connectorId>.config.<field>`. Returns
 * the prompt's url + label hint so the chat-side preflight can mirror
 * the wizard's "click here to create your PAT" UX.
 *
 * Returns null when no template / no matching prompt. Best-effort —
 * the chat error still gives the user enough to act on.
 */
function lookupWizardPromptHint(
  connectorId: string,
  field: string,
): { url?: string; url_label?: string; prompt_label?: string } | null {
  const resolved = resolveWizardTemplate();
  if (!resolved) return null;
  const tmpl = resolved.template as any;
  if (!tmpl || typeof tmpl !== 'object') return null;
  const connectorBlock = tmpl[connectorId];
  const rawVal = connectorBlock?.config?.[field];
  if (typeof rawVal !== 'string') return null;
  const m = rawVal.match(/^\$\{([a-zA-Z0-9_]+)\}$/);
  if (!m) return null;
  const promptKey = m[1];
  const p = tmpl._prompts?.[promptKey];
  if (!p) return null;
  return {
    url: typeof p.url === 'string' ? p.url : undefined,
    url_label: typeof p.url_label === 'string' ? p.url_label : undefined,
    prompt_label: typeof p.label === 'string' ? p.label : undefined,
  };
}

/**
 * Check that every `required: true` setting on the connector has a
 * non-empty value in the user's installed config. Returns a chat-
 * friendly error string (with PAT-creation URL when the wizard template
 * declares one) when something's missing, else null.
 */
function preflightConnectorSettings(
  def: ConnectorDefinition,
  settings: Record<string, any>,
): string | null {
  const schema = def.settings || {};
  const missing: Array<{ key: string; label: string; hint: ReturnType<typeof lookupWizardPromptHint> }> = [];
  for (const [key, fdef] of Object.entries(schema)) {
    if (!fdef?.required) continue;
    const v = settings?.[key];
    const empty = v == null
      || (typeof v === 'string' && v.trim() === '')
      || (Array.isArray(v) && v.length === 0);
    if (!empty) continue;
    missing.push({
      key,
      label: fdef.label || key,
      hint: lookupWizardPromptHint(def.id, key),
    });
  }
  if (missing.length === 0) return null;

  const lines = [
    `${def.name} isn't configured yet — needs ${missing.length === 1 ? '1 field' : `${missing.length} fields`} before tools can run:`,
    '',
  ];
  for (const m of missing) {
    lines.push(`• ${m.label} (${m.key})`);
    if (m.hint?.url) {
      lines.push(`  ${m.hint.url_label || 'Get it here'}: ${m.hint.url}`);
    }
  }
  lines.push('');
  lines.push(`Then open Settings → Connectors → ${def.name} to paste the values in.`);
  return lines.join('\n');
}

// ─── Public entry ─────────────────────────────────────────

export interface DispatchOptions {
  /** Per-turn extra builtins (e.g. memory_* when Temper is configured). */
  extraBuiltins?: Record<string, BuiltinHandler>;
  /**
   * Skip the HTTP-protocol 8KB body cap. Set by callers that parse the
   * response programmatically (Jobs scheduler, /api/jobs/preview) and
   * therefore don't need an LLM-friendly truncation.
   */
  noTruncation?: boolean;
  /** Chat session that triggered this call — used to register a watch
   *  (async tools) bound to the right session for completion callbacks. */
  sessionId?: string;
  /** Remaining chain budget for async watch callbacks. Defaults to the
   *  full depth at the top level; decremented when a watch chains a tool. */
  chainDepth?: number;
}

export async function dispatchTool(
  call: ToolCall,
  optsOrExtraBuiltins?: DispatchOptions | Record<string, BuiltinHandler>,
): Promise<ToolResult> {
  // Back-compat: accept either DispatchOptions or a bare extraBuiltins map
  // so older callers (chat agent-loop etc.) keep working without edits.
  const opts: DispatchOptions = optsOrExtraBuiltins && 'extraBuiltins' in (optsOrExtraBuiltins as any)
    ? (optsOrExtraBuiltins as DispatchOptions)
    : optsOrExtraBuiltins && 'noTruncation' in (optsOrExtraBuiltins as any)
      ? (optsOrExtraBuiltins as DispatchOptions)
      : { extraBuiltins: optsOrExtraBuiltins as Record<string, BuiltinHandler> | undefined };
  const extraBuiltins = opts.extraBuiltins;
  // Per-turn builtins first (memory tools), then global builtins.
  const dynBuiltin = extraBuiltins?.[call.name];
  if (dynBuiltin) {
    try { return { content: await dynBuiltin(call.input) }; }
    catch (e) { return { content: `tool '${call.name}' failed: ${(e as Error).message}`, is_error: true }; }
  }
  const builtin = BUILTINS[call.name];
  if (builtin) {
    try {
      const content = await builtin(call.input);
      return { content };
    } catch (e) {
      return { content: `builtin tool '${call.name}' failed: ${(e as Error).message}`, is_error: true };
    }
  }

  // Connector tool
  const located = findConnectorTool(call.name);
  if (!located) {
    return { content: `unknown tool: ${call.name}`, is_error: true };
  }

  const def = getConnector(located.connectorId)!;
  const protocol = located.tool.protocol || 'browser';
  const argInput = (call.input ?? {}) as Record<string, any>;

  // Apply each parameter's `default` for keys the model omitted, so
  // template tokens like {args.scp_host} resolve instead of staying
  // literal. JSON-schema defaults are only advisory to the model — it
  // routinely drops optional fields — so fill them here. Only sets
  // missing/null; never overrides a value the model actually passed.
  for (const [pname, pdef] of Object.entries(located.tool.parameters || {})) {
    if (pdef && typeof pdef === 'object' && 'default' in (pdef as any)
        && (argInput[pname] === undefined || argInput[pname] === null)) {
      argInput[pname] = (pdef as any).default;
    }
  }

  // Multi-instance overlay: when a connector's settings carry a
  // `instances` array of `{name, ...}` objects, the tool's `instance`
  // arg picks one and its fields are merged into the top-level settings
  // namespace so templates like `{settings.base_url}` resolve against
  // the chosen instance. Strictly guarded so connectors without this
  // shape (the existing gitlab/mantis/teams/github-api/pmdb) are
  // completely unaffected.
  let effectiveSettings = located.settings;
  let instances = (located.settings as any)?.instances;
  // The `type: json` settings UI stores the field as a string literal,
  // not a parsed value — accept both forms (string from the form,
  // already-parsed array from manifests that ship a default).
  if (typeof instances === 'string') {
    try { instances = JSON.parse(instances); } catch { instances = null; }
  }
  if (
    Array.isArray(instances) &&
    instances.length > 0 &&
    instances.every((i: any) => i && typeof i === 'object' && typeof i.name === 'string')
  ) {
    const wanted = typeof argInput.instance === 'string' ? argInput.instance : undefined;
    const inst = wanted
      ? instances.find((i: any) => i.name === wanted)
      : instances[0];
    if (wanted && !inst) {
      const available = instances.map((i: any) => i.name).join(', ');
      return { content: `unknown instance "${wanted}". Available: ${available}`, is_error: true };
    }
    effectiveSettings = { ...located.settings, ...inst };
  }

  // Post-overlay arg defaults — for any tool parameter the LLM omitted,
  // check effectiveSettings for a `default_<paramName>` field and use
  // its value. Lets connectors declare instance-level defaults (e.g.
  // Jenkins `default_job_path = "fortinac-build-7.6-token"`) so users
  // can say "build fortinac" without spelling out the job path every
  // time. Strict: only string args, only when missing or blank, only
  // when the default is non-empty.
  //
  // Two-pass with consumed-default tracking. Exact `default_<pname>`
  // wins (e.g. `default_project_name` → `project_name` arg). Stem
  // match is the safety net (e.g. `default_project_path` → `project_id`
  // arg, since GitLab accepts URL-encoded paths as :id). The consumed
  // set keeps a default value from polluting MULTIPLE sibling params —
  // before this, both `project_id` AND `project_name` would receive
  // the same value from `default_project`, breaking Mantis where
  // project_id must be numeric.
  const stem = (n: string) => n.replace(/_(id|path|name|key|slug)$/, '');
  const paramNames = Object.keys(located.tool.parameters || {});
  const isMissing = (pname: string) => {
    const v = argInput[pname];
    return v == null || (typeof v === 'string' && v.trim() === '');
  };
  const consumed = new Set<string>();
  // Pass 1 — exact default_<pname> matches.
  for (const pname of paramNames) {
    if (!isMissing(pname)) continue;
    const key = `default_${pname}`;
    const v = (effectiveSettings as any)?.[key];
    if (typeof v === 'string' && v.trim() !== '') {
      argInput[pname] = v;
      consumed.add(key);
    }
  }
  // Pass 2 — stem-match for still-unfilled params, skipping defaults
  // already consumed by pass 1.
  for (const pname of paramNames) {
    if (!isMissing(pname)) continue;
    const want = stem(pname);
    for (const k of Object.keys(effectiveSettings || {})) {
      if (consumed.has(k)) continue;
      if (!k.startsWith('default_')) continue;
      if (stem(k.slice('default_'.length)) !== want) continue;
      const v = (effectiveSettings as any)[k];
      if (typeof v === 'string' && v.trim() !== '') {
        argInput[pname] = v;
        consumed.add(k);
        break;
      }
    }
  }

  // Preflight: any required setting blank? Bail with a wizard-style
  // error pointing at the credential-creation URL instead of letting
  // the call HTTP-401 (or browser-script silently misbehave) at the
  // user. Mirrors the wizard's required-field message verbatim when
  // a matching ${key} prompt exists.
  const cfgGap = preflightConnectorSettings(def, effectiveSettings);
  if (cfgGap) return { content: cfgGap, is_error: true };

  // Resolve any `scratch://<file>` arg into the file's base64 content,
  // server-side — so the LLM can hand a saved file to a connector
  // (e.g. onedrive.upload_file content_base64) without hand-encoding it.
  try {
    await resolveScratchRefsInArgs(argInput);
  } catch (e) {
    return { content: `scratch reference failed: ${(e as Error).message}`, is_error: true };
  }

  try {
    let result: ToolResult;
    switch (protocol) {
      case 'http':
        result = await runHttp({ tool: located.tool, settings: effectiveSettings, args: argInput, connectorAuth: def.auth, noTruncation: opts.noTruncation, verifyTls: def.http?.verify_tls });
        break;
      case 'shell':
        result = await runShell({ tool: located.tool, settings: effectiveSettings, args: argInput });
        break;
      case 'ssh':
        result = await runSsh({ tool: located.tool, settings: effectiveSettings, args: argInput });
        break;
      case 'browser': {
        // Hand the whole connector + tool spec + input + settings to the
        // extension's runner.ts via the bridge. The extension keeps owning
        // the runner logic (tab acquire, navigate, executeScript).
        const connector = buildConnectorPayload(def, located.entry, effectiveSettings);
        const r = (await bridgeRpc('connector.run', {
          pluginId: located.connectorId,       // wire-name kept for extension
          toolName: located.toolName,
          input: argInput,
          connector,
          settings: effectiveSettings,
        }, located.tool.timeout_ms)) as { content?: string; is_error?: boolean } | null;
        result = { content: r?.content ?? '(no content returned)', is_error: !!r?.is_error };
        break;
      }
      default:
        return { content: `unknown protocol "${protocol}" on tool ${call.name}`, is_error: true };
    }

    // Async (long-task watch): if the tool declared an `async` block and
    // it ran without error, register a background watch that polls to
    // completion and reports back to the originating chat session. The
    // tool's own result is returned to the caller immediately (detach).
    if (located.tool.async && !result.is_error) {
      try {
        const { registerWatch, DEFAULT_CHAIN_DEPTH } = await import('../watch/register');
        let parsed: unknown = result.content;
        try { parsed = JSON.parse(result.content); } catch { /* keep string */ }
        const reg = registerWatch({
          spec: located.tool.async,
          connectorId: located.connectorId,
          toolName: located.toolName,
          args: argInput,
          result: parsed,
          settings: effectiveSettings,
          sessionId: opts.sessionId ?? null,
          chainDepth: opts.chainDepth ?? DEFAULT_CHAIN_DEPTH,
        });
        const note = reg.ok
          ? `\n\n[watch ${reg.watch_id} registered — polling in the background; you'll get a chat update on completion.]`
          : `\n\n[watch not registered: ${reg.reason}]`;
        result = { ...result, content: result.content + note };
      } catch (e) {
        console.warn('[dispatch] registerWatch failed', (e as Error).message);
      }
    }
    return result;
  } catch (e) {
    return { content: `connector tool failed: ${(e as Error).message}`, is_error: true };
  }
}
