import * as fs from 'fs';
import * as path from 'path';
import * as child_process from 'child_process';
import { ToolDef } from '../../llm/openai-compat.js';
import type { Message } from '../../llm/openai-compat.js';
import { logger } from '../../logger.js';
import type { SearchFilterConfig } from '../../config.js';
import type { ContextManager } from '../context-manager.js';
import { executeSandboxedBash, isBwrapAvailable, buildSandboxEnv, type SandboxedBashResult } from './sandbox.js';
import { resolveSandboxOverlay } from '../python-packages.js';
import { looksLikeBinaryBytes, SNIFF_HEAD_BYTES, stripLeadingBom } from './binary-detect.js';
import { detectTextEncoding, decodeFileText, encodeFileText, UTF8_BOM_LABEL } from './text-decode.js';
import { recordVersion, resolveWriteTarget } from './conflict-guard.js';

export interface ToolsConfig {
  searxngUrl?: string;           // default: http://searxng:8080
  visionModel?: string;          // default: qwen2-vl:8b-instruct
  visionBaseUrl?: string;        // default: same as provider.baseUrl
  visionTimeout?: number;        // seconds, default: 60
  visionMaxTokens?: number;      // default: 1024
  webfetchTimeout?: number;      // seconds, default: 30
  websearchTimeout?: number;     // seconds, default: 15
  webfetchAllowedHosts?: string[]; // private IPs to explicitly allow
  xCliCommand?: string[] | string;
  xTimeout?: number;
  xAuthToken?: string;
  xCt0?: string;
  xProxy?: string;
  xChromeProfile?: string;
  /** X 投稿の画像/動画 自動 DL モード。default 'auto' (media[] が空でなければ DL) */
  xDownloadMedia?: 'auto' | 'never';
  /** 動画の扱い。default 'thumbnail' (poster のみ)。'full' で variants 最高画質 mp4 も DL、'never' でスキップ */
  xDownloadVideo?: 'thumbnail' | 'full' | 'never';
  /** 1 メディア当たりの DL サイズ上限 MB (default 25) */
  xMediaMaxMb?: number;
  /** 1 メディアあたりの fetch hard timeout 秒 (default 15)。CDN 応答停止で無限 hang を防ぐ */
  xMediaFetchTimeoutSeconds?: number;
  ocrModel?: string;  // GLM-OCR モデル名 (default: glm-ocr)
  /** @deprecated Use AppConfig.searchFilter instead */
  searchFilter?: SearchFilterConfig;
  browserPageTimeout?: number;    // ms, default: 60000
  browserActionTimeout?: number;  // ms, default: 30000
  googleMapsApiKey?: string;      // Google Maps API キー（省略時は Nominatim/OSRM を使用）
  mapsTimeout?: number;           // 地図 API タイムアウト秒 (default: 30)
  amazonAffiliateTag?: string;   // Amazon アソシエイトタグ
  keepaApiKey?: string;          // Keepa API キー（省略時はグラフ画像リンクのみ）
  speechServerUrl?: string;      // 音声認識サーバー URL (例: http://localhost:8000/v1)
  speechTimeout?: number;        // タイムアウト秒 (default: 300)
  speechLanguage?: string;       // デフォルト言語 (default: ja)
  officeExcelMaxSizeMb?: number;        // ReadExcel の最大ファイルサイズ (default: 10)
  officeDocxMaxSizeMb?: number;         // ReadDocx の最大ファイルサイズ (default: 10)
  officePdfMaxSizeMb?: number;          // ReadPdf の最大ファイルサイズ (default: 10)
  officePptxMaxSizeMb?: number;         // ReadPPTX の最大ファイルサイズ (default: 50)
  officePptxMaxUncompressedMb?: number; // ReadPPTX の ZIP 展開後サイズ上限 (default: 200)
  officeMsgMaxSizeMb?: number;          // ReadMsg の最大ファイルサイズ (default: 25)
  webfetchScreenshot?: boolean;         // WebFetch で vlmEnabled 時にスクショを添付するか (default: true)
  webfetchScreenshotTimeoutMs?: number; // スクショ取得のタイムアウト (default: 15000)
}

export interface ToolContext {
  workspacePath: string;    // ツール実行のルートディレクトリ
  /**
   * 実行ログ root（計画5 / spec §10）。logs/bash-history・raw・checklist 等の
   * 実行ログ書込先。未指定時は `join(workspacePath, 'logs')` にフォールバック
   * （後方互換: Phase A では常に undefined なので現行と byte 一致）。
   * runtime_dir は files jail の外になりうるため resolveAndGuard を経由させず直接書く。
   */
  runtimeDir?: string;
  editAllowed: boolean;     // movement の edit フラグ
  vlmEnabled?: boolean;     // worker の vlm=true → ReadImage で AgentLoop LLM に画像注入可能
  allowedCommands?: string[]; // Bash ツールで許可するコマンド名一覧 (省略時は DEFAULT_ALLOWED_COMMANDS)
  bashUnrestricted?: boolean; // true: skip the command whitelist (bwrap/exec is chosen by bashSandbox, not this)
  bashSandbox?: 'auto' | 'always' | 'off'; // サンドボックス機構の選択 (config.safety.bashSandbox 由来)
  bashAllowNetwork?: boolean; // true: bwrap サンドボックスで host network を許可 (--unshare-net を外す。config.safety.bashAllowNetwork 由来)
  skillCatalog?: import('../skills.js').SkillCatalog;
  toolsConfig?: ToolsConfig;
  searchFilter?: SearchFilterConfig; // AppConfig.searchFilter (トップレベル)
  customPiecesDir?: string | string[];
  /**
   * Resolved space folder context (spaces foundation). When set, AGENTS.md /
   * memory / custom-piece resolution uses `(rootDir, leafId)` instead of the
   * legacy `(userFolderRoot, userId)`. Absent => legacy personal-folder
   * resolution (backward compatible; space_id=null tasks behave as before).
   */
  folderContext?: { rootDir: string; leafId: string };
  /**
   * 実行ジョブが属するスペース ID（spaces foundation）。MCP/SSH の per-space
   * 解決に使う予定。null/undefined = global/legacy（全体スコープで解決）。
   * Phase 1 では値を配線するだけで、まだ誰も参照しない（no-op、後方互換）。
   */
  spaceId?: string;
  /**
   * このジョブのスペースが持つ Python パッケージ overlay の `current` シンボリック
   * リンクパス（`{root}/{spaceKey}/current`）。admin が入れた wheels を read-only
   * bind + PYTHONPATH で sandbox に露出する。存在しない（未インストール）なら
   * undefined。worker が spaceId + config から事前解決して渡す。
   */
  pythonPackagesDir?: string;
  /**
   * ローカルタスク ID (string)。BrowseWeb / InteractiveBrowse が
   * per-task noVNC session を使うときのキー。subtask 実行のときは親の
   * localTaskId が入る (worker 側で resolve)。subtask root が gitea issue
   * など local_task に紐づかないジョブのときは undefined になる。
   */
  taskId?: string;
  /**
   * タスク owner の user.id。noVNC upgrade の visibility チェック以外には
   * 直接参照されないが、BrowseWeb が新しい task session を生成するときに
   * 渡してログ / 認可で使う。
   */
  userId?: string;
  /**
   * Job owner user.id. Required by the MCP aggregator to look up the user's
   * stored OAuth tokens. Distinct from `userId` (which is also the owner's id)
   * — kept as a separate field name to make MCP-specific code paths explicit.
   * In practice ownerId === userId today.
   */
  ownerId?: string | null;
  /** Job id. Used by MCP binary saver for output file paths under workspace/output/. */
  jobId?: string | null;
  /** Runtime config for MCP subsystem (timeouts + quotas). Worker reads from AppConfig.mcp. */
  mcpConfig?: import('../../mcp/config.js').McpRuntimeConfig;
  /** Per-movement quota accumulator for MCP binary saves. Reset at movement entry. */
  mcpQuotaState?: { files: number; bytes: number };
  runIsolatedLlm?: (messages: Message[]) => Promise<string>;
  spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>;
  /**
   * WaitSubTask: このジョブに未完了（非 terminal）の子サブタスクが残っているか。
   * worker が getSubJobs ベースで実装する。未配線（サブタスク非対応経路）なら undefined。
   */
  hasPendingSubtasks?: () => Promise<boolean>;
  /**
   * 相 B: 直列・文脈節約型サブエージェント実行。delegate ツールから呼ぶ。
   * 親ループ内で同期的にサブエージェントを完了まで走らせ最終結果のみ返す。
   * agent-loop が深さ上限未満のときだけ注入する（未設定 = delegate 使用不可）。
   */
  runDelegate?: (params: { description: string; prompt: string }) => Promise<{ result: string; status: 'success' | 'aborted' | 'needs_user_input' }>;
  /** 相 B: 現在の委譲ネスト深さ（0=トップ）。runDelegate がサブに +1 して渡す。 */
  delegationDepth?: number;
  /** 相 D0: 現在の delegate run の安定 ID。runDelegateSubAgent がサブ ctx に設定し、
   *  ネストした delegate の parentRunId 導出に使う（イベントの可観測性復元用）。 */
  delegateRunId?: string;
  contextManager?: ContextManager; // Read 系 / Bash の出力サイズをコンテキスト残量に合わせて切り詰めるために使用
  /**
   * Traceability T-1: structured event log writer. Optional at the
   * type boundary so existing tests that construct minimal ToolContexts
   * stay green; the engine (agent-loop / piece-runner) wraps with a
   * NoopEventLogger when undefined so internal emission code never has
   * to deal with the unset case. Production callers (worker → piece-
   * runner) always pass a real FileEventLogger.
   */
  eventLogger?: import('../../progress/event-log.js').EventLogger;
  /**
   * Cancel-traceability PR2: cancellation signal threaded from
   * piece-runner → agent-loop. Tools that spawn child processes / make
   * network calls should pass it to the underlying API so user-initiated
   * cancel actually kills in-flight work instead of letting it run to
   * completion in the background.
   */
  abortSignal?: AbortSignal;
  /**
   * Mission Brief IO. Provided by piece-runner when a local_task ID is
   * known so the MissionUpdate tool and the system-prompt injector
   * can read/write the per-task pinned memo. Subtask contexts that
   * aren't bound to a local_task leave this unset.
   */
  missionBrief?: MissionBriefIO;
  /**
   * Per-task conversation access for SearchTaskConversation / ReadTaskConversation.
   * Set by piece-runner only when bound to a local task (mirrors missionBrief);
   * unset for subtasks / gitea-issue runs so the tools degrade to a friendly
   * no-op instead of leaking other tasks' logs.
   */
  taskConversation?: TaskConversationIO;
  /**
   * Workspace-wide task search for SearchWorkspaceTasks / ReadWorkspaceTaskAround.
   * Bound by the worker to this run's local task + owner (scopes FTS search to the
   * same space the owner can see); unset for subtask / gitea-issue runs so the tool
   * degrades to a no-context message instead of leaking other users' tasks.
   */
  workspaceTaskSearch?: WorkspaceTaskSearchIO;
  /**
   * File-provenance READ side for GetFileProvenance / ListWorkspaceFiles.
   * Bound to this run's workspace_path by the worker (mirrors taskConversation);
   * unset for subtask / gitea-issue runs so the tools degrade to a no-op.
   */
  fileProvenance?: FileProvenanceIO;
  /**
   * File-provenance WRITE side: best-effort recorder invoked by Write / Edit /
   * Bash when a workspace file is created or modified. Bound by the worker to
   * task/job/space + workspace_path; piece + movement injected by piece-runner.
   * MUST never throw into tool execution (callers wrap in try/catch). Undefined
   * (unit tests / non-persistent runs) => recording is skipped.
   */
  recordFileProvenance?: (ev: FileProvenanceEvent) => void;
  /** Decrypted Playwright storageState for the browser session profile bound to this job. */
  browserSessionState?: object;
  /** Profile id for audit/expiry callbacks. */
  browserSessionProfileId?: number;
  /** Profile metadata used by BrowseWeb's expiry check (Task 9 will use this). */
  browserSessionProfile?: { loggedInSelector: string | null; loginUrlPatterns: string[] };
  /** Worker-provided callback invoked when BrowseWeb detects auth expiry. */
  onAuthExpired?: (profileId: number, reason: string) => void;
  /**
   * SSH connection allowlist for this job — the workspace/space-scoped UUID list
   * resolved once per job by the worker (`connectionRepo.listBySpace(spaceId)`)
   * and applied to every movement. Per-piece `allowed_ssh_connections` was
   * removed in the tool-consolidation work. undefined/empty = SSH tools reject
   * (fail-closed) before any other check, so we don't leak the existence of
   * connections outside this workspace.
   */
  allowedSshConnections?: string[];
  /**
   * Phase 7: name of the piece running this tool. Used by SSH tools to evaluate
   * per-piece grants (a grant may apply to a specific piece or to all pieces).
   * Plumbed by piece-runner from `piece.name`.
   */
  pieceName?: string;
  /**
   * Tool-request mechanism: the current movement's name + its effective
   * allowed-tool set (workspace tool policy ∪ per-task grants ∪ META_TOOLS), so RequestTool
   * can tell "already available" from "not allowed here", and the passive
   * blocked-call capture knows which movement to attribute. Plumbed by
   * piece-runner from the prepared movement.
   */
  movementName?: string;
  effectiveToolNames?: string[];
  /**
   * Full catalog of known tool names (static + meta), injected by the tool
   * dispatcher only for RequestTool, so it can classify an unknown tool name
   * (possible hallucination) vs one that exists but isn't allowed here.
   */
  knownToolNames?: string[];
  /**
   * Recorder for tool requests (RequestTool active declaration + passive
   * blocked-call capture). Provided by the worker bound to task/job/space;
   * piece-runner wraps it to inject piece + movement. Undefined => no-op
   * (e.g. unit tests, contexts without a repo).
   */
  recordToolRequest?: (req: {
    toolName: string;
    reason?: string | null;
    category: 'requested' | 'blocked' | 'unknown';
    status?: 'pending' | 'approved' | 'denied' | 'auto_denied';
  }) => void;
  /**
   * Tool-request mechanism: true when this run can pause for inline approval
   * (a user is reachable — a local task, not a subtask/headless run). When
   * false, RequestTool records `auto_denied` and the agent proceeds without
   * the tool (the user finds the request later in the task detail).
   */
  toolApprovalInteractive?: boolean;
  /**
   * Set by RequestTool to signal the movement loop to pause (waiting_human)
   * for inline approval of the named tool. Read + reset by agent-loop.
   */
  pendingToolApproval?: { toolName: string } | null;
  /**
   * Set by WaitSubTask to signal the movement loop to park (waiting_subtasks)
   * until this job's spawned children finish. Read + reset by agent-loop, which
   * then resumes the SAME movement. Mirrors pendingToolApproval.
   */
  pendingSubtaskWait?: boolean;
  /**
   * Package-request mechanism: recorder for agent-declared Python package
   * requests (RequestPackage). Bound by the worker (task/job/space) ONLY when
   * the feature is enabled AND the run has a resolvable space. Undefined =>
   * feature unavailable for this run (RequestPackage records nothing and tells
   * the agent to proceed). piece-runner wraps it to inject piece + movement.
   */
  recordPackageRequest?: (req: {
    spec: string;
    normalizedName: string;
    reason?: string | null;
    status?: 'pending' | 'approved' | 'denied' | 'auto_denied';
  }) => void;
  /**
   * Package-request mechanism: packages already installed in this workspace's
   * overlay (name→canonical spec, from the space desired-state at job start).
   * Version-aware: a pinned request (`name==version`) is only "already
   * installed" on an exact spec match; a bare-name request matches any version.
   * Lets RequestPackage short-circuit (and after a resume, recognise the
   * just-approved one). Undefined when the feature is off.
   */
  installedPackages?: Array<{ name: string; spec: string }>;
  /**
   * Package-request mechanism: PEP 503 normalized names already declined
   * (denied / auto_denied) for THIS job. RequestPackage short-circuits a
   * re-request for one of these instead of re-parking — otherwise a deny would
   * loop (deny → movement re-runs → re-request → park). Undefined => none.
   */
  declinedPackages?: string[];
  /**
   * Set by RequestPackage to signal the movement loop to pause (waiting_human)
   * for inline approval of the named package. Read + reset by agent-loop.
   * Mirrors pendingToolApproval.
   */
  pendingPackageApproval?: { spec: string } | null;
  /** Per-task option: when true, MCP tools are not loaded/dispatched. */
  mcpDisabled?: boolean;
  /** Per-task option: when true, skill index is not injected into the system prompt. */
  skillsDisabled?: boolean;
  /**
   * Role of the job owner ('admin' | 'user'). Used to gate admin-only tool
   * actions (e.g. system-scope InstallSkill). Defaults to 'user' when unset
   * (no-auth mode or unresolved owner).
   */
  userRole?: 'admin' | 'user';
  /**
   * Spaces foundation (plan 3): conflict detection for persistent (shared)
   * workspaces. When `true`, Read/Edit/Write record per-file content versions
   * in a workspace sidecar ledger, and Write to a file that changed on disk
   * since the agent last saw it (or was never read) archives the old file under
   * old/ before writing the new content in place. Default `undefined`
   * (= OFF): Read/Write/Edit behave byte-identically to legacy. Only the
   * worker sets it `true`, for persistent local-task workspaces.
   */
  conflictDetection?: boolean;
  /**
   * 競合台帳 dir（計画5 / spec §10.3）。台帳は files ツリーの外に住む:
   * スペースタスクは `space/{id}/.conflict`、それ以外は `join(workspacePath,'.conflict')`。
   * 未指定時は後方互換で `join(workspacePath,'.conflict')` にフォールバックする。
   */
  conflictDir?: string;
}

export interface MissionBriefValue {
  goal: string;
  done: string;
  open: string;
  clarifications: string;
  user_constraints?: string;
  decisions?: string;
  current_focus?: string;
}

export interface MissionBriefIO {
  read(): MissionBriefValue | null;
  update(patch: Partial<MissionBriefValue>): MissionBriefValue | null;
}

/** Minimal comment shape the conversation tools need (subset of LocalTaskComment). */
export interface TaskConversationComment {
  id: number;
  author: string;
  kind: string;
  body: string;
  createdAt: string;
}

/**
 * Per-task conversation access for SearchTaskConversation / ReadTaskConversation.
 * Bound to a single local task (comments) + this run's transcript path so the
 * tools can never reach another task's logs. Constructed by the worker layer
 * (which owns the repo) and threaded through ToolContext, mirroring MissionBriefIO.
 */
export interface TaskConversationIO {
  listComments(): Promise<TaskConversationComment[]>;
  /** Absolute path to this run's transcript.jsonl (may not exist yet). */
  transcriptPath?: string;
}

/**
 * Workspace-wide task-comment search for SearchWorkspaceTasks / ReadWorkspaceTaskAround.
 * Scoped by the worker layer to the current task's space + owner; the tool can only
 * ever search/read within that scope. `fts5Available` lets the tool degrade gracefully
 * (e.g. LIKE-based fallback messaging) when the FTS5 index table doesn't exist.
 */
export interface WorkspaceTaskSearchIO {
  fts5Available: boolean;
  search(
    query: string,
    opts: { limit?: number; kind?: string; author?: string; taskId?: number },
  ): Promise<
    Array<{
      taskId: number;
      taskTitle: string;
      commentId: number;
      author: string;
      kind: string;
      createdAt: string;
      text: string;
    }>
  >;
  around(
    commentId: number,
    before: number,
    after: number,
  ): Promise<{
    taskId: number;
    taskTitle: string;
    comments: Array<{
      commentId: number;
      author: string;
      kind: string;
      createdAt: string;
      body: string;
      isCenter: boolean;
    }>;
  } | null>;
}

/** How a workspace file first came to exist (its creator's kind). */
export type FileProvenanceSourceKind =
  | 'user_input'
  | 'agent_output'
  | 'agent_edit'
  | 'bash_generated'
  | 'subtask_output'
  | 'imported_existing'
  | 'unknown';

/** Compact provenance record for one workspace file (no file contents). */
export interface FileProvenanceRecord {
  relPath: string;
  sourceKind: FileProvenanceSourceKind;
  createdByTaskId: number | null;
  createdByJobId: string | null;
  createdByPiece: string | null;
  createdByMovement: string | null;
  firstSeenAt: string | null;
  lastModifiedByTaskId: number | null;
  lastModifiedByJobId: string | null;
  lastModifiedAt: string | null;
  checksum: string | null;
  note: string | null;
}

export interface FileProvenanceListFilters {
  /** workspace-relative path prefix, e.g. 'output/'. */
  pathPrefix?: string;
  sourceKind?: string;
  createdByTaskId?: number;
  lastModifiedByTaskId?: number;
  /** When false, unknown / imported_existing rows are excluded. Default true. */
  includeUnknown?: boolean;
  limit?: number;
}

/**
 * Read side of the provenance ledger, bound by the worker to a SINGLE
 * workspace_path so the agent tools (GetFileProvenance / ListWorkspaceFiles)
 * can only ever see the current run's workspace — never another workspace's
 * (and thus never another user's) file lineage. Unset for subtask / gitea-issue
 * runs, in which case the tools degrade to a friendly no-op.
 */
export interface FileProvenanceIO {
  get(relPath: string): FileProvenanceRecord | null;
  list(filters?: FileProvenanceListFilters): FileProvenanceRecord[];
}

/** Write side: a best-effort recorder bound by the worker to task/job/space. */
export interface FileProvenanceEvent {
  relPath: string;
  event: 'create' | 'modify';
  /** Creator kind — only consulted when this event first inserts the row. */
  sourceKind?: FileProvenanceSourceKind;
  checksum?: string;
}

export interface ToolResult {
  output: string;
  isError: boolean;
  structuredBlocks?: import('./structured-blocks.js').StructuredBlock[];
  images?: Array<{ dataUrl: string; label?: string }>;
}

const BLOCKED_BINARY_EXTENSIONS = new Set([
  '.png',
  '.jpg',
  '.jpeg',
  '.gif',
  '.webp',
  '.bmp',
]);

// ドキュメント系: 拡張子から office.ts の抽出ハンドラへ内部ディスパッチする。
// Read が唯一の入口で、旧 ReadExcel / ReadDocx / ReadPdf / ReadPPTX は登録ツールとしては廃止。
const DOCUMENT_EXTENSION_TOOL: Record<string, string> = {
  '.xlsx': 'ReadExcel',
  '.xls': 'ReadExcel',
  '.xlsm': 'ReadExcel',
  '.docx': 'ReadDocx',
  '.doc': 'ReadDocx',
  '.pptx': 'ReadPPTX',
  '.ppt': 'ReadPPTX',
  '.pdf': 'ReadPdf',
};

// Outlook メール: 添付保存の副作用があるため office.ts の ReadMsg ハンドラへ委譲する。
const MSG_EXTENSIONS = new Set(['.msg']);

/**
 * offset/limit/byte_offset/byte_length はテキスト読み取り専用。PDF/Office/.msg の
 * 抽出ハンドラはこれらを読まないため、指定しても黙って無視され「常に先頭から」に
 * なる（PDF を offset でずらして読もうとして毎回 1 ページ目に戻る典型不具合）。
 * ドキュメント形式にこれらが渡されたら、無視する代わりに正しい範囲指定パラメータ
 * （PDF=page_range、Excel=sheet/range 等）へ誘導する注記を返す。該当が無ければ null。
 */
export function documentRangeHint(ext: string, input: Record<string, unknown>): string | null {
  const usedTextRange =
    input['offset'] !== undefined ||
    input['limit'] !== undefined ||
    input['byte_offset'] !== undefined ||
    input['byte_length'] !== undefined;
  if (!usedTextRange) return null;

  const perFormat: Record<string, string> = {
    '.pdf':
      'PDF はページを指定して読むには page_range を使ってください（例: Read({ file_path, page_range: "5-10" })）。特定の語の周辺だけ読むなら query も使えます。',
    '.xlsx': 'Excel は sheet と range で範囲を指定してください（例: Read({ file_path, sheet: "Sheet1", range: "A1:D50" })）。',
    '.xls': 'Excel は sheet と range で範囲を指定してください（例: Read({ file_path, sheet: "Sheet1", range: "A1:D50" })）。',
    '.xlsm': 'Excel は sheet と range で範囲を指定してください（例: Read({ file_path, sheet: "Sheet1", range: "A1:D50" })）。',
  };
  const specific = perFormat[ext] ?? 'この形式での範囲指定方法は ReadToolDoc({ name: "Read" }) を参照してください。';
  return `ℹ️ offset/limit/byte_offset はテキスト読み取り専用で、この形式（${ext}）では無視されました。${specific}`;
}

/** Prepend the range-param steering note (if any) to a document read result. */
function withDocumentRangeHint(ext: string, input: Record<string, unknown>, base: ToolResult): ToolResult {
  const hint = documentRangeHint(ext, input);
  if (!hint) return base;
  return { ...base, output: `${hint}\n\n${base.output}` };
}

// 一般的に Read で開く価値が無いバイナリ拡張子（誘導先なし）
const BLOCKED_OPAQUE_BINARY_EXTENSIONS = new Set([
  '.zip', '.tar', '.gz', '.tgz', '.bz2', '.xz', '.7z', '.rar',
  '.exe', '.dll', '.so', '.dylib', '.o', '.obj', '.a', '.lib',
  '.bin', '.dat', '.db', '.sqlite', '.sqlite3',
  '.ttf', '.otf', '.woff', '.woff2', '.eot',
  '.mp3', '.mp4', '.m4a', '.m4v', '.mov', '.avi', '.mkv', '.webm',
  '.wav', '.flac', '.ogg', '.opus',
  '.ico', '.tiff', '.tif',
  '.class', '.jar', '.war', '.pyc', '.pyo', '.wasm',
]);

function readHeadBytes(filePath: string, maxBytes: number): Buffer | null {
  try {
    const fd = fs.openSync(filePath, 'r');
    try {
      const stat = fs.fstatSync(fd);
      const size = Math.min(stat.size, maxBytes);
      const buf = Buffer.alloc(size);
      if (size > 0) fs.readSync(fd, buf, 0, size, 0);
      return buf;
    } finally {
      fs.closeSync(fd);
    }
  } catch {
    return null;
  }
}

/**
 * Encode Write content for disk. When overwriting an existing file that is
 * readable non-UTF-8 text (e.g. Shift_JIS), keep that encoding so a full
 * rewrite does not silently flip the file to UTF-8. New files and UTF-8 files
 * are written as UTF-8. Encoding is judged from the whole existing file (an
 * ASCII prefix must not hide a Shift_JIS tail). If the new content has chars the
 * original encoding cannot represent, returns an error instead of a lossy save.
 */
function encodeWriteContent(
  resolved: string,
  content: string,
  existedBefore: boolean,
): { bytes: Buffer } | { error: string } {
  if (existedBefore) {
    try {
      const decoded = decodeFileText(fs.readFileSync(resolved));
      if (!decoded.binary && decoded.encoding !== 'utf-8') {
        const bytes = encodeFileText(content, decoded.encoding);
        if (bytes === null) {
          return {
            error:
              `内容に ${decoded.encoding} で表現できない文字（絵文字など）が含まれるため、破損を避けて保存を中止しました。` +
              `既存ファイルの文字コードは ${decoded.encoding} です。UTF-8 の別ファイルに保存するか、表現可能な文字を使ってください。`,
          };
        }
        return { bytes };
      }
    } catch {
      /* fall through to UTF-8 */
    }
  }
  return { bytes: Buffer.from(content, 'utf-8') };
}

const BASH_MAX_BUFFER_BYTES = 10 * 1024 * 1024;

// Read the whole file into memory to detect its encoding only up to this size.
// Beyond it we fall back to head-only detection (byte-range's main use is huge
// minified/log files, which are UTF-8/ASCII) so a 2 GB target cannot OOM us.
const FULL_DETECT_MAX_BYTES = 25 * 1024 * 1024;

// --- コンテキスト予算に基づく切り詰め ---

const CHARS_TO_TOKENS = 1.5; // 保守的（ASCII worst case: 1 char ≈ 1.5 tokens）
const ABSOLUTE_MAX_OUTPUT_TOKENS = 60_000; // contextManager 不在時の絶対上限
const MIN_RETURNED_TOKENS = 2_000; // 切り詰め下限（小さすぎても意味がないので最低限返す）
const ENCODED_PAYLOAD_MAX_OUTPUT_TOKENS = 2_000; // base64/data URL を含む HTML 等は通常より強く抑制
const TRUNCATION_NOTICE_RESERVE_CHARS = 600; // 注記分の予約

export function estimateTokensFromChars(chars: number): number {
  return Math.ceil(chars * CHARS_TO_TOKENS);
}

export function estimateCharsForTokenBudget(tokens: number): number {
  return Math.floor(tokens / CHARS_TO_TOKENS);
}

/**
 * 残コンテキストから「1 回の tool result に使って良い」トークン予算を決める。
 * - contextManager があれば getAvailableTokens の 50% を目安にする
 * - なければ ABSOLUTE_MAX_OUTPUT_TOKENS を返す
 */
export function getToolOutputBudgetTokens(ctx: ToolContext): number {
  const cm = ctx.contextManager;
  if (cm) {
    const available = cm.getAvailableTokens();
    // 半分を 1 回のツール出力に割り当て（残り半分は会話履歴や次の思考用）
    const budget = Math.floor(available * 0.5);
    return Math.max(MIN_RETURNED_TOKENS, Math.min(budget, ABSOLUTE_MAX_OUTPUT_TOKENS));
  }
  return ABSOLUTE_MAX_OUTPUT_TOKENS;
}

/**
 * コンテンツが改行構造を持つかを判定する。
 * - 1KB あたり 1 本以上の改行があれば「行指向」とみなす
 * - 行指向なら line 単位で切り詰め、そうでなければ byte/char 単位で切り詰める
 */
function isLineOriented(content: string): boolean {
  if (content.length < 500) return content.includes('\n');
  const newlines = (content.match(/\n/g) || []).length;
  return newlines >= content.length / 1024;
}

function looksLikeLargeEncodedPayload(content: string): boolean {
  if (content.length < 8_000) return false;
  if (/data:[^;,\s]+;base64,[A-Za-z0-9+/=\s]{2000,}/.test(content)) return true;
  if (/base64[,:"'\s]+[A-Za-z0-9+/=\s]{2000,}/i.test(content)) return true;
  return false;
}

function capBudgetForEncodedPayload(content: string, budgetTokens: number): number {
  if (!looksLikeLargeEncodedPayload(content)) return budgetTokens;
  return Math.min(budgetTokens, ENCODED_PAYLOAD_MAX_OUTPUT_TOKENS);
}

/**
 * 文字列が budget を超える場合、先頭側を残して切り詰め、統一書式の注記を付ける。
 * 行指向コンテンツは改行境界で、そうでなければ文字境界で切る。
 * 注記には元サイズ・返却サイズ・続きの読み方を含める。
 */
export function truncateToBudget(
  content: string,
  budgetTokens: number,
  options: {
    sourceLabel: string;        // "file.txt" など、何のコンテンツか
    totalLines?: number;         // 元の行数（分かる場合）
    totalBytes?: number;         // 元のバイト数（分かる場合）
    continuationHint?: string;   // 続きの読み方（上書き）。未指定時は行指向/非行指向で自動選択
  },
): { text: string; truncated: boolean; returnedTokensEstimate: number; cutCharIndex: number } {
  const totalChars = content.length;
  const totalTokens = estimateTokensFromChars(totalChars);
  if (totalTokens <= budgetTokens) {
    return { text: content, truncated: false, returnedTokensEstimate: totalTokens, cutCharIndex: totalChars };
  }

  const budgetChars = estimateCharsForTokenBudget(budgetTokens) - TRUNCATION_NOTICE_RESERVE_CHARS;
  const safeBudgetChars = Math.max(1000, budgetChars);

  const lineOriented = isLineOriented(content);
  let cutIndex = Math.min(safeBudgetChars, content.length);
  if (lineOriented) {
    const lastNewline = content.lastIndexOf('\n', cutIndex);
    if (lastNewline > safeBudgetChars * 0.8) {
      cutIndex = lastNewline;
    }
  }
  const truncatedText = content.slice(0, cutIndex);
  const returnedTokens = estimateTokensFromChars(truncatedText.length);
  const returnedLines = truncatedText.split('\n').length;
  const returnedBytes = Buffer.byteLength(truncatedText, 'utf-8');

  const sizeParts: string[] = [];
  sizeParts.push(`推定 ${totalTokens.toLocaleString()} tokens`);
  if (options.totalLines !== undefined) sizeParts.push(`${options.totalLines.toLocaleString()} 行`);
  if (options.totalBytes !== undefined) sizeParts.push(`${options.totalBytes.toLocaleString()} bytes`);

  const defaultHint = lineOriented
    ? `続きを読むには Read(offset=${returnedLines}, limit=...) で次の範囲を指定するか、Grep/Bash(head/tail/grep) で必要箇所だけ抽出してください`
    : `このファイルは改行が少なく行指定が効きません。続きは Read(byte_offset=${returnedBytes}, byte_length=...) で指定するか、Grep で必要箇所を抽出してください`;
  const hint = options.continuationHint ?? defaultHint;

  const returnedDescr = lineOriented
    ? `先頭 ${returnedLines.toLocaleString()} 行`
    : `先頭 ${returnedBytes.toLocaleString()} bytes`;

  const notice =
    `[自動切り詰め] ${options.sourceLabel} は大きすぎるため (${sizeParts.join(' / ')})、` +
    `残コンテキスト予算 ${budgetTokens.toLocaleString()} tokens に収まる ${returnedDescr} ` +
    `(約 ${returnedTokens.toLocaleString()} tokens) のみ返却しました。\n` +
    `${hint}\n` +
    `--- 以下、${returnedDescr} ---\n`;

  return {
    text: notice + truncatedText,
    truncated: true,
    returnedTokensEstimate: estimateTokensFromChars((notice + truncatedText).length),
    cutCharIndex: cutIndex,
  };
}

// --- パスガード ---

export function resolveAndGuard(workspacePath: string, filePath: string): string {
  const root = path.resolve(workspacePath);
  const resolved = path.resolve(root, filePath);
  // 1) 字句的な封じ込めチェック（従来動作）
  if (!resolved.startsWith(root + path.sep) && resolved !== root) {
    throw new Error(`Path traversal detected: "${filePath}" is outside workspace`);
  }
  // 2) symlink 脱出チェック。ワークスペース root が実在するときだけ行う
  //    （未作成 root を渡すテスト等の挙動を保つため）。
  if (fs.existsSync(root)) {
    const realRoot = fs.realpathSync(root);
    // resolved から上に辿り、実在する最も深い祖先を見つける
    let probe = resolved;
    while (!fs.existsSync(probe) && path.dirname(probe) !== probe) {
      probe = path.dirname(probe);
    }
    const realProbe = fs.realpathSync(probe);
    if (realProbe !== realRoot && !realProbe.startsWith(realRoot + path.sep)) {
      throw new Error(`Path traversal detected (symlink escape): "${filePath}" resolves outside workspace`);
    }
  }
  return resolved;
}

/**
 * ワークスペース直下の閲覧専用ディレクトリ名。ここ配下のファイルは Read 可・
 * Write/Edit 不可。ユーザーが「エージェントに編集されたくないファイル」を置く領域。
 */
export const READONLY_WORKSPACE_DIR = 'readonly';

/**
 * `resolved`（resolveAndGuard 済みの絶対パス）が `<workspace>/readonly/` 配下なら
 * 例外を投げる。Read は許可し、Write/Edit/上書きだけを拒否する。
 *
 * 注意: Bash はシェル経由でこのガードを迂回できる（完全サンドボックスは別範囲）。
 * システムプロンプトでも readonly/ への書込禁止を指示し、二層で主要動線を塞ぐ。
 */
export function assertWritable(workspacePath: string, resolved: string): void {
  const root = path.resolve(workspacePath);
  const readonlyRoot = path.join(root, READONLY_WORKSPACE_DIR);
  if (resolved === readonlyRoot || resolved.startsWith(readonlyRoot + path.sep)) {
    const rel = path.relative(root, resolved).split(path.sep).join('/');
    throw new Error(
      `"${rel}" は readonly/（閲覧のみ）配下です。ここはユーザー専用の領域で書き込み・編集できません。成果物は output/ に書いてください。`,
    );
  }
}

function normalizeWorkspaceRelativePath(workspacePath: string, targetPath: string): string {
  return path.relative(path.resolve(workspacePath), path.resolve(targetPath)).split(path.sep).join('/');
}

/**
 * Skills live OUTSIDE the file jail (`data/spaces/{id}/skills`, sibling of
 * `files/`) and only appear under `{workspace}/skills/{name}/` after ReadSkill
 * materializes a directory-type skill. Agents often reach for `Read("skills/…")`
 * out of habit and hit a bare ENOENT with no signpost. Given the
 * workspace-relative path of a failed Read, return an actionable hint pointing
 * at ReadSkill/ListSkills — or null when the path is not under `skills/`.
 *
 * `relPath` is already resolveAndGuard-normalized (jail-confined, forward
 * slashes, no `..` escape), so the first segment is a safe skill-name candidate.
 */
function skillReadEnoentHint(ctx: ToolContext, relPath: string): string | null {
  const segments = relPath.split('/').filter(Boolean);
  if (segments[0] !== 'skills') return null;
  const skillName = segments[1];

  // `Read("skills")` itself, or no name segment → generic guidance.
  if (!skillName) {
    return '`skills/` はスキルストアで、Read では直接読めません。ListSkills で一覧を確認し、ReadSkill({ name }) で本文を取得してください。';
  }

  // Already materialized (dir exists) but the requested sub-file is missing:
  // do NOT misdirect toward ReadSkill — the path itself is wrong.
  if (fs.existsSync(path.join(ctx.workspacePath, 'skills', skillName))) {
    return `スキル "${skillName}" は既に workspace の skills/${skillName}/ に展開済みですが、指定したファイル "${relPath}" が見つかりません。skills/${skillName}/ 配下の正しいパスを Glob か Bash の \`ls\` で確認してください。`;
  }

  // Known skill, not yet materialized → the ReadSkill gateway.
  const folder = ctx.folderContext
    ? { root: ctx.folderContext.rootDir, leaf: ctx.folderContext.leafId }
    : { root: ctx.skillCatalog?.getUserRoot() ?? '', leaf: ctx.userId ?? 'local' };
  const known = ctx.skillCatalog?.getForFolder(folder.root, folder.leaf).some((s) => s.name === skillName);
  if (known) {
    return `スキル "${skillName}" は Read では直接読めません（まだ展開されていません）。ReadSkill({ name: "${skillName}" }) を呼ぶと本文が返り、ディレクトリ型スキルなら skills/${skillName}/ に展開されて以後 Read で個別ファイルを読めます。`;
  }

  // Under skills/ but no catalog match → generic guidance.
  return `"${relPath}" は見つかりません。\`skills/\` 配下はスキルストアで、Read では直接読めません。ListSkills で一覧を確認し、ReadSkill({ name }) で本文を取得してください（ディレクトリ型スキルは ReadSkill 後に skills/<name>/ が Read 可能になります）。`;
}

function normalizeAllowedPrefix(prefix: string): string {
  return prefix.replace(/^\/+/, '').replace(/\/+$/, '');
}

export function resolveOutputPathWithin(workspacePath: string, requestedPath: string, allowedPrefixes: string[]): string {
  const resolved = resolveAndGuard(workspacePath, requestedPath);
  const relative = normalizeWorkspaceRelativePath(workspacePath, resolved);
  const normalizedPrefixes = allowedPrefixes.map(normalizeAllowedPrefix).filter(Boolean);
  const isAllowed = normalizedPrefixes.some((prefix) => relative === prefix || relative.startsWith(`${prefix}/`));
  if (!isAllowed) {
    throw new Error(`Output path "${requestedPath}" must be within one of: ${normalizedPrefixes.join(', ')}`);
  }
  return resolved;
}

// --- コマンドホワイトリスト ---

export const DEFAULT_ALLOWED_COMMANDS: string[] = [
  'cat', 'head', 'tail', 'wc', 'sort', 'uniq', 'awk', 'sed', 'grep',
  'jq', 'ls', 'find', 'echo', 'date', 'diff',
  'tr', 'cut', 'paste', 'tee', 'mkdir', 'cp', 'mv', 'touch',
  'basename', 'dirname', 'realpath', 'stat', 'file', 'which',
  'yes', 'true', 'false', 'test', 'expr',
  'python3', 'node',
];

/**
 * コマンド文字列からサブコマンドの先頭トークンを抽出する。
 * パイプ (|)、セミコロン (;)、&&、||、改行 (\n) によって連結された各サブコマンドを分割し、
 * さらにバッククォートや $() によるコマンド置換内のコマンドも抽出する。
 * プロセス置換 <(...) や >(...) も検出する。
 * ここ文字列 <<< も検出する。
 */
export function extractCommandTokens(command: string): string[] {
  const tokens: string[] = [];

  // プロセス置換 <(...) や >(...) を検出して内部を再帰抽出
  const processSubstPattern = /[<>]\(([^)]*(?:\([^)]*\)[^)]*)*)\)/g;
  let psMatch: RegExpExecArray | null;
  while ((psMatch = processSubstPattern.exec(command)) !== null) {
    const inner = psMatch[1] ?? '';
    tokens.push(...extractCommandTokens(inner));
  }

  // $(...) のネストを適切に処理: 深さを追跡しながら抽出
  function extractDollarParens(cmd: string): string[] {
    const inner: string[] = [];
    let i = 0;
    while (i < cmd.length) {
      if (cmd[i] === '$' && cmd[i + 1] === '(') {
        let depth = 1;
        let start = i + 2;
        i = start;
        while (i < cmd.length && depth > 0) {
          if (cmd[i] === '(') depth++;
          else if (cmd[i] === ')') depth--;
          i++;
        }
        const content = cmd.slice(start, i - 1);
        inner.push(content);
        // 再帰的にネストされた $() を処理
        inner.push(...extractDollarParens(content));
      } else {
        i++;
      }
    }
    return inner;
  }

  const dollarParenContents = extractDollarParens(command);
  for (const inner of dollarParenContents) {
    tokens.push(...extractCommandTokens(inner));
  }

  // バッククォート内を抽出して再帰
  const backtickPattern = /`([^`]*)`/g;
  let btMatch: RegExpExecArray | null;
  while ((btMatch = backtickPattern.exec(command)) !== null) {
    const inner = btMatch[1] ?? '';
    tokens.push(...extractCommandTokens(inner));
  }

  // $(...) および `...` およびプロセス置換を除去してからオペレータで分割
  // パイプ(|)、セミコロン(;)、&&、||(論理OR)、改行(\n)、<<<(here string)
  let stripped = command;
  // ヒアドキュメント (<< 'DELIM' ... DELIM, << "DELIM" ... DELIM, <<DELIM ... DELIM) を除去
  stripped = stripped.replace(/<<-?\s*'([^']+)'\n[\s\S]*?\n\1/g, '');
  stripped = stripped.replace(/<<-?\s*"([^"]+)"\n[\s\S]*?\n\1/g, '');
  stripped = stripped.replace(/<<-?\s*([A-Za-z_]\w*)\n[\s\S]*?\n\1/g, '');
  // $(...) を除去 (ネスト対応)
  stripped = stripped.replace(/\$\([^)]*(?:\([^)]*\)[^)]*)*\)/g, '');
  // バッククォートを除去
  stripped = stripped.replace(/`[^`]*`/g, '');
  // プロセス置換を除去
  stripped = stripped.replace(/[<>]\([^)]*\)/g, '');
  // <<< を除去 (here string)
  stripped = stripped.replace(/<<<\s*\S+/g, '');

  // オペレータで分割: ||, &&, |, ;, \n
  const parts = stripped.split(/\|\||&&|\||\n|;/);
  for (const part of parts) {
    const trimmed = part.trim();
    if (trimmed.length === 0) continue;
    // 先頭トークン: 環境変数割り当て (KEY=VALUE) をスキップして最初の単語を取得
    const words = trimmed.split(/\s+/);
    for (const word of words) {
      if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) continue; // env var assignment
      tokens.push(word);
      break;
    }
  }

  return tokens;
}

/**
 * インストール系コマンドパターンを検出してブロックする。
 * apt/pip/npm 等によるパッケージインストールを防止する。
 */
const BLOCKED_INSTALL_PATTERNS: RegExp[] = [
  /\bpython3?\s+-m\s+pip\s+install\b/,
  /\bpip3?\s+install\b/,
  /\bapt(?:-get)?\s+install\b/,
  /\bnpm\s+install\b/,
  /\bnpm\s+i\b/,
  /\byarn\s+add\b/,
  /\bcurl\s+.*\|\s*(?:ba)?sh\b/,
  /\bwget\s+.*\|\s*(?:ba)?sh\b/,
];

/**
 * Packages baked into the runtime python (see runtime/python-requirements.txt).
 * Listed by IMPORT name (what `import` takes), with the pip name in parens where
 * it differs — e.g. you write `import docx`, not `import python-docx`.
 */
export const PREINSTALLED_HINT =
  'pypdf, fitz(pymupdf), pdfplumber, docx(python-docx), pptx(python-pptx), openpyxl, ' +
  'xlsxwriter, xlrd, odf(odfpy), striprtf, bs4(beautifulsoup4), lxml, markdownify, ' +
  'markdown, numpy, pandas, tabulate, dateutil(python-dateutil), matplotlib, ' +
  'PIL(Pillow), charset_normalizer, yaml(PyYAML)';

export function checkBlockedInstallPatterns(command: string): void {
  for (const pattern of BLOCKED_INSTALL_PATTERNS) {
    if (pattern.test(command)) {
      throw new Error(
        `Package installation is not available in the sandbox. ` +
        `These are preinstalled and importable: ${PREINSTALLED_HINT}. ` +
        `If you need another package, request it with RequestPackage({ name: "<pkg>", reason: "..." }) ` +
        `— an approver installs it into this workspace and the run resumes. Do not retry the install here.`
      );
    }
  }
}

const SAFE_ABS_PATH_PREFIXES = [
  '/tmp/',
  '/dev/null',
  '/dev/stdin',
  '/dev/stdout',
  '/dev/stderr',
  '/usr/bin/',
  '/usr/local/bin/',
  '/usr/lib/',
  '/usr/share/',
  '/usr/include/',
  '/bin/',
  '/sbin/',
  '/lib/',
  '/lib64/',
  '/proc/self/',
];

const SAFE_EXACT_ABS_PATHS = new Set([
  '/tmp',
  '/dev/null',
  '/dev/stdin',
  '/dev/stdout',
  '/dev/stderr',
]);

function isAbsPathAllowed(token: string, workspacePath: string): boolean {
  const resolvedWorkspace = path.resolve(workspacePath);
  // Resolve relative tokens against the workspace (the bash cwd).
  const resolved = path.isAbsolute(token)
    ? path.resolve(token)
    : path.resolve(resolvedWorkspace, token);
  if (resolved === resolvedWorkspace || resolved.startsWith(resolvedWorkspace + path.sep)) {
    return true;
  }
  if (SAFE_EXACT_ABS_PATHS.has(token) || SAFE_EXACT_ABS_PATHS.has(resolved)) {
    return true;
  }
  return SAFE_ABS_PATH_PREFIXES.some((prefix) =>
    resolved.startsWith(prefix) || token.startsWith(prefix)
  );
}

export function checkBashPathScope(command: string, workspacePath: string): void {
  // Defense-in-depth path confinement for the non-bwrap (hardened) bash path.
  // Tokenize on shell operators/whitespace, strip quotes and VAR=/--flag=
  // prefixes, then confine any token that looks like a filesystem path.
  // A token "looks like a path" if it is absolute or contains a "/" or is "..".
  // Resolution is workspace-relative, so an in-workspace token (incl. regex
  // patterns / URLs that happen to contain "/") resolves inside the workspace
  // and passes; only tokens that escape the workspace (and aren't a safe
  // system prefix) are rejected. This catches descend-then-escape
  // (output/../../x) and flag/var-prefixed traversal (--file=../../x).
  const rawWords = command.split(new RegExp('[\\s|;&<>()\\x60]+'));
  for (const raw of rawWords) {
    if (!raw) continue;
    // Strip surrounding quotes.
    let token = raw.replace(/^['"]+/, '').replace(/['"]+$/, '');
    // Strip a leading VAR= or --flag= / -f= prefix (keep the value).
    token = token.replace(/^--?[A-Za-z0-9][A-Za-z0-9-]*=/, '');
    token = token.replace(/^[A-Za-z_][A-Za-z0-9_]*=/, '');
    if (!token) continue;
    const looksLikePath = token.startsWith('/') || token.includes('/') || token === '..';
    if (!looksLikePath) continue;
    if (!isAbsPathAllowed(token, workspacePath)) {
      throw new Error(
        `パス "${token}" は workspace 外を参照しています。workspace 内の相対パスを使用してください ` +
        `(許可: ${workspacePath} 配下, /tmp, /usr/bin 等)`
      );
    }
  }
}

export function checkAllowedCommand(command: string, allowedCommands: string[]): void {
  // まずインストールパターンをチェック
  checkBlockedInstallPatterns(command);

  const tokens = extractCommandTokens(command);
  for (const token of tokens) {
    // パスが含まれる場合はベース名で比較 (例: /usr/bin/grep → grep)
    const base = path.basename(token);
    if (!allowedCommands.includes(base)) {
      throw new Error(`Command not allowed: "${base}" is not in the allowed commands list`);
    }
  }
}

// --- ツール定義 ---

const READ_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'Read',
    description: 'テキストや Excel/Word/PDF/PowerPoint/Outlook メール(.msg) を拡張子で自動判定して読み込む。テキストは offset/limit(行)・byte_offset/byte_length(バイト)、PDF はページ送りに page_range、Excel は sheet/range で範囲指定する(offset/limit はテキスト専用)。大きすぎる場合は自動で切り詰める。詳細は ReadToolDoc({ name: "Read" })。',
    parameters: {
      type: 'object',
      properties: {
        file_path: { type: 'string', description: 'workspace 内の相対または絶対パス' },
        offset: { type: 'number', description: '読み始める行番号 (0-indexed, 省略時は先頭)。テキスト読みのみ有効（PDF/Office では無視される）' },
        limit: { type: 'number', description: '読む最大行数 (省略時は全行)。テキスト読みのみ有効（PDF/Office では無視される）' },
        byte_offset: { type: 'number', description: '読み始めるバイト位置。改行のない大容量テキスト向け。offset/limit と排他。テキスト読みのみ有効' },
        byte_length: { type: 'number', description: '読むバイト数。byte_offset と併用。テキスト読みのみ有効' },
        page_range: { type: 'string', description: 'PDF のページ範囲（例 "5-10" や "3"）。PDF を途中から/一部だけ読むときはこれを使う（offset/limit ではなく）' },
      },
      required: ['file_path'],
    },
  },
};

const WRITE_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'Write',
    description: 'ファイルを書き込む。親ディレクトリが存在しない場合は自動作成する。',
    parameters: {
      type: 'object',
      properties: {
        file_path: { type: 'string', description: 'workspace 内の相対または絶対パス' },
        content: { type: 'string', description: '書き込む内容' },
      },
      required: ['file_path', 'content'],
    },
  },
};

const EDIT_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'Edit',
    description: 'ファイル内の old_string を new_string に置換する (最初の一致のみ)。',
    parameters: {
      type: 'object',
      properties: {
        file_path: { type: 'string', description: 'workspace 内の相対または絶対パス' },
        old_string: { type: 'string', description: '置換前の文字列 (完全一致)' },
        new_string: { type: 'string', description: '置換後の文字列' },
      },
      required: ['file_path', 'old_string', 'new_string'],
    },
  },
};

const BASH_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'Bash',
    description: 'シェルコマンドを実行する。cwd は workspace ルート。workspace 外への絶対パス (/root/, /etc/ 等) は拒否される (例外: /tmp, /usr/bin 等)。パッケージインストール（apt/pip/npm 等）は禁止。詳細は ReadToolDoc({ name: "Bash" })。',
    parameters: {
      type: 'object',
      properties: {
        command: { type: 'string', description: '実行するシェルコマンド' },
        timeout: { type: 'number', description: 'タイムアウト秒数 (デフォルト: 30, 最大: 300)' },
      },
      required: ['command'],
    },
  },
};

const GLOB_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'Glob',
    description: 'ファイルパターン (glob) で workspace 内を検索する。',
    parameters: {
      type: 'object',
      properties: {
        pattern: { type: 'string', description: 'glob パターン (例: **/*.ts)' },
        path: { type: 'string', description: '検索の起点ディレクトリ (省略時は workspace ルート)' },
      },
      required: ['pattern'],
    },
  },
};

const GREP_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'Grep',
    description: 'workspace 内のファイルを再帰的にテキスト検索する。',
    parameters: {
      type: 'object',
      properties: {
        pattern: { type: 'string', description: '正規表現パターン' },
        path: { type: 'string', description: '検索の起点ディレクトリ (省略時は workspace ルート)' },
        glob: { type: 'string', description: 'ファイル名フィルタ (例: *.ts)' },
      },
      required: ['pattern'],
    },
  },
};

export const ALL_TOOL_DEFS: Record<string, ToolDef> = {
  Read: READ_DEF,
  Write: WRITE_DEF,
  Edit: EDIT_DEF,
  Bash: BASH_DEF,
  Glob: GLOB_DEF,
  Grep: GREP_DEF,
};

// ツール定義一覧を返す (allowedTools と editAllowed に応じてフィルタリング)
export function getToolDefs(allowedTools: string[], editAllowed: boolean): ToolDef[] {
  return allowedTools
    .filter((name) => {
      if (!editAllowed && (name === 'Write' || name === 'Edit')) return false;
      return name in ALL_TOOL_DEFS;
    })
    .map((name) => ALL_TOOL_DEFS[name]!);
}

// --- ツール実行実装 ---

async function executRead(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
  const filePath = input['file_path'] as string;
  const offset = typeof input['offset'] === 'number' ? input['offset'] : 0;
  const limit = typeof input['limit'] === 'number' ? input['limit'] : undefined;
  const byteOffset = typeof input['byte_offset'] === 'number' ? input['byte_offset'] : undefined;
  const byteLength = typeof input['byte_length'] === 'number' ? input['byte_length'] : undefined;

  let resolved: string;
  try {
    resolved = resolveAndGuard(ctx.workspacePath, filePath);
  } catch (e) {
    return { output: (e as Error).message, isError: true };
  }

  const ext = path.extname(resolved).toLowerCase();
  if (BLOCKED_BINARY_EXTENSIONS.has(ext)) {
    return {
      output: `Read cannot open binary image files like "${filePath}". Use ReadImage to view the image.`,
      isError: true,
    };
  }
  // ドキュメント (Office/PDF) は拡張子から抽出ハンドラへディスパッチする。office.ts は
  // core.ts を静的 import するため、循環を避けて動的 import する。
  const documentTool = DOCUMENT_EXTENSION_TOOL[ext];
  if (documentTool) {
    const office = await import('./office.js');
    const result = await office.executeTool(documentTool, { ...input, path: filePath }, ctx);
    const base = result ?? { output: `Read: 内部ディスパッチ (${ext}) が結果を返しませんでした`, isError: true };
    return withDocumentRangeHint(ext, input, base);
  }
  if (MSG_EXTENSIONS.has(ext)) {
    const office = await import('./office.js');
    const result = await office.executeTool('ReadMsg', input, ctx);
    const base = result ?? { output: `Read: 内部ディスパッチ (.msg) が結果を返しませんでした`, isError: true };
    return withDocumentRangeHint(ext, input, base);
  }
  if (BLOCKED_OPAQUE_BINARY_EXTENSIONS.has(ext)) {
    return {
      output: `Read cannot open binary files like "${filePath}". バイナリデータを読み込むと LLM コンテキストが壊れるので拒否しました。Bash で \`file\` や \`head -c 200 ${filePath} | xxd\` を使って必要部分だけ確認してください。`,
      isError: true,
    };
  }

  // 1. ファイルサイズ取得 + ディレクトリ拒否
  let totalBytes = 0;
  try {
    const st = fs.statSync(resolved);
    // ディレクトリは Read 不可。readFileSync(dir) は EISDIR を投げる。これを
    // ここで弾かないと、後続の競合検知 recordVersion()（persistent ワークスペース
    // で ON）が dir を readFileSync して EISDIR を投げ、ジョブごとクラッシュする。
    // ツールエラー (isError) として返せばエージェントは回復できる。
    if (st.isDirectory()) {
      return {
        output: `Read: "${filePath}" はディレクトリです。ファイルパスを指定してください（一覧は Glob か Bash の \`ls\` を使用）。`,
        isError: true,
      };
    }
    totalBytes = st.size;
  } catch (e) {
    // A missing file under `skills/` almost always means the agent tried to
    // Read a skill directly instead of going through ReadSkill. Return an
    // actionable hint instead of a bare ENOENT so the agent can recover.
    if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
      const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved);
      const hint = skillReadEnoentHint(ctx, relPath);
      if (hint) return { output: hint, isError: true };
    }
    return { output: `Read error: ${(e as Error).message}`, isError: true };
  }

  // 拡張子フォールバック: 先頭 8KB を sniff。真のバイナリ（magic byte / NUL /
  // 高制御文字比率）は拒否するが、UTF-8 でデコードできないだけの非 UTF-8 テキスト
  // （Shift_JIS/CP932・EUC-JP 等）は検出して UTF-8 に変換して読む。拡張子の
  // ホワイト/ブラックリストで漏れた未知バイナリを止めつつ、日本語 txt/CSV を救う。
  let readEncoding = 'utf-8';
  if (totalBytes > 0) {
    const head = readHeadBytes(resolved, SNIFF_HEAD_BYTES);
    const verdict = head ? detectTextEncoding(head) : { binary: false as const, encoding: 'utf-8' };
    if (verdict.binary) {
      return {
        output: `Read cannot open "${filePath}" (binary content detected in head ${SNIFF_HEAD_BYTES} bytes). バイナリを読み込むと LLM コンテキストが壊れます。Bash で \`file\` や \`head -c 200 ${filePath} | xxd\` を使って必要部分だけ確認してください。`,
        isError: true,
      };
    }
    readEncoding = verdict.encoding;
  }

  const budgetTokens = getToolOutputBudgetTokens(ctx);
  const budgetBytes = estimateCharsForTokenBudget(budgetTokens);

  // バイト範囲指定は UTF-8 のみ対応。非 UTF-8 のバイトオフセットは元エンコの
  // バイト境界を指し、多バイト文字が途中で割れる。黙って先頭を返すと契約違反に
  // なるので、明示的にエラーで断り、行範囲(offset/limit)へ誘導する。
  // エンコ判定は先頭 8KB だけでなく全文で行う（先頭 ASCII・末尾 Shift_JIS を
  // 取りこぼさない）。ただしバイト範囲の主用途は巨大 minified/ログ（UTF-8/ASCII）
  // なので、OOM を避けて一定サイズ超のファイルは先頭判定にフォールバックする。
  if (byteOffset !== undefined || byteLength !== undefined) {
    let rangeEncoding = readEncoding;
    if (totalBytes > 0 && totalBytes <= FULL_DETECT_MAX_BYTES) {
      try {
        const verdict = detectTextEncoding(fs.readFileSync(resolved));
        if (!verdict.binary) rangeEncoding = verdict.encoding;
      } catch {
        /* keep the head-based estimate */
      }
    }
    if (rangeEncoding !== 'utf-8') {
      return {
        output:
          `Read: "${filePath}" は ${rangeEncoding} テキストです。バイト範囲指定 (byte_offset/byte_length) は UTF-8 ファイルのみ対応しています。` +
          `offset/limit で行範囲を指定してください（全文は UTF-8 に変換して読み込めます）。`,
        isError: true,
      };
    }
  }

  // 競合検知 (persistent workspace): Read はファイルを変更しないので、ここで
  // 現在のディスク版を「エージェントが見た版」として台帳に記録する。後続 Write は
  // この版を基準に外部変更を検出する。OFF (既定) のときは完全に no-op。
  // 内容を返さずエラーで終わる分岐（バイナリ拒否・非 UTF-8 の byte 範囲など）の
  // 後に置くこと。エラー時に記録すると、見ていない版を「見た」ことにして後続
  // Write の競合検知をすり抜けさせてしまう。
  if (ctx.conflictDetection) {
    const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved);
    recordVersion(ctx.workspacePath, relPath, conflictDirOf(ctx));
  }

  // 2. バイト範囲指定が優先（改行のないファイル向け）
  if (byteOffset !== undefined || byteLength !== undefined) {
    const start = Math.max(0, byteOffset ?? 0);
    if (start >= totalBytes) {
      return { output: `Read: byte_offset ${start} is beyond file size ${totalBytes}`, isError: false };
    }
    // byte_length が未指定または予算より大きければ budget にクリップ
    const requested = byteLength ?? (totalBytes - start);
    const effectiveLength = Math.min(requested, budgetBytes, totalBytes - start);
    const buf = Buffer.alloc(effectiveLength);
    try {
      const fd = fs.openSync(resolved, 'r');
      try {
        fs.readSync(fd, buf, 0, effectiveLength, start);
      } finally {
        fs.closeSync(fd);
      }
    } catch (e) {
      return { output: `Read error: ${(e as Error).message}`, isError: true };
    }
    // BOM は先頭にしか現れない。範囲の先頭から読むときだけ剥がす。
    const text = start === 0 ? stripLeadingBom(buf.toString('utf-8')) : buf.toString('utf-8');
    const capped = effectiveLength < requested;
    if (capped) {
      const nextOffset = start + effectiveLength;
      const notice =
        `[自動切り詰め] ${filePath} からバイト範囲 [${start}, ${start + requested}) が要求されましたが、` +
        `残コンテキスト予算 ${budgetTokens.toLocaleString()} tokens に収まる [${start}, ${nextOffset}) (${effectiveLength.toLocaleString()} bytes) のみ返却しました。\n` +
        `続きは Read(byte_offset=${nextOffset}, byte_length=...) で指定してください\n` +
        `--- 以下、要求範囲の先頭 ${effectiveLength.toLocaleString()} bytes ---\n`;
      return { output: notice + text, isError: false };
    }
    return { output: text, isError: false };
  }

  // 3. 行単位読み込み（既存挙動）+ 事前切り詰め
  try {
    const rawFull = fs.readFileSync(resolved);
    // 全文でエンコを確定する（先頭 8KB が ASCII でも末尾が Shift_JIS のことがある）。
    const decodedFile = decodeFileText(rawFull);
    if (decodedFile.binary) {
      return {
        output: `Read cannot open "${filePath}" (binary content detected). バイナリを読み込むと LLM コンテキストが壊れます。Bash で \`file\` や \`head -c 200 ${filePath} | xxd\` を使って必要部分だけ確認してください。`,
        isError: true,
      };
    }
    const fileEncoding = decodedFile.encoding;
    const content = stripLeadingBom(decodedFile.text);
    const lines = content.split('\n');
    const sliced = limit !== undefined ? lines.slice(offset, offset + limit) : lines.slice(offset);
    const slicedText = sliced.join('\n');

    const effectiveBudgetTokens = capBudgetForEncodedPayload(slicedText, budgetTokens);
    const { text, truncated } = truncateToBudget(slicedText, effectiveBudgetTokens, {
      sourceLabel: filePath,
      totalLines: lines.length,
      totalBytes,
      continuationHint: looksLikeLargeEncodedPayload(slicedText)
        ? 'base64/data URL を含む大きな内容です。全文を読む代わりに Grep や Read(offset/limit)、Read(byte_offset/byte_length) で必要箇所だけ抽出してください'
        : undefined,
    });
    if (truncated) {
      logger.info(`[read] truncated file=${filePath} original_lines=${lines.length} original_bytes=${totalBytes} budget_tokens=${effectiveBudgetTokens}`);
    }
    // UTF-8（BOM 付き含む）は変換なしなので注記しない。非 UTF-8 のみ注記する。
    const encNote =
      fileEncoding === 'utf-8' || fileEncoding === UTF8_BOM_LABEL
        ? ''
        : `[検出エンコーディング: ${fileEncoding} → UTF-8 に変換して表示しています。ファイルはディスク上では ${fileEncoding} のままです]\n`;
    return { output: encNote + text, isError: false };
  } catch (e) {
    return { output: `Read error: ${(e as Error).message}`, isError: true };
  }
}

/**
 * Best-effort provenance recorder. Wrapped so a ledger failure (bad DB, unbound
 * recorder, etc.) can NEVER break tool execution — mirrors recordToolRequest.
 */
function recordProvenanceSafe(ctx: ToolContext, ev: FileProvenanceEvent): void {
  if (!ctx.recordFileProvenance) return;
  try {
    ctx.recordFileProvenance(ev);
  } catch (e) {
    logger.debug(`[provenance] record skipped: ${(e as Error).message}`);
  }
}

/**
 * Snapshot mtime (ms) of every file under output/. Kept intentionally small:
 * Bash provenance only covers the output/ subtree (the deliverables dir) so a
 * command that rewrites a large input/ or logs/ tree does not trigger a scan.
 */
// Provenance snapshots run automatically on every Bash call (unlike Glob's
// on-demand walk), so they must stay cheap. Skip heavy generated dirs and bail
// past a file cap (returns null → caller skips Bash provenance for this call)
// rather than paying an O(files) synchronous stat storm on a large output/ tree
// (e.g. a git clone or npm install). Adversarial-review D1.
const PROV_SNAPSHOT_IGNORE = new Set(['.git', 'node_modules', '.venv', '__pycache__', '.cache']);
const PROV_SNAPSHOT_FILE_CAP = 2000;

function snapshotOutputMtimes(workspacePath: string): Map<string, number> | null {
  const map = new Map<string, number>();
  const stack = [path.join(workspacePath, 'output')];
  while (stack.length > 0) {
    const dir = stack.pop()!;
    let entries: fs.Dirent[];
    try {
      entries = fs.readdirSync(dir, { withFileTypes: true });
    } catch {
      continue;
    }
    for (const entry of entries) {
      const full = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        if (!PROV_SNAPSHOT_IGNORE.has(entry.name)) stack.push(full);
        continue;
      }
      if (map.size >= PROV_SNAPSHOT_FILE_CAP) return null; // too large → skip provenance
      try {
        map.set(path.relative(workspacePath, full), fs.statSync(full).mtimeMs);
      } catch {
        /* file vanished between walk and stat */
      }
    }
  }
  return map;
}

/** After a Bash command, record new output/ files (bash_generated) + changed ones (modify). */
function recordBashOutputProvenance(ctx: ToolContext, before: Map<string, number> | null): void {
  const after = snapshotOutputMtimes(ctx.workspacePath);
  if (!before || !after) return; // tree too large / unavailable → best-effort skip
  for (const [relPath, mtime] of after) {
    const prev = before.get(relPath);
    if (prev === undefined) {
      recordProvenanceSafe(ctx, { relPath, event: 'create', sourceKind: 'bash_generated' });
    } else if (prev !== mtime) {
      recordProvenanceSafe(ctx, { relPath, event: 'modify', sourceKind: 'bash_generated' });
    }
  }
}

function executeWrite(input: Record<string, unknown>, ctx: ToolContext): ToolResult {
  if (!ctx.editAllowed) {
    return { output: 'Write is not allowed: edit flag is false', isError: true };
  }
  const filePath = input['file_path'] as string;
  const content = input['content'] as string;

  let resolved: string;
  try {
    // The workspace itself is the boundary (resolveAndGuard rejects path
    // traversal outside it). Both creating and overwriting are allowed
    // anywhere inside the workspace — previously new files were restricted to
    // output/, which was inconsistent with overwrite (allowed anywhere) and
    // produced confusing failures for legitimate writes to other subdirs.
    resolved = resolveAndGuard(ctx.workspacePath, filePath);
    assertWritable(ctx.workspacePath, resolved);
  } catch (e) {
    return { output: (e as Error).message, isError: true };
  }

  // Provenance: a new file → this task is the creator (agent_output); an
  // existing file → this task is only the last modifier. Captured before write.
  const existedBefore = fs.existsSync(resolved);

  // 既存の非 UTF-8 ファイルはエンコを保持して書き（Read/Edit と揃える）、表現
  // 不能文字は破損保存せず拒否する。競合検知パスは対象ファイルを退避(move)して
  // しまうため、判定はこの時点（退避前）に済ませ、両パスで同じバイト列を書く。
  const encoded = encodeWriteContent(resolved, content, existedBefore);
  if ('error' in encoded) {
    return { output: `Write error: ${encoded.error}`, isError: true };
  }

  // 競合検知 (persistent workspace): 対象がエージェントの見た版から外部変更
  // されている / そもそも読まれていない場合は、古いファイルを old/ へ退避してから上書きする。
  // OFF (既定) のときはこのブロックを完全にスキップし、従来挙動を保つ。
  if (ctx.conflictDetection) {
    const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved);
    const resolution = resolveWriteTarget(ctx.workspacePath, relPath, content, conflictDirOf(ctx));
    let writeResolved = resolved;
    if (resolution.conflict) {
      try {
        // 書込先を再ガード (念のためルート内であることを保証)。
        writeResolved = resolveAndGuard(ctx.workspacePath, resolution.targetRelPath);
      } catch (e) {
        return { output: (e as Error).message, isError: true };
      }
    }
    try {
      fs.mkdirSync(path.dirname(writeResolved), { recursive: true });
      fs.writeFileSync(writeResolved, encoded.bytes);
      // 書き込んだパスの版を台帳に記録 (このエージェントの最新版)。
      recordVersion(ctx.workspacePath, resolution.targetRelPath, conflictDirOf(ctx));
      // Provenance: a conflict wrote a fresh (renamed) file → create; otherwise
      // it followed the plain existed-before semantics.
      recordProvenanceSafe(ctx, {
        relPath: resolution.targetRelPath,
        event: resolution.conflict || !existedBefore ? 'create' : 'modify',
        sourceKind: 'agent_output',
      });
      if (resolution.conflict) {
        const archived = resolution.archivedRelPath ?? 'old/';
        return {
          output:
            `⚠️ 競合を検知したため古いファイルを「${archived}」へ退避し、` +
            `新しい内容を「${resolution.targetRelPath}」として保存しました。` +
            ` Written ${content.length} bytes to ${resolution.targetRelPath}`,
          isError: false,
        };
      }
      return { output: `Written ${content.length} bytes to ${filePath}`, isError: false };
    } catch (e) {
      return { output: `Write error: ${(e as Error).message}`, isError: true };
    }
  }

  try {
    fs.mkdirSync(path.dirname(resolved), { recursive: true });
    fs.writeFileSync(resolved, encoded.bytes);
    recordProvenanceSafe(ctx, {
      relPath: normalizeWorkspaceRelativePath(ctx.workspacePath, resolved),
      event: existedBefore ? 'modify' : 'create',
      sourceKind: 'agent_output',
    });
    return { output: `Written ${content.length} bytes to ${filePath}`, isError: false };
  } catch (e) {
    return { output: `Write error: ${(e as Error).message}`, isError: true };
  }
}

function executeEdit(input: Record<string, unknown>, ctx: ToolContext): ToolResult {
  if (!ctx.editAllowed) {
    return { output: 'Edit is not allowed: edit flag is false', isError: true };
  }
  const filePath = input['file_path'] as string;
  const oldString = input['old_string'] as string;
  const newString = input['new_string'] as string;

  let resolved: string;
  try {
    resolved = resolveAndGuard(ctx.workspacePath, filePath);
    assertWritable(ctx.workspacePath, resolved);
  } catch (e) {
    return { output: (e as Error).message, isError: true };
  }

  try {
    // Read が非 UTF-8 テキストを UTF-8 に変換して見せるので、Edit も同じ変換を
    // かけて old_string を照合しないと一致しない。書き戻しは元エンコを保持する
    // （Shift_JIS のファイルは Shift_JIS のまま保存し、外部ツールの前提を壊さない）。
    // エンコ判定は全文で行う（先頭 ASCII・末尾 Shift_JIS の取りこぼしを防ぐ）。
    const raw = fs.readFileSync(resolved);
    const decoded = decodeFileText(raw);
    const editEncoding = decoded.binary ? 'utf-8' : decoded.encoding;
    const original = decoded.binary ? raw.toString('utf-8') : decoded.text;
    const idx = original.indexOf(oldString);
    if (idx === -1) {
      return { output: `Edit error: old_string not found in ${filePath}`, isError: true };
    }
    const updated = original.slice(0, idx) + newString + original.slice(idx + oldString.length);
    // 元エンコで表現できない文字（Shift_JIS に絵文字等）が入ると iconv は '?' に
    // 置換して例外を投げない。往復一致を確認し、失われるなら保存せずエラーにする。
    const outBytes = encodeFileText(updated, editEncoding);
    if (outBytes === null) {
      return {
        output: `Edit error: 置換後の内容に ${editEncoding} で表現できない文字（絵文字など）が含まれるため、破損を避けて保存を中止しました。ファイルの文字コードは ${editEncoding} です。UTF-8 の別ファイルに保存するか、表現可能な文字を使ってください。`,
        isError: true,
      };
    }
    fs.writeFileSync(resolved, outBytes);
    // 競合検知: Edit は in-place 書き換えなので、書き込んだ版を台帳に記録するだけ
    // (Edit は old_string 一致を要求するので、そもそも盲目上書きの問題は起きない)。
    if (ctx.conflictDetection) {
      const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved);
      recordVersion(ctx.workspacePath, relPath, conflictDirOf(ctx));
    }
    // Provenance: Edit only ever modifies an existing file; the creator is kept.
    recordProvenanceSafe(ctx, {
      relPath: normalizeWorkspaceRelativePath(ctx.workspacePath, resolved),
      event: 'modify',
      sourceKind: 'agent_edit',
    });
    return { output: `Edited ${filePath}: replaced ${oldString.length} chars`, isError: false };
  } catch (e) {
    return { output: `Edit error: ${(e as Error).message}`, isError: true };
  }
}

/**
 * 実行ログ root を解決する（計画5）。ctx.runtimeDir があればそれ、
 * 無ければ従来の `workspacePath/logs`（後方互換）。runtime_dir は files jail
 * の外になりうるため resolveAndGuard を経由させず直接書く。
 */
export function runtimeLogsDir(ctx: ToolContext): string {
  return ctx.runtimeDir ?? path.join(ctx.workspacePath, 'logs');
}

/**
 * 競合台帳 dir を解決する（計画5）。ctx.conflictDir があればそれ、無ければ
 * 後方互換で `workspacePath/.conflict`（files ツリーの外）。
 */
function conflictDirOf(ctx: ToolContext): string {
  return ctx.conflictDir ?? path.join(ctx.workspacePath, '.conflict');
}

function logBashHistory(
  logsDir: string,
  command: string,
  isError: boolean,
  durationMs: number,
  extra?: { blocked?: boolean; outputBytes?: number },
): void {
  try {
    fs.mkdirSync(logsDir, { recursive: true });
    const entry = {
      timestamp: new Date().toISOString(),
      command,
      isError,
      durationMs,
      ...extra,
    };
    fs.appendFileSync(
      path.join(logsDir, 'bash-history.jsonl'),
      JSON.stringify(entry) + '\n',
      'utf-8',
    );
  } catch {
    // best-effort logging
  }
}

type BashMode = 'sandboxed' | 'hardened';

async function resolveBashMode(ctx: ToolContext): Promise<BashMode> {
  const setting = ctx.bashSandbox ?? 'auto';
  if (setting === 'off') return 'hardened';        // plain exec path, still env-scrubbed
  if (setting === 'always') return 'sandboxed';    // bwrap verified at boot
  return (await isBwrapAvailable()) ? 'sandboxed' : 'hardened'; // auto
}

async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
  const command = input['command'] as string;
  let timeoutSec = typeof input['timeout'] === 'number' ? input['timeout'] : 30;
  timeoutSec = Math.min(timeoutSec, 300);
  const startedAt = Date.now();
  const logsDir = runtimeLogsDir(ctx);

  const budgetTokens = getToolOutputBudgetTokens(ctx);
  const capOutput = (raw: string, label: string): string => {
    // cat 等で BOM 付きファイルを出力したときの先頭 U+FEFF を剥がす（stderr は無害）。
    raw = stripLeadingBom(raw);
    const { text, truncated } = truncateToBudget(raw, budgetTokens, {
      sourceLabel: `Bash ${label}`,
      continuationHint:
        'コマンド出力が大きすぎます。cat/less で全文を取得する代わりに、head/tail/grep/awk/sed で必要箇所だけに絞ってください',
    });
    if (truncated) {
      logger.info(`[bash] truncated ${label} original_chars=${raw.length} budget_tokens=${budgetTokens}`);
    }
    return text;
  };

  // Install commands are never supported (packages are pre-baked). Reject in
  // EVERY mode — including bashUnrestricted, which previously skipped this.
  try {
    checkBlockedInstallPatterns(command);
  } catch (e) {
    logBashHistory(logsDir, command, true, Date.now() - startedAt, { blocked: true });
    return { output: (e as Error).message, isError: true };
  }

  // Provenance (best-effort, SMALL): snapshot the output/ file set + mtimes so
  // new/changed deliverables can be attributed to this task after the command
  // runs. Limitation: only output/ is diffed (not input/ or logs/), so a
  // command writing elsewhere is not tracked — a deliberate trade-off to keep
  // the scan bounded.
  const provBefore = ctx.recordFileProvenance ? snapshotOutputMtimes(ctx.workspacePath) : null;

  const mode = await resolveBashMode(ctx);
  const applyWhitelist = !ctx.bashUnrestricted;

  // Per-space Python package overlay (admin-installed wheels). Bound read-only +
  // put on PYTHONPATH so python3 can import them; the agent's sandbox keeps
  // --unshare-net (the packages were fetched out-of-band, not here). Exposed
  // ONLY in the bwrap-sandboxed path — the hardened (no-bwrap) path has neither
  // the read-only bind nor network isolation, so surfacing an overlay there
  // would be fail-open (codex P1). Feature therefore requires bwrap.
  const pyOverlay = mode === 'sandboxed' ? resolveSandboxOverlay(ctx.pythonPackagesDir) : null;

  if (mode === 'sandboxed') {
    const skillBinds = ctx.skillCatalog?.getSkillBinds?.(ctx.userId ?? 'local') ?? [];
    const binds = pyOverlay ? [...skillBinds, pyOverlay.bind] : skillBinds;
    if (applyWhitelist) {
      try {
        checkAllowedCommand(command, ctx.allowedCommands ?? DEFAULT_ALLOWED_COMMANDS);
      } catch (e) {
        logBashHistory(logsDir, command, true, Date.now() - startedAt, { blocked: true });
        return { output: (e as Error).message, isError: true };
      }
    }
    const result: SandboxedBashResult = await executeSandboxedBash(
      command, ctx.workspacePath, timeoutSec, BASH_MAX_BUFFER_BYTES, ctx.abortSignal, binds,
      ctx.bashAllowNetwork === true, pyOverlay?.sandboxPythonPath,
    );
    const out = result.isError ? result.output : capOutput(result.output, 'stdout');
    logBashHistory(logsDir, command, result.isError, Date.now() - startedAt, {
      outputBytes: Buffer.byteLength(out, 'utf-8'),
    });
    if (provBefore) recordBashOutputProvenance(ctx, provBefore);
    return { output: out, isError: result.isError };
  }

  // mode === 'hardened': no bwrap. Whitelist (unless unrestricted) + path scope + env scrub.
  try {
    if (applyWhitelist) checkAllowedCommand(command, ctx.allowedCommands ?? DEFAULT_ALLOWED_COMMANDS);
    checkBashPathScope(command, ctx.workspacePath);
  } catch (e) {
    logBashHistory(logsDir, command, true, Date.now() - startedAt, { blocked: true });
    return { output: (e as Error).message, isError: true };
  }

  return await new Promise((resolve) => {
    if (ctx.abortSignal?.aborted) {
      logBashHistory(logsDir, command, true, Date.now() - startedAt);
      resolve({ output: 'Cancelled before bash launch', isError: true });
      return;
    }
    child_process.exec(
      command,
      {
        cwd: ctx.workspacePath,
        timeout: timeoutSec * 1000,
        encoding: 'utf-8',
        maxBuffer: BASH_MAX_BUFFER_BYTES,
        signal: ctx.abortSignal,
        env: buildSandboxEnv(process.env, ctx.workspacePath), // scrub secrets (no overlay: hardened has no isolation)
      },
      (error, stdout, stderr) => {
        if (!error) {
          const out = capOutput(stdout, 'stdout');
          logBashHistory(logsDir, command, false, Date.now() - startedAt, {
            outputBytes: Buffer.byteLength(out, 'utf-8'),
          });
          if (provBefore) recordBashOutputProvenance(ctx, provBefore);
          resolve({ output: out, isError: false });
          return;
        }

        const msg = error.message ?? String(error);
        const execError = error as Error & { signal?: NodeJS.Signals | null; killed?: boolean; code?: string };
        const cappedStdout = stdout ? capOutput(stdout, 'stdout') : '';
        const cappedStderr = stderr ? capOutput(stderr, 'stderr') : '';
        const details: string[] = [cappedStdout, cappedStderr];
        if (execError.code === 'ABORT_ERR' || ctx.abortSignal?.aborted) {
          details.push('Cancelled by user request');
        } else if (execError.killed) {
          details.push(`Command timed out after ${timeoutSec}s`);
        }
        if (execError.signal) {
          details.push(`Signal: ${execError.signal}`);
        }
        const output = [...details, msg].filter(Boolean).join('\n');
        logBashHistory(logsDir, command, true, Date.now() - startedAt, {
          outputBytes: Buffer.byteLength(output, 'utf-8'),
        });
        // A failed command can still have written files before erroring.
        if (provBefore) recordBashOutputProvenance(ctx, provBefore);
        resolve({ output, isError: true });
      },
    );
  });
}

// 簡易 glob: minimatch 相当の実装 (** と * のみサポート)
function expandBraces(pattern: string): string[] {
  const match = pattern.match(/^(.*?)\{([^}]+)\}(.*)$/);
  if (!match) return [pattern];
  const [, prefix, alternatives, suffix] = match;
  const results: string[] = [];
  for (const alt of alternatives.split(',')) {
    results.push(...expandBraces(`${prefix}${alt}${suffix}`));
  }
  return results;
}

function globMatch(pattern: string, filePath: string): boolean {
  // ブレース展開 {a,b,c} をサポート
  const expanded = expandBraces(pattern);
  return expanded.some((pat) => {
    // **/ → 0個以上のディレクトリ（ルート直下にもマッチ）
    // ** → 任意のパスセグメント列
    // * → 単一セグメント内の任意文字列
    const regexStr = pat
      .replace(/\*\*\//g, '%%GLOBSTAR_SLASH%%')
      .replace(/\*\*/g, '%%GLOBSTAR%%')
      .split('%%GLOBSTAR_SLASH%%')
      .map((segment) =>
        segment
          .split('%%GLOBSTAR%%')
          .map((part) =>
            part
              .split('*')
              .map((s) => s.replace(/[.+^${}()|[\]\\]/g, '\\$&'))
              .join('[^/]*'),
          )
          .join('.*'),
      )
      .join('(.+/)?');
    const regex = new RegExp(`^${regexStr}$`);
    return regex.test(filePath);
  });
}

function collectFiles(dir: string, base: string): string[] {
  const results: string[] = [];
  let entries: fs.Dirent[];
  try {
    entries = fs.readdirSync(dir, { withFileTypes: true });
  } catch {
    return results;
  }
  for (const entry of entries) {
    const full = path.join(dir, entry.name);
    const rel = path.relative(base, full);
    if (entry.isDirectory()) {
      results.push(...collectFiles(full, base));
    } else {
      results.push(rel);
    }
  }
  return results;
}

function executeGlob(input: Record<string, unknown>, ctx: ToolContext): ToolResult {
  const pattern = input['pattern'] as string;
  const searchPath = input['path'] as string | undefined;

  let baseDir: string;
  try {
    baseDir = searchPath
      ? resolveAndGuard(ctx.workspacePath, searchPath)
      : path.resolve(ctx.workspacePath);
  } catch (e) {
    return { output: (e as Error).message, isError: true };
  }

  const allFiles = collectFiles(baseDir, baseDir);
  const matched = allFiles.filter((f) => globMatch(pattern, f));

  if (matched.length === 0) {
    return { output: '(no files matched)', isError: false };
  }
  return { output: matched.join('\n'), isError: false };
}

function executeGrep(input: Record<string, unknown>, ctx: ToolContext): ToolResult {
  const pattern = input['pattern'] as string;
  const searchPath = input['path'] as string | undefined;
  const globFilter = input['glob'] as string | undefined;

  let baseDir: string;
  try {
    baseDir = searchPath
      ? resolveAndGuard(ctx.workspacePath, searchPath)
      : path.resolve(ctx.workspacePath);
  } catch (e) {
    return { output: (e as Error).message, isError: true };
  }

  let regex: RegExp;
  try {
    regex = new RegExp(pattern);
  } catch (e) {
    return { output: `Invalid regex: ${(e as Error).message}`, isError: true };
  }

  const allFiles = collectFiles(baseDir, baseDir);
  const filteredFiles = globFilter
    ? allFiles.filter((f) => globMatch(globFilter, path.basename(f)))
    : allFiles;

  const results: string[] = [];
  for (const rel of filteredFiles) {
    const full = path.join(baseDir, rel);
    let content: string;
    try {
      const raw = fs.readFileSync(full);
      // 真バイナリ（png 等）はスキップ。無条件に utf-8 デコードすると、パターンが
      // バイナリ中の ASCII 断片（IHDR/IDAT 等）にマッチしてバイナリ行が結果に混入し、
      // LLM コンテキストを壊す。非 UTF-8 テキストは検出したエンコで変換して検索する。
      // エンコ判定は全文で行う（先頭 ASCII・末尾 Shift_JIS の取りこぼしを防ぐ）。
      const decoded = decodeFileText(raw);
      if (decoded.binary) continue;
      content = stripLeadingBom(decoded.text);
    } catch {
      continue;
    }
    const lines = content.split('\n');
    for (let i = 0; i < lines.length; i++) {
      if (regex.test(lines[i]!)) {
        results.push(`${rel}:${i + 1}: ${lines[i]}`);
      }
    }
  }

  if (results.length === 0) {
    return { output: '(no matches)', isError: false };
  }
  // Read と Bash は予算切り詰めがあるのに Grep だけ無制限だった。緩いパターン
  // 1回で数十万文字が会話に流れ込み、プロンプトを preflight 上限際まで押し込む
  // 起爆剤になっていたので、同じトークン予算で切り詰める。
  const budgetTokens = getToolOutputBudgetTokens(ctx);
  const raw = results.join('\n');
  const { text, truncated } = truncateToBudget(raw, budgetTokens, {
    sourceLabel: `Grep 結果 (pattern: ${pattern})`,
    totalLines: results.length,
    continuationHint:
      'マッチが多すぎます。pattern をより具体的にする、glob でファイルを絞る、path でディレクトリを絞る、のいずれかで検索範囲を狭めてください',
  });
  if (truncated) {
    logger.info(`[grep] truncated matches=${results.length} original_chars=${raw.length} budget_tokens=${budgetTokens}`);
  }
  return { output: text, isError: false };
}

// コアツールを実行 (Read, Write, Edit, Bash, Glob, Grep)
// null を返す場合はこのモジュールでは処理しないことを意味する
export async function executeCoreTools(
  name: string,
  input: Record<string, unknown>,
  ctx: ToolContext,
): Promise<ToolResult | null> {
  logger.debug(`[tools/core] executing ${name}`);

  switch (name) {
    case 'Read':
      return await executRead(input, ctx);
    case 'Write':
      return executeWrite(input, ctx);
    case 'Edit':
      return executeEdit(input, ctx);
    case 'Bash':
      return await executeBash(input, ctx);
    case 'Glob':
      return executeGlob(input, ctx);
    case 'Grep':
      return executeGrep(input, ctx);
    default:
      return null;
  }
}
