import * as fs from 'fs';
import * as path from 'path';
import * as child_process from 'child_process';
import ExcelJS from 'exceljs';
import mammoth from 'mammoth';
import AdmZip from 'adm-zip';
import { XMLParser } from 'fast-xml-parser';
import { PDFParse } from 'pdf-parse';
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import { resolveAndGuard, resolveOutputPathWithin, truncateToBudget, getToolOutputBudgetTokens } from './core.js';
import { resolveThemePalette, extractSheetStyles } from './excel-styles.js';
import { READ_MSG_DEF, executeReadMsg } from './msg.js';
import { logger } from '../../logger.js';
import { callVisionModel, resolveImagePath } from './image.js';
import type {
  OfficeDocument,
  OfficeWarning,
  ExcelSheetBlock,
  SheetCell,
  DocxParagraphBlock,
  DocxTableBlock,
  PptxSlideBlock,
  PdfPageBlock,
} from './office-types.js';

// --- Constants ---

const MAX_OUTPUT_CHARS = 8000;
const PROCESSING_TIMEOUT_MS = 30_000; // 30 seconds
const PDF_RENDER_TIMEOUT_MS = 120_000; // 120 秒（大きな PDF の変換に対応）

// --- Security helpers ---

const DEFAULT_EXCEL_MAX_SIZE_MB = 10;
const DEFAULT_DOCX_MAX_SIZE_MB = 10;
const DEFAULT_PDF_MAX_SIZE_MB = 10;
const DEFAULT_PPTX_MAX_SIZE_MB = 50;
const DEFAULT_PPTX_MAX_UNCOMPRESSED_MB = 200;
const MACRO_EXTENSIONS = new Set(['.xlsm', '.docm', '.pptm', '.xlsb']);

function mbToBytes(mb: number): number {
  return Math.max(1, Math.floor(mb)) * 1024 * 1024;
}

function resolveMaxSize(ctx: ToolContext, key: keyof NonNullable<ToolContext['toolsConfig']>, defaultMb: number): number {
  const v = ctx.toolsConfig?.[key];
  const mb = typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : defaultMb;
  return mbToBytes(mb);
}

// 拡張子と magic byte sniff の組み合わせで、Office/PDF 系 tool に
// **別フォーマットのファイル**が渡されたケースを早期検出する。
// JSZip / pdf-parse は内部で cryptic な error ("Can't find end of central
// directory : is this a zip file ?" など) を投げ、LLM エージェントが
// 復帰策を判断できず同じ tool を再呼び出しするループに陥りやすい。
//
// この helper は **mismatch 時に agent-actionable な error を返す**
// ことが目的。マッチした場合は `null` を返し、呼び出し側は通常の
// パイプラインを続行する。
type DetectedFormat = 'ooxml' | 'cfb' | 'pdf' | 'html' | 'text' | 'empty' | 'unknown';

function detectFileFormat(filePath: string): DetectedFormat {
  let buf: Buffer;
  try {
    const fd = fs.openSync(filePath, 'r');
    try {
      buf = Buffer.alloc(16);
      const bytesRead = fs.readSync(fd, buf, 0, 16, 0);
      if (bytesRead === 0) return 'empty';
      buf = buf.slice(0, bytesRead);
    } finally {
      fs.closeSync(fd);
    }
  } catch {
    return 'unknown';
  }
  if (buf.length === 0) return 'empty';
  // OOXML / generic ZIP: "PK\x03\x04" or empty-archive "PK\x05\x06"
  if (buf[0] === 0x50 && buf[1] === 0x4B && (buf[2] === 0x03 || buf[2] === 0x05) && (buf[3] === 0x04 || buf[3] === 0x06)) {
    return 'ooxml';
  }
  // CFB (old binary .xls/.doc/.ppt): D0 CF 11 E0 A1 B1 1A E1
  if (
    buf.length >= 8 &&
    buf[0] === 0xD0 && buf[1] === 0xCF && buf[2] === 0x11 && buf[3] === 0xE0 &&
    buf[4] === 0xA1 && buf[5] === 0xB1 && buf[6] === 0x1A && buf[7] === 0xE1
  ) {
    return 'cfb';
  }
  // PDF: "%PDF-"
  if (buf.length >= 5 && buf[0] === 0x25 && buf[1] === 0x50 && buf[2] === 0x44 && buf[3] === 0x46 && buf[4] === 0x2D) {
    return 'pdf';
  }
  // HTML: leading "<" optionally after whitespace/BOM
  const asText = buf.toString('utf-8').replace(/^﻿/, '').trimStart().toLowerCase();
  if (asText.startsWith('<!doctype html') || asText.startsWith('<html') || asText.startsWith('<?xml')) {
    return 'html';
  }
  // Plain text heuristic: ASCII printable + common whitespace, no NUL bytes
  const looksLikeText = !buf.includes(0) && buf.every((b) => b === 0x09 || b === 0x0A || b === 0x0D || (b >= 0x20 && b < 0x7F) || b >= 0x80);
  if (looksLikeText) return 'text';
  return 'unknown';
}

interface FormatValidationOpts {
  /** 期待する extension の集合 (lowercase, leading dot) */
  expectedExts: string[];
  /** 期待する magic-byte 形式 */
  expectedFormat: 'ooxml' | 'pdf';
  /** Tool 名 (error メッセージで参照される) */
  toolName: string;
}

/**
 * mismatch (拡張子 or magic byte) の場合に **agent 向けエラー** を返す。
 * 一致または skip 可能なら null。CFB / HTML / text などの detected format ごとに
 * 「次にどうすればよいか」を error メッセージに含める。
 */
function validateFileFormat(filePath: string, opts: FormatValidationOpts): string | null {
  const ext = path.extname(filePath).toLowerCase();
  const expectedExtsText = opts.expectedExts.join(', ');

  // Step 1: 拡張子が明らかに別物 (Read で十分なテキスト系) なら早期 reject
  const textyExts = new Set(['.md', '.markdown', '.txt', '.csv', '.tsv', '.log', '.json', '.yaml', '.yml', '.xml', '.html', '.htm']);
  if (textyExts.has(ext)) {
    const alt = (ext === '.html' || ext === '.htm')
      ? 'Read({ file_path }) でテキスト取得、または BrowseWeb({ url: "<path>" }) でレンダリング表示'
      : 'Read({ file_path })';
    return `${opts.toolName} には "${ext}" 形式のファイルを渡せません (拡張子: ${ext}, 期待: ${expectedExtsText})。代わりに ${alt} を使ってください。`;
  }

  // Step 2: 期待拡張子に含まれていない場合の警告 (macro 系は許可)
  if (!opts.expectedExts.includes(ext) && !MACRO_EXTENSIONS.has(ext)) {
    return `${opts.toolName} には ${expectedExtsText} を渡してください (受け取った拡張子: ${ext || '<none>'})。`;
  }

  // Step 3: 拡張子は OK でも実体が違う場合 → magic byte で判定
  const detected = detectFileFormat(filePath);
  if (detected === opts.expectedFormat) return null;
  if (detected === 'unknown') return null; // 不明なものはツール本体に委ねる

  if (opts.expectedFormat === 'ooxml') {
    switch (detected) {
      case 'cfb':
        return `${opts.toolName} は旧バイナリ形式 (.xls/.doc/.ppt の CFB) を読めません。Excel/Word/PowerPoint で .xlsx/.docx/.pptx として保存し直すか、CSV エクスポートして Read で読んでください。`;
      case 'pdf':
        return `${opts.toolName} に渡されたファイルは実体が PDF です。代わりに ReadPdf({ path }) を使ってください。`;
      case 'html':
        return `${opts.toolName} に渡されたファイルは実体が HTML です。Read({ file_path }) で読むか、BrowseWeb({ url: "<path>" }) でレンダリング表示してください。`;
      case 'text':
        return `${opts.toolName} に渡されたファイルは実体がテキスト/CSV です。Read({ file_path }) を使ってください。`;
      case 'empty':
        return `${opts.toolName} に渡されたファイルは空です (0 bytes)。ファイルを再生成するか別のパスを指定してください。`;
    }
  } else if (opts.expectedFormat === 'pdf') {
    switch (detected) {
      case 'ooxml':
        return `${opts.toolName} に渡されたファイルは実体が Office (OOXML/.xlsx/.docx/.pptx) です。拡張子に応じて ReadExcel / ReadDocx / ReadPPTX を使ってください。`;
      case 'cfb':
        return `${opts.toolName} に渡されたファイルは旧バイナリ Office (.xls/.doc/.ppt) です。Office で PDF/xlsx として保存し直してください。`;
      case 'html':
        return `${opts.toolName} に渡されたファイルは実体が HTML です。Read({ file_path }) で読むか、BrowseWeb で表示してください。`;
      case 'text':
        return `${opts.toolName} に渡されたファイルは実体がテキストです。Read({ file_path }) を使ってください。`;
      case 'empty':
        return `${opts.toolName} に渡されたファイルは空です (0 bytes)。`;
    }
  }
  return null;
}

function validateOfficeFile(filePath: string, expectedExts: string[], maxSize: number): OfficeWarning[] {
  const warnings: OfficeWarning[] = [];
  const ext = path.extname(filePath).toLowerCase();

  if (!expectedExts.includes(ext) && !MACRO_EXTENSIONS.has(ext)) {
    warnings.push({ code: 'UNSUPPORTED_FORMAT', message: `Unexpected extension: ${ext}` });
  }

  if (MACRO_EXTENSIONS.has(ext)) {
    warnings.push({ code: 'MACRO_DISABLED', message: 'Macro-enabled file detected. Macros will not be executed.' });
  }

  let stats: fs.Stats;
  try {
    stats = fs.statSync(filePath);
  } catch (e) {
    return warnings;
  }

  if (stats.size > maxSize) {
    const limitMB = (maxSize / 1024 / 1024).toFixed(0);
    warnings.push({
      code: 'FILE_TOO_LARGE',
      message: `File size ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds limit of ${limitMB}MB`,
    });
  }

  return warnings;
}

// --- Tool definitions ---

const READ_EXCEL_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'ReadExcel',
    description: 'Excel (.xlsx) を読み取りテキストで返す。include_styles:true でセル装飾も取得可。詳細は ReadToolDoc({ name: "ReadExcel" })。',
    parameters: {
      type: 'object',
      properties: {
        path: { type: 'string', description: 'Excel ファイルのパス' },
        sheet: { type: 'string', description: 'シート名（省略時は全シート）' },
        range: { type: 'string', description: 'セル範囲（例: A1:D10、省略時はシート全体）' },
        max_cells: { type: 'number', description: '最大セル数（デフォルト: 1000）' },
        include_styles: { type: 'boolean', description: 'true でセル装飾(背景色/フォント/罫線/書式/結合)を ### Styles として追記。デフォルト false' },
        max_style_ranges: { type: 'number', description: 'include_styles 時に出力する style range の上限（デフォルト 250）' },
      },
      required: ['path'],
    },
  },
};

const READ_DOCX_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'ReadDocx',
    description: 'Word (.docx) を読み取り本文＋表を抽出する。詳細は ReadToolDoc({ name: "ReadDocx" })。',
    parameters: {
      type: 'object',
      properties: {
        path: { type: 'string', description: 'Word ファイルのパス' },
        mode: { type: 'string', enum: ['text', 'text+tables'], description: '出力モード（デフォルト: text+tables）' },
        max_paragraphs: { type: 'number', description: '最大段落数（デフォルト: 200）' },
      },
      required: ['path'],
    },
  },
};

const SPLIT_EXCEL_SHEETS_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'SplitExcelSheets',
    description: 'Excel をシート単位で .md 分割＋ manifest 生成。詳細は ReadToolDoc({ name: "SplitExcelSheets" })。',
    parameters: {
      type: 'object',
      properties: {
        path: { type: 'string', description: 'Excel ファイルのパス' },
        output_dir: { type: 'string', description: '出力ディレクトリ（省略時: output/preprocessed）' },
        max_cells_per_sheet: { type: 'number', description: 'シートごとの最大セル数（デフォルト: 5000）' },
      },
      required: ['path'],
    },
  },
};

const SPLIT_DOCX_SECTIONS_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'SplitDocxSections',
    description: 'Word を見出しベースで章分割＋ manifest 生成。詳細は ReadToolDoc({ name: "SplitDocxSections" })。',
    parameters: {
      type: 'object',
      properties: {
        path: { type: 'string', description: 'Word ファイルのパス' },
        output_dir: { type: 'string', description: '出力ディレクトリ（省略時: output/preprocessed）' },
      },
      required: ['path'],
    },
  },
};

const READ_PPTX_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'ReadPPTX',
    description: 'PowerPoint (.pptx) からスライドテキスト・表・ノートを抽出。詳細は ReadToolDoc({ name: "ReadPPTX" })。',
    parameters: {
      type: 'object',
      properties: {
        path: { type: 'string', description: 'PowerPoint ファイルのパス' },
        slideRange: { type: 'string', description: 'スライド範囲（例: "1-5" または "3"、省略時は全スライド）' },
      },
      required: ['path'],
    },
  },
};

const READ_PDF_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'ReadPdf',
    description: 'PDF からページごとのテキストを抽出する（スキャン PDF は PdfToImages → ReadImage 等に切替、query 指定で grep -n 風の検索結果を返す）。詳細は ReadToolDoc({ name: "ReadPdf" })。',
    parameters: {
      type: 'object',
      properties: {
        path: { type: 'string', description: 'PDF ファイルのパス' },
        page_range: { type: 'string', description: 'ページ範囲（例: "1-5" または "3"、省略時は全ページ）' },
        pageRange: { type: 'string', description: 'page_range の別名' },
        max_pages: { type: 'number', description: '抽出する最大ページ数（省略時は制限なし）' },
        max_chars: { type: 'number', description: `返却する最大文字数（デフォルト: ${MAX_OUTPUT_CHARS}）` },
        query: {
          type: 'string',
          description: '指定するとマッチしたページのみを grep -n 風 (周辺行付き) で返す。省略時は全文抽出。',
        },
        query_mode: {
          type: 'string',
          enum: ['substring', 'regex', 'iregex'],
          description: 'query の解釈: substring (デフォルト、大小無視の部分一致) / regex (大小区別の正規表現) / iregex (大小無視の正規表現)',
        },
        context_lines: {
          type: 'number',
          description: 'query マッチ時にマッチ行前後で出力するコンテキスト行数（デフォルト: 2、最大: 20）',
        },
      },
      required: ['path'],
    },
  },
};

const PDF_TO_IMAGES_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'PdfToImages',
    description: 'PDF の各ページを PNG に変換し output/ReadPdf/ に保存する。詳細は ReadToolDoc({ name: "PdfToImages" })。',
    parameters: {
      type: 'object',
      properties: {
        path: { type: 'string', description: 'workspace 内の PDF ファイルパス' },
        output_dir: {
          type: 'string',
          description: '出力先ディレクトリ（省略時: output/ReadPdf/{basename}、output/ReadPdf/ 配下のみ指定可能）',
        },
        dpi: {
          type: 'number',
          description: '解像度 DPI（省略時: 150、最大: 300）。OCR 用途には 150〜200 が適切。',
        },
        page_range: {
          type: 'string',
          description: 'ページ範囲（例: "1-5"）。省略時は全ページ。',
        },
      },
      required: ['path'],
    },
  },
};

export const TOOL_DEFS: Record<string, ToolDef> = {
  ReadExcel: READ_EXCEL_DEF,
  ReadDocx: READ_DOCX_DEF,
  ReadPdf: READ_PDF_DEF,
  ReadPPTX: READ_PPTX_DEF,
  ReadMsg: READ_MSG_DEF,
  SplitExcelSheets: SPLIT_EXCEL_SHEETS_DEF,
  SplitDocxSections: SPLIT_DOCX_SECTIONS_DEF,
  PdfToImages: PDF_TO_IMAGES_DEF,
};

// --- Excel column letter helpers ---

function colIndexToLetter(colIdx: number): string {
  let letter = '';
  let n = colIdx;
  while (n > 0) {
    const rem = (n - 1) % 26;
    letter = String.fromCharCode(65 + rem) + letter;
    n = Math.floor((n - 1) / 26);
  }
  return letter;
}

// Parse range string like "A1:D10" into row/col bounds (1-indexed)
function parseRange(rangeStr: string): { minRow: number; maxRow: number; minCol: number; maxCol: number } | null {
  const match = rangeStr.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i);
  if (!match) return null;

  function letterToCol(letters: string): number {
    let col = 0;
    for (const ch of letters.toUpperCase()) {
      col = col * 26 + (ch.charCodeAt(0) - 64);
    }
    return col;
  }

  return {
    minRow: parseInt(match[2]!, 10),
    maxRow: parseInt(match[4]!, 10),
    minCol: letterToCol(match[1]!),
    maxCol: letterToCol(match[3]!),
  };
}

// Format cells as a readable text table
function sanitizeFilename(name: string): string {
  return name.replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, '_').slice(0, 80) || 'untitled';
}

function normalizePdfPageText(text: string): string {
  return text
    .replace(/\n?--\s+\d+\s+of\s+\d+\s+--\s*$/g, '')
    .replace(/\r\n/g, '\n')
    .replace(/\n{3,}/g, '\n\n')
    .trim();
}

function parsePageRange(rangeStr: string | undefined): { min: number; max: number } | null {
  if (!rangeStr) return null;
  const rangeMatch = rangeStr.match(/^(\d+)-(\d+)$/);
  if (rangeMatch) {
    const min = parseInt(rangeMatch[1]!, 10);
    const max = parseInt(rangeMatch[2]!, 10);
    if (min <= 0 || max <= 0 || min > max) return null;
    return { min, max };
  }
  const singleMatch = rangeStr.match(/^(\d+)$/);
  if (singleMatch) {
    const n = parseInt(singleMatch[1]!, 10);
    if (n <= 0) return null;
    return { min: n, max: n };
  }
  return null;
}

function formatCellsAsTable(cells: SheetCell[], sheetName: string, range: string): string {
  if (cells.length === 0) return `## Sheet: ${sheetName}\n\n(empty)\n`;

  const rows = new Map<number, Map<number, string>>();
  let minRow = Infinity, maxRow = -Infinity, minCol = Infinity, maxCol = -Infinity;

  for (const cell of cells) {
    if (!rows.has(cell.row)) rows.set(cell.row, new Map());
    const displayVal = cell.formula
      ? (cell.value != null ? String(cell.value) : '')
      : cell.value != null ? String(cell.value) : '';
    rows.get(cell.row)!.set(cell.col, displayVal);
    if (cell.row < minRow) minRow = cell.row;
    if (cell.row > maxRow) maxRow = cell.row;
    if (cell.col < minCol) minCol = cell.col;
    if (cell.col > maxCol) maxCol = cell.col;
  }

  // Build column widths
  const colWidths: number[] = [];
  for (let c = minCol; c <= maxCol; c++) {
    let maxLen = colIndexToLetter(c).length;
    for (let r = minRow; r <= maxRow; r++) {
      const val = rows.get(r)?.get(c) ?? '';
      if (val.length > maxLen) maxLen = val.length;
    }
    colWidths[c - minCol] = maxLen;
  }

  const colHeaders = [];
  for (let c = minCol; c <= maxCol; c++) {
    colHeaders.push(colIndexToLetter(c).padEnd(colWidths[c - minCol]!));
  }

  const separator = colWidths.map((w) => '-'.repeat(w)).join('-+-');
  const lines: string[] = [];
  lines.push(`## Sheet: ${sheetName} (${range})`);
  lines.push('');
  lines.push(colHeaders.join(' | '));
  lines.push(separator);

  for (let r = minRow; r <= maxRow; r++) {
    const rowCols = [];
    for (let c = minCol; c <= maxCol; c++) {
      const val = rows.get(r)?.get(c) ?? '';
      rowCols.push(val.padEnd(colWidths[c - minCol]!));
    }
    lines.push(rowCols.join(' | '));
  }

  lines.push('');
  lines.push(`(${cells.length} cells read)`);
  return lines.join('\n');
}

function renderSheetMarkdownForHumans(cells: SheetCell[], sheetName: string, range: string, maxRows: number = 120): string {
  if (cells.length === 0) return `# ${sheetName}\n\n> empty sheet`;

  const rowMap = new Map<number, Map<number, string>>();
  let minRow = Infinity, maxRow = -Infinity, minCol = Infinity, maxCol = -Infinity;
  for (const c of cells) {
    if (!rowMap.has(c.row)) rowMap.set(c.row, new Map());
    rowMap.get(c.row)!.set(c.col, c.value == null ? '' : String(c.value));
    minRow = Math.min(minRow, c.row); maxRow = Math.max(maxRow, c.row);
    minCol = Math.min(minCol, c.col); maxCol = Math.max(maxCol, c.col);
  }

  const colCount = Math.max(0, maxCol - minCol + 1);
  const rowCount = Math.max(0, maxRow - minRow + 1);
  const headerRowNo = minRow;
  const headerVals: string[] = [];
  for (let c = minCol; c <= maxCol; c++) {
    const raw = rowMap.get(headerRowNo)?.get(c)?.trim() ?? '';
    headerVals.push(raw.length > 0 ? raw : colIndexToLetter(c));
  }

  const md: string[] = [];
  md.push(`# ${sheetName}`);
  md.push('');
  md.push(`- Range: \`${range}\``);
  md.push(`- Rows: ${rowCount}`);
  md.push(`- Cols: ${colCount}`);
  md.push(`- Cells Read: ${cells.length}`);
  md.push('');
  md.push('## Preview');
  md.push('');
  md.push(`| ${headerVals.join(' | ')} |`);
  md.push(`| ${headerVals.map(() => '---').join(' | ')} |`);

  let shown = 0;
  for (let r = headerRowNo + 1; r <= maxRow; r++) {
    if (shown >= maxRows) break;
    const rowVals: string[] = [];
    for (let c = minCol; c <= maxCol; c++) {
      const v = (rowMap.get(r)?.get(c) ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ').trim();
      rowVals.push(v);
    }
    if (rowVals.every(v => v === '')) continue;
    md.push(`| ${rowVals.join(' | ')} |`);
    shown++;
  }

  if ((maxRow - headerRowNo) > shown) {
    md.push('');
    md.push(`> preview truncated: showing ${shown} row(s)`);
  }

  return md.join('\n');
}

// --- ReadExcel implementation ---

async function executeReadExcel(
  input: Record<string, unknown>,
  ctx: ToolContext,
): Promise<ToolResult> {
  const filePath = input['path'] as string;
  const sheetFilter = typeof input['sheet'] === 'string' ? input['sheet'] : undefined;
  const rangeFilter = typeof input['range'] === 'string' ? input['range'] : undefined;
  const maxCells = typeof input['max_cells'] === 'number' ? input['max_cells'] : 1000;
  const includeStyles = input['include_styles'] === true;
  const maxStyleRanges = typeof input['max_style_ranges'] === 'number' ? input['max_style_ranges'] : 250;

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

  if (!fs.existsSync(resolved)) {
    return { output: `File not found: ${filePath}`, isError: true };
  }

  const formatError = validateFileFormat(resolved, {
    expectedExts: ['.xlsx', '.xlsm'],
    expectedFormat: 'ooxml',
    toolName: 'ReadExcel',
  });
  if (formatError) {
    return { output: formatError, isError: true };
  }

  const warnings = validateOfficeFile(resolved, ['.xlsx', '.xls', '.xlsm', '.xlsb'], resolveMaxSize(ctx, 'officeExcelMaxSizeMb', DEFAULT_EXCEL_MAX_SIZE_MB));
  const hasTooLarge = warnings.some((w) => w.code === 'FILE_TOO_LARGE');
  if (hasTooLarge) {
    return { output: warnings.map((w) => w.message).join('\n'), isError: true };
  }

  const fileSizeBytes = (() => {
    try { return fs.statSync(resolved).size; } catch { return undefined; }
  })();

  const timeoutPromise = new Promise<ToolResult>((resolve) =>
    setTimeout(() => resolve({ output: 'Processing timeout: file took longer than 30 seconds', isError: true }), PROCESSING_TIMEOUT_MS)
  );

  const mainPromise = (async (): Promise<ToolResult> => {
    const startMs = Date.now();

    const wb = new ExcelJS.Workbook();
    try {
      await wb.xlsx.readFile(resolved);
    } catch (e) {
      return { output: `Failed to read Excel file: ${(e as Error).message}`, isError: true };
    }

    const themeXml = includeStyles ? ((wb.model as unknown as { themes?: { theme1?: string } }).themes?.theme1) : undefined;
    const palette = includeStyles ? resolveThemePalette(themeXml) : [];

    const filename = path.basename(resolved);
    const sheetBlocks: ExcelSheetBlock[] = [];
    let totalCells = 0;
    let truncated = false;

    const doc: OfficeDocument = {
      kind: 'excel',
      source: { path: resolved, filename },
      metadata: { sheetCount: wb.worksheets.length, fileSizeBytes, processingTimeMs: Date.now() - startMs },
      blocks: [],
      warnings,
    };

    for (const ws of wb.worksheets) {
      if (sheetFilter && ws.name !== sheetFilter) continue;
      if (truncated) break;

      const rangeBounds = rangeFilter ? parseRange(rangeFilter) : null;
      const cells: SheetCell[] = [];

      ws.eachRow({ includeEmpty: false }, (row, rowNumber) => {
        if (truncated) return;
        if (rangeBounds && (rowNumber < rangeBounds.minRow || rowNumber > rangeBounds.maxRow)) return;

        (row as ExcelJS.Row).eachCell({ includeEmpty: false }, (cell, colNumber) => {
          if (truncated) return;
          if (rangeBounds && (colNumber < rangeBounds.minCol || colNumber > rangeBounds.maxCol)) return;
          if (totalCells >= maxCells) {
            truncated = true;
            return;
          }

          const address = `${colIndexToLetter(colNumber)}${rowNumber}`;
          let value: string | number | boolean | null = null;
          let formula: string | undefined;

          if (cell.type === ExcelJS.ValueType.Formula) {
            formula = typeof cell.formula === 'string' ? cell.formula : undefined;
            const result = cell.result;
            if (typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean') {
              value = result;
            } else if (result instanceof Date) {
              value = result.toISOString();
            } else {
              value = null;
            }
          } else if (cell.value instanceof Date) {
            value = cell.value.toISOString();
          } else if (
            typeof cell.value === 'string' ||
            typeof cell.value === 'number' ||
            typeof cell.value === 'boolean'
          ) {
            value = cell.value;
          } else if (cell.value === null || cell.value === undefined) {
            value = null;
          } else {
            // Rich text or other complex types
            value = String(cell.text ?? '');
          }

          const sheetCell: SheetCell = {
            address,
            value,
            row: rowNumber,
            col: colNumber,
          };
          if (formula) sheetCell.formula = formula;

          cells.push(sheetCell);
          totalCells++;
        });
      });

      // Determine actual range from cells
      let rangeStr = rangeFilter ?? 'A1';
      if (cells.length > 0) {
        const minR = Math.min(...cells.map((c) => c.row));
        const maxR = Math.max(...cells.map((c) => c.row));
        const minC = Math.min(...cells.map((c) => c.col));
        const maxC = Math.max(...cells.map((c) => c.col));
        rangeStr = `${colIndexToLetter(minC)}${minR}:${colIndexToLetter(maxC)}${maxR}`;
      }

      const block: ExcelSheetBlock = {
        type: 'excel.sheet',
        sheetName: ws.name,
        range: rangeStr,
        cells,
      };
      sheetBlocks.push(block);
      doc.blocks.push(block);
    }

    if (truncated) {
      doc.warnings.push({
        code: 'PARSE_PARTIAL_SUCCESS',
        message: `Cell limit reached: only first ${maxCells} cells were read`,
      });
    }

    if (sheetBlocks.length === 0) {
      const msg = sheetFilter
        ? `Sheet "${sheetFilter}" not found in ${filename}`
        : `No sheets found in ${filename}`;
      return { output: msg, isError: true };
    }

    const parts: string[] = [`# ${filename}\n`];
    for (const block of sheetBlocks) {
      parts.push(formatCellsAsTable(block.cells, block.sheetName, block.range));
      parts.push('');
    }

    if (includeStyles) {
      let rangeBudget = maxStyleRanges;
      for (const ws of wb.worksheets) {
        if (sheetFilter && ws.name !== sheetFilter) continue;
        const styleBounds = rangeFilter ? parseRange(rangeFilter) : null;
        const styles = extractSheetStyles(ws, palette, rangeBudget, styleBounds ?? undefined);
        rangeBudget -= styles.assignments.reduce((n, a) => n + a.ranges.length, 0);
        if (styles.legend.length === 0 && styles.merges.length === 0 && !styles.conditionalFormatting) continue;
        parts.push(`### Styles — ${ws.name}`);
        for (const { id, sig } of styles.legend) parts.push(`${id} = ${sig}`);
        parts.push('');
        for (const { id, ranges } of styles.assignments) parts.push(`${id}: ${ranges.join(',')}`);
        if (styles.merges.length) parts.push(`merges: ${styles.merges.join(',')}`);
        if (styles.conditionalFormatting) parts.push('conditionalFormatting: present (effective styles not evaluated)');
        if (styles.truncated || rangeBudget <= 0) parts.push(`[styles truncated: max_style_ranges=${maxStyleRanges} reached]`);
        parts.push('');
        if (rangeBudget <= 0) break;
      }
    }

    if (doc.warnings.length > 0) {
      parts.push('### Warnings');
      for (const w of doc.warnings) {
        parts.push(`- [${w.code}] ${w.message}`);
      }
    }

    doc.metadata.processingTimeMs = Date.now() - startMs;
    logger.debug(`[ReadExcel] ${filename}: ${sheetBlocks.length} sheet(s), ${totalCells} cells`);

    let output = parts.join('\n');
    if (output.length > MAX_OUTPUT_CHARS) {
      output = output.slice(0, MAX_OUTPUT_CHARS) + `\n\n[truncated: output exceeded ${MAX_OUTPUT_CHARS} characters]`;
    }
    output = truncateToBudget(output, getToolOutputBudgetTokens(ctx), {
      sourceLabel: `ReadExcel(${filename})`,
      continuationHint: 'セル範囲を絞るか、SplitExcelSheets で分割してください',
    }).text;
    return { output, isError: false };
  })();

  return Promise.race([mainPromise, timeoutPromise]);
}

async function executeSplitExcelSheets(
  input: Record<string, unknown>,
  ctx: ToolContext,
): Promise<ToolResult> {
  const filePath = input['path'] as string;
  const outputDirInput = typeof input['output_dir'] === 'string' ? input['output_dir'] : 'output/preprocessed';
  const maxCellsPerSheet = typeof input['max_cells_per_sheet'] === 'number' ? input['max_cells_per_sheet'] : 5000;

  let resolved: string;
  let outputDir: string;
  try {
    resolved = resolveAndGuard(ctx.workspacePath, filePath);
    outputDir = resolveOutputPathWithin(ctx.workspacePath, outputDirInput, ['output/preprocessed']);
  } catch (e) {
    return { output: (e as Error).message, isError: true };
  }

  if (!fs.existsSync(resolved)) return { output: `File not found: ${filePath}`, isError: true };
  fs.mkdirSync(outputDir, { recursive: true });

  const wb = new ExcelJS.Workbook();
  await wb.xlsx.readFile(resolved);

  const base = sanitizeFilename(path.basename(resolved, path.extname(resolved)));
  const records: Array<{ sheet: string; file: string; rows: number; cols: number; cells: number; truncated: boolean }> = [];

  for (const ws of wb.worksheets) {
    const cells: SheetCell[] = [];
    let truncated = false;

    ws.eachRow({ includeEmpty: false }, (row, rowNumber) => {
      if (truncated) return;
      (row as ExcelJS.Row).eachCell({ includeEmpty: false }, (cell, colNumber) => {
        if (cells.length >= maxCellsPerSheet) {
          truncated = true;
          return;
        }
        const address = `${colIndexToLetter(colNumber)}${rowNumber}`;
        let value: string | number | boolean | null = null;
        if (cell.value instanceof Date) value = cell.value.toISOString();
        else if (typeof cell.value === 'string' || typeof cell.value === 'number' || typeof cell.value === 'boolean') value = cell.value;
        else if (cell.value == null) value = null;
        else value = String(cell.text ?? '');
        cells.push({ address, value, row: rowNumber, col: colNumber });
      });
    });

    const range = cells.length
      ? `${colIndexToLetter(Math.min(...cells.map(c => c.col)))}${Math.min(...cells.map(c => c.row))}:${colIndexToLetter(Math.max(...cells.map(c => c.col)))}${Math.max(...cells.map(c => c.row))}`
      : 'A1';

    const content = renderSheetMarkdownForHumans(cells, ws.name, range) + (truncated ? `\n\n> truncated at ${maxCellsPerSheet} cells` : '');
    const outName = `${base}__${sanitizeFilename(ws.name)}.md`;
    const outPath = path.join(outputDir, outName);
    fs.writeFileSync(outPath, content, 'utf-8');

    records.push({
      sheet: ws.name,
      file: path.relative(ctx.workspacePath, outPath),
      rows: ws.rowCount,
      cols: ws.columnCount,
      cells: cells.length,
      truncated,
    });
  }

  const manifestPath = path.join(outputDir, `${base}__manifest.json`);
  fs.writeFileSync(manifestPath, JSON.stringify({ kind: 'excel-split', source: filePath, generatedAt: new Date().toISOString(), sheets: records }, null, 2), 'utf-8');

  return {
    output: `SplitExcelSheets done. sheets=${records.length}\nmanifest=${path.relative(ctx.workspacePath, manifestPath)}\nfiles=\n${records.map(r => `- ${r.file}`).join('\n')}`,
    isError: false,
  };
}

async function executeSplitDocxSections(
  input: Record<string, unknown>,
  ctx: ToolContext,
): Promise<ToolResult> {
  const filePath = input['path'] as string;
  const outputDirInput = typeof input['output_dir'] === 'string' ? input['output_dir'] : 'output/preprocessed';

  let resolved: string;
  let outputDir: string;
  try {
    resolved = resolveAndGuard(ctx.workspacePath, filePath);
    outputDir = resolveOutputPathWithin(ctx.workspacePath, outputDirInput, ['output/preprocessed']);
  } catch (e) {
    return { output: (e as Error).message, isError: true };
  }

  if (!fs.existsSync(resolved)) return { output: `File not found: ${filePath}`, isError: true };
  fs.mkdirSync(outputDir, { recursive: true });

  const raw = await mammoth.extractRawText({ path: resolved });
  const lines = raw.value.split(/\r?\n/).map(s => s.trim()).filter(Boolean);

  const isHeading = (s: string) => /^(#{1,6}\s+|第[0-9０-９一二三四五六七八九十]+[章節]|[0-9]+[\.|\)]\s+)/.test(s);
  const sections: Array<{ title: string; lines: string[] }> = [];
  let current: { title: string; lines: string[] } = { title: 'intro', lines: [] };

  for (const line of lines) {
    if (isHeading(line) && current.lines.length > 0) {
      sections.push(current);
      current = { title: line.replace(/^#+\s*/, ''), lines: [] };
    } else {
      current.lines.push(line);
    }
  }
  if (current.lines.length > 0) sections.push(current);

  const base = sanitizeFilename(path.basename(resolved, path.extname(resolved)));
  const records: Array<{ section: string; file: string; paragraphs: number }> = [];

  sections.forEach((s, idx) => {
    const name = `${base}__section_${String(idx + 1).padStart(2, '0')}__${sanitizeFilename(s.title)}.md`;
    const outPath = path.join(outputDir, name);
    fs.writeFileSync(outPath, `# ${s.title}\n\n${s.lines.join('\n\n')}`, 'utf-8');
    records.push({ section: s.title, file: path.relative(ctx.workspacePath, outPath), paragraphs: s.lines.length });
  });

  const manifestPath = path.join(outputDir, `${base}__manifest.json`);
  fs.writeFileSync(manifestPath, JSON.stringify({ kind: 'docx-split', source: filePath, generatedAt: new Date().toISOString(), sections: records }, null, 2), 'utf-8');

  return {
    output: `SplitDocxSections done. sections=${records.length}\nmanifest=${path.relative(ctx.workspacePath, manifestPath)}\nfiles=\n${records.map(r => `- ${r.file}`).join('\n')}`,
    isError: false,
  };
}

// --- HTML table parser for mammoth output ---

function parseTablesFromHtml(html: string): string[][][] {
  const tables: string[][][] = [];
  const tableRegex = /<table[^>]*>([\s\S]*?)<\/table>/gi;
  let tableMatch: RegExpExecArray | null;

  while ((tableMatch = tableRegex.exec(html)) !== null) {
    const tableContent = tableMatch[1] ?? '';
    const rows: string[][] = [];
    const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
    let rowMatch: RegExpExecArray | null;

    while ((rowMatch = rowRegex.exec(tableContent)) !== null) {
      const rowContent = rowMatch[1] ?? '';
      const cells: string[] = [];
      const cellRegex = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi;
      let cellMatch: RegExpExecArray | null;

      while ((cellMatch = cellRegex.exec(rowContent)) !== null) {
        // Strip any remaining HTML tags from cell content
        const cellText = (cellMatch[1] ?? '').replace(/<[^>]+>/g, '').trim();
        cells.push(cellText);
      }

      if (cells.length > 0) {
        rows.push(cells);
      }
    }

    if (rows.length > 0) {
      tables.push(rows);
    }
  }

  return tables;
}

function formatDocxTable(rows: string[][], tableIndex: number): string {
  if (rows.length === 0) return '';

  const colWidths = rows[0]!.map((_, ci) => {
    return rows.reduce((max, row) => {
      const val = row[ci] ?? '';
      return Math.max(max, val.length);
    }, 3);
  });

  const lines: string[] = [`### Table ${tableIndex + 1}`, ''];
  const headerRow = rows[0]!.map((cell, i) => cell.padEnd(colWidths[i]!)).join(' | ');
  const separator = colWidths.map((w) => '-'.repeat(w)).join('-|-');
  lines.push(headerRow);
  lines.push(separator);

  for (let r = 1; r < rows.length; r++) {
    const rowLine = rows[r]!.map((cell, i) => cell.padEnd(colWidths[i]!)).join(' | ');
    lines.push(rowLine);
  }

  return lines.join('\n');
}

// --- ReadDocx implementation ---

async function executeReadDocx(
  input: Record<string, unknown>,
  ctx: ToolContext,
): Promise<ToolResult> {
  const filePath = input['path'] as string;
  const mode = typeof input['mode'] === 'string' ? input['mode'] : 'text+tables';
  const maxParagraphs = typeof input['max_paragraphs'] === 'number' ? input['max_paragraphs'] : 200;

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

  if (!fs.existsSync(resolved)) {
    return { output: `File not found: ${filePath}`, isError: true };
  }

  const formatError = validateFileFormat(resolved, {
    expectedExts: ['.docx', '.docm'],
    expectedFormat: 'ooxml',
    toolName: 'ReadDocx',
  });
  if (formatError) {
    return { output: formatError, isError: true };
  }

  const warnings = validateOfficeFile(resolved, ['.docx', '.docm'], resolveMaxSize(ctx, 'officeDocxMaxSizeMb', DEFAULT_DOCX_MAX_SIZE_MB));
  const hasTooLarge = warnings.some((w) => w.code === 'FILE_TOO_LARGE');
  if (hasTooLarge) {
    return { output: warnings.map((w) => w.message).join('\n'), isError: true };
  }

  const fileSizeBytes = (() => {
    try { return fs.statSync(resolved).size; } catch { return undefined; }
  })();

  const timeoutPromise = new Promise<ToolResult>((resolve) =>
    setTimeout(() => resolve({ output: 'Processing timeout: file took longer than 30 seconds', isError: true }), PROCESSING_TIMEOUT_MS)
  );

  const mainPromise = (async (): Promise<ToolResult> => {
    const startMs = Date.now();
    const filename = path.basename(resolved);

    let rawText = '';
    let htmlOutput = '';

    try {
      const textResult = await mammoth.extractRawText({ path: resolved });
      rawText = textResult.value;
      if (textResult.messages.length > 0) {
        warnings.push({
          code: 'PARSE_PARTIAL_SUCCESS',
          message: `mammoth warnings: ${textResult.messages.map((m) => m.message).join('; ')}`,
        });
      }
    } catch (e) {
      return { output: `Failed to read Word file: ${(e as Error).message}`, isError: true };
    }

    if (mode === 'text+tables') {
      try {
        const htmlResult = await mammoth.convertToHtml({ path: resolved });
        htmlOutput = htmlResult.value;
      } catch (e) {
        // Non-fatal: fall back to text only
        warnings.push({
          code: 'PARSE_PARTIAL_SUCCESS',
          message: `HTML conversion failed, table extraction skipped: ${(e as Error).message}`,
        });
      }
    }

    // Build paragraph blocks from raw text
    let paragraphBlocks: DocxParagraphBlock[] = rawText
      .split('\n')
      .map((line, i) => ({ type: 'docx.paragraph' as const, index: i, text: line }))
      .filter((b) => b.text.trim().length > 0);

    // Apply maxParagraphs limit
    if (paragraphBlocks.length > maxParagraphs) {
      paragraphBlocks = paragraphBlocks.slice(0, maxParagraphs);
      warnings.push({
        code: 'PARSE_PARTIAL_SUCCESS',
        message: `Paragraph limit reached: only first ${maxParagraphs} paragraphs were read`,
      });
    }

    // Build table blocks from HTML if available
    const tableBlocks: DocxTableBlock[] = [];
    if (htmlOutput) {
      const parsedTables = parseTablesFromHtml(htmlOutput);
      for (let i = 0; i < parsedTables.length; i++) {
        tableBlocks.push({ type: 'docx.table', index: i, rows: parsedTables[i]! });
      }
    }

    const doc: OfficeDocument = {
      kind: 'docx',
      source: { path: resolved, filename },
      metadata: { fileSizeBytes, processingTimeMs: Date.now() - startMs },
      blocks: [...paragraphBlocks, ...tableBlocks],
      warnings,
    };

    // Format output
    const parts: string[] = [`## Document: ${filename}`, '', '### Content', ''];

    const paragraphTexts = paragraphBlocks.map((b) => b.text);
    parts.push(paragraphTexts.join('\n\n'));

    if (tableBlocks.length > 0) {
      parts.push('');
      for (let i = 0; i < tableBlocks.length; i++) {
        parts.push('');
        parts.push(formatDocxTable(tableBlocks[i]!.rows, i));
      }
    }

    const wordCount = rawText.split(/\s+/).filter((w) => w.length > 0).length;
    parts.push('');
    parts.push(`(${wordCount} words extracted)`);

    if (doc.warnings.length > 0) {
      parts.push('');
      parts.push('### Warnings');
      for (const w of doc.warnings) {
        parts.push(`- [${w.code}] ${w.message}`);
      }
    }

    doc.metadata.processingTimeMs = Date.now() - startMs;
    logger.debug(`[ReadDocx] ${filename}: ${paragraphBlocks.length} paragraphs, ${tableBlocks.length} tables, ${wordCount} words`);

    let output = parts.join('\n');
    if (output.length > MAX_OUTPUT_CHARS) {
      output = output.slice(0, MAX_OUTPUT_CHARS) + `\n\n[truncated: output exceeded ${MAX_OUTPUT_CHARS} characters]`;
    }
    output = truncateToBudget(output, getToolOutputBudgetTokens(ctx), {
      sourceLabel: `ReadDocx(${filename})`,
      continuationHint: 'SplitDocxSections で章ごとに分割するか、max_paragraphs を絞ってください',
    }).text;
    return { output, isError: false };
  })();

  return Promise.race([mainPromise, timeoutPromise]);
}

// --- ReadPdf query / search helper ---

type ReadPdfQueryMode = 'substring' | 'regex' | 'iregex';

interface ReadPdfPageMatch {
  pageNo: number;
  matchCount: number;
  /** Grep-style snippet block — line-numbered, with N lines of context
   *  around each match. Multiple match clusters per page are separated
   *  by `  …`. */
  snippet: string;
}

interface ReadPdfQueryResult {
  matches: ReadPdfPageMatch[];
  totalMatches: number;
  /** Set when `regex` / `iregex` mode received an invalid pattern. */
  regexError?: string;
}

/**
 * Decide whether a query mode triggers regex semantics.
 */
function isRegexQueryMode(mode: ReadPdfQueryMode): boolean {
  return mode === 'regex' || mode === 'iregex';
}

/**
 * Compile the query into a regex usable with String.prototype.matchAll.
 * For substring mode we escape the pattern + add the `i` flag so the
 * cheapest user input ("find this string") behaves the way operators
 * expect (case-insensitive substring, no regex surprises). regex /
 * iregex preserve user-supplied syntax.
 *
 * Returns `{ regex }` on success or `{ error }` for unparseable
 * regex patterns — never throws so the caller can surface a friendly
 * error in the ReadPdf output instead of a 500.
 */
function compileReadPdfQuery(query: string, mode: ReadPdfQueryMode): { regex: RegExp } | { error: string } {
  if (!isRegexQueryMode(mode)) {
    const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    return { regex: new RegExp(escaped, 'gi') };
  }
  try {
    const flags = mode === 'iregex' ? 'gi' : 'g';
    return { regex: new RegExp(query, flags) };
  } catch (e) {
    return { error: `invalid regex: ${(e as Error).message}` };
  }
}

/**
 * Filter PDF pages to the ones containing the query, returning a
 * grep-style snippet per page. Empty queries (after trimming) bypass
 * the filter — the caller is expected to check before invoking. Pages
 * with zero matches are dropped. Match overflow per page is capped at
 * `MAX_MATCHES_PER_PAGE` to avoid runaway output on tokens like "the".
 */
const MAX_MATCHES_PER_PAGE = 50;

function searchPdfPages(
  pages: { pageNo: number; text: string }[],
  query: string,
  mode: ReadPdfQueryMode,
  contextLines: number,
): ReadPdfQueryResult {
  const compiled = compileReadPdfQuery(query, mode);
  if ('error' in compiled) {
    return { matches: [], totalMatches: 0, regexError: compiled.error };
  }
  const regex = compiled.regex;
  const matches: ReadPdfPageMatch[] = [];
  let totalMatches = 0;
  const ctx = Math.max(0, Math.min(20, Math.floor(contextLines)));

  for (const page of pages) {
    const lines = page.text.split('\n');
    const matchedLineNos: number[] = [];
    for (let i = 0; i < lines.length; i++) {
      // Recreate the regex per-line so the stateful `lastIndex` from
      // an earlier line never bleeds into the next match — using the
      // same RegExp object across lines would skip lines after a
      // partial-line match because `g` flag remembers position.
      const perLine = new RegExp(regex.source, regex.flags);
      if (perLine.test(lines[i]!)) {
        matchedLineNos.push(i);
        if (matchedLineNos.length >= MAX_MATCHES_PER_PAGE) break;
      }
    }
    if (matchedLineNos.length === 0) continue;
    totalMatches += matchedLineNos.length;

    // Collapse overlapping context windows into single clusters so
    // adjacent matches don't repeat the same surrounding lines.
    const clusters: Array<{ from: number; to: number; matchLines: Set<number> }> = [];
    for (const m of matchedLineNos) {
      const from = Math.max(0, m - ctx);
      const to = Math.min(lines.length - 1, m + ctx);
      const last = clusters[clusters.length - 1];
      if (last && from <= last.to + 1) {
        last.to = Math.max(last.to, to);
        last.matchLines.add(m);
      } else {
        clusters.push({ from, to, matchLines: new Set([m]) });
      }
    }

    const lineNoWidth = String(lines.length).length;
    const snippetParts: string[] = [];
    clusters.forEach((cluster, idx) => {
      if (idx > 0) snippetParts.push('  …');
      for (let i = cluster.from; i <= cluster.to; i++) {
        const marker = cluster.matchLines.has(i) ? '>' : ' ';
        const lineLabel = String(i + 1).padStart(lineNoWidth, ' ');
        snippetParts.push(`${marker} ${lineLabel}: ${lines[i]}`);
      }
    });
    matches.push({
      pageNo: page.pageNo,
      matchCount: matchedLineNos.length,
      snippet: snippetParts.join('\n'),
    });
  }
  return { matches, totalMatches };
}

// --- ReadPdf implementation ---

async function executeReadPdf(
  input: Record<string, unknown>,
  ctx: ToolContext,
): Promise<ToolResult> {
  const filePath = input['path'] as string;
  const pageRangeInput = typeof input['page_range'] === 'string'
    ? input['page_range']
    : typeof input['pageRange'] === 'string'
      ? input['pageRange']
      : undefined;
  const pageRange = parsePageRange(pageRangeInput);
  const maxPages = typeof input['max_pages'] === 'number' && Number.isFinite(input['max_pages'])
    ? Math.max(1, Math.floor(input['max_pages']))
    : undefined;
  const maxChars = typeof input['max_chars'] === 'number' && Number.isFinite(input['max_chars'])
    ? Math.max(200, Math.floor(input['max_chars']))
    : MAX_OUTPUT_CHARS;
  const outputLimit = Math.min(MAX_OUTPUT_CHARS, maxChars);

  // Query is opt-in: when present and non-empty after trim the handler
  // switches to grep -n style output (matched pages + context lines).
  // Otherwise the full-text path is taken — same as before this param.
  const rawQuery = typeof input['query'] === 'string' ? input['query'] : null;
  const query = rawQuery && rawQuery.trim().length > 0 ? rawQuery : null;
  const rawQueryMode = typeof input['query_mode'] === 'string' ? input['query_mode'] : 'substring';
  const queryMode: ReadPdfQueryMode = rawQueryMode === 'regex' || rawQueryMode === 'iregex' || rawQueryMode === 'substring'
    ? rawQueryMode
    : 'substring';
  const contextLines = typeof input['context_lines'] === 'number' && Number.isFinite(input['context_lines'])
    ? Math.max(0, Math.min(20, Math.floor(input['context_lines'])))
    : 2;

  if (pageRangeInput && !pageRange) {
    return { output: `Invalid page_range: ${pageRangeInput}`, isError: true };
  }

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

  if (!fs.existsSync(resolved)) {
    return { output: `File not found: ${filePath}`, isError: true };
  }

  const formatError = validateFileFormat(resolved, {
    expectedExts: ['.pdf'],
    expectedFormat: 'pdf',
    toolName: 'ReadPdf',
  });
  if (formatError) {
    return { output: formatError, isError: true };
  }

  const warnings = validateOfficeFile(resolved, ['.pdf'], resolveMaxSize(ctx, 'officePdfMaxSizeMb', DEFAULT_PDF_MAX_SIZE_MB));
  const hasTooLarge = warnings.some((w) => w.code === 'FILE_TOO_LARGE');
  if (hasTooLarge) {
    return { output: warnings.map((w) => w.message).join('\n'), isError: true };
  }

  const fileSizeBytes = (() => {
    try { return fs.statSync(resolved).size; } catch { return undefined; }
  })();

  const timeoutPromise = new Promise<ToolResult>((resolve) =>
    setTimeout(() => resolve({ output: 'Processing timeout: file took longer than 30 seconds', isError: true }), PROCESSING_TIMEOUT_MS)
  );

  const mainPromise = (async (): Promise<ToolResult> => {
    const startMs = Date.now();
    const filename = path.basename(resolved);
    const parser = new PDFParse({ data: fs.readFileSync(resolved) });

    try {
      const info = await parser.getInfo();
      const totalPages = info.total;

      let selectedPages: number[] | undefined;
      if (pageRange) {
        selectedPages = [];
        for (let pageNo = pageRange.min; pageNo <= Math.min(pageRange.max, totalPages); pageNo++) {
          selectedPages.push(pageNo);
        }
        if (selectedPages.length === 0) {
          return { output: `No pages matched page_range=${pageRangeInput} (document has ${totalPages} page(s))`, isError: true };
        }
      }

      if (maxPages !== undefined) {
        selectedPages = (selectedPages ?? Array.from({ length: totalPages }, (_, i) => i + 1)).slice(0, maxPages);
        if (selectedPages.length < totalPages) {
          warnings.push({
            code: 'PARSE_PARTIAL_SUCCESS',
            message: `Page limit applied: extracted ${selectedPages.length} page(s) out of ${totalPages}`,
          });
        }
      }

      const textResult = await parser.getText(selectedPages ? { partial: selectedPages } : undefined);
      const pageBlocks: PdfPageBlock[] = textResult.pages.map((page) => ({
        type: 'pdf.page',
        pageNo: page.num,
        text: normalizePdfPageText(page.text),
      }));

      if (pageBlocks.length === 0) {
        warnings.push({
          code: 'PARSE_PARTIAL_SUCCESS',
          message: 'No extractable text pages were found in the PDF',
        });
      }

      if (pageBlocks.every((page) => page.text.length === 0) && pageBlocks.length > 0) {
        // テキスト抽出失敗 → PdfToImages + Vision OCR に自動フォールバック
        if (ctx.editAllowed) {
          logger.info(`[ReadPdf] ${filename}: text extraction empty, falling back to image-based OCR`);
          await parser.destroy().catch(() => undefined);

          // PdfToImages 実行
          const pdfImagesResult = await executePdfToImages({ path: filePath, dpi: 150 }, ctx);
          if (pdfImagesResult.isError) {
            return { output: `ReadPdf text extraction returned empty. Image fallback also failed: ${pdfImagesResult.output}`, isError: true };
          }

          // 生成された画像を Vision OCR で解析。query モードでは
          // OCR で起こしたテキストにも同じ filter を適用するので、
          // ページごとに「ヘッダ付き markdown 用」「query 用 plain
          // text」の 2 形式を並行して保持する。
          const imageLines = pdfImagesResult.output.split('\n').filter(l => l.startsWith('- '));
          const imagePaths = imageLines.map(l => l.replace('- ', '').trim());
          const ocrResults: string[] = [];
          const ocrPages: { pageNo: number; text: string }[] = [];
          for (let i = 0; i < imagePaths.length; i++) {
            const pageNo = i + 1;
            const imgResult = resolveImagePath(imagePaths[i], ctx);
            if ('isError' in imgResult) {
              const msg = `(image OCR failed: ${imgResult.output})`;
              ocrResults.push(`## Page ${pageNo}\n\n${msg}`);
              ocrPages.push({ pageNo, text: '' });
              continue;
            }
            const visionResult = await callVisionModel(imgResult.dataUrl, 'この画像のテキストを全て正確に書き起こしてください。', ctx);
            const text = visionResult.isError ? '' : visionResult.output;
            ocrResults.push(`## Page ${pageNo}\n\n${visionResult.isError ? `(OCR error: ${visionResult.output})` : text}`);
            ocrPages.push({ pageNo, text });
          }

          const header = [
            `# ${filename} (image-based OCR${query ? `, query: "${query}"` : ''})`,
            '',
            `スキャン PDF のため画像ベース OCR にフォールバックしました。`,
            `- Total pages: ${totalPages}`,
            `- OCR pages: ${imagePaths.length}`,
          ];

          let body: string[];
          if (query) {
            const queryResult = searchPdfPages(ocrPages, query, queryMode, contextLines);
            if (queryResult.regexError) {
              return { output: `ReadPdf query error: ${queryResult.regexError}`, isError: true };
            }
            body = [
              `- Pages with match: ${queryResult.matches.length}`,
              `- Total matches: ${queryResult.totalMatches}`,
              '',
              '### Matches (from OCR)',
              '',
            ];
            if (queryResult.matches.length === 0) {
              body.push(`(no matches for "${query}")`);
            } else {
              for (const m of queryResult.matches) {
                body.push(`## Page ${m.pageNo} — ${m.matchCount} match${m.matchCount === 1 ? '' : 'es'}${m.matchCount === MAX_MATCHES_PER_PAGE ? ' (capped)' : ''}`);
                body.push('');
                body.push('```');
                body.push(m.snippet);
                body.push('```');
                body.push('');
              }
            }
          } else {
            body = ['', ...ocrResults];
          }

          const output = [...header, ...body].join('\n');

          let truncated = output;
          if (truncated.length > outputLimit) {
            truncated = truncated.slice(0, outputLimit) + `\n\n[truncated: output exceeded ${outputLimit} characters]`;
          }
          truncated = truncateToBudget(truncated, getToolOutputBudgetTokens(ctx), {
            sourceLabel: `ReadPdf(${filename}, OCR)`,
            continuationHint: 'page_range / max_pages / max_chars で範囲を絞ってください',
          }).text;
          return { output: truncated, isError: false };
        }

        warnings.push({
          code: 'PARSE_PARTIAL_SUCCESS',
          message: 'PDF text extraction returned empty text on all extracted pages. If this is a scanned PDF, use image-based OCR instead.',
        });
      }

      const doc: OfficeDocument = {
        kind: 'pdf',
        source: { path: resolved, filename },
        metadata: { pageCount: totalPages, fileSizeBytes, processingTimeMs: Date.now() - startMs },
        blocks: pageBlocks,
        warnings,
      };

      const extractedPageNumbers = pageBlocks.map((page) => page.pageNo);
      const extractedSummary = extractedPageNumbers.length > 0
        ? extractedPageNumbers.length === 1
          ? String(extractedPageNumbers[0])
          : `${extractedPageNumbers[0]}-${extractedPageNumbers[extractedPageNumbers.length - 1]}`
        : 'none';

      const queryHeader = query ? `, query: "${query}"${queryMode !== 'substring' ? ` mode=${queryMode}` : ''}` : '';
      const parts: string[] = [
        `# ${filename}${queryHeader}`,
        '',
        '### Summary',
        `- Total pages: ${totalPages}`,
        `- Extracted pages: ${extractedSummary}`,
      ];

      if (query) {
        // Query mode: filter to matched pages + grep-style snippet.
        // Full-text output is intentionally suppressed because the
        // operator who passed `query` is signalling "I only want
        // matches, not the whole document."
        const queryResult = searchPdfPages(pageBlocks, query, queryMode, contextLines);
        if (queryResult.regexError) {
          return { output: `ReadPdf query error: ${queryResult.regexError}`, isError: true };
        }
        parts.push(`- Pages with match: ${queryResult.matches.length}`);
        parts.push(`- Total matches: ${queryResult.totalMatches}`);
        parts.push('');
        parts.push('### Matches');
        parts.push('');
        if (queryResult.matches.length === 0) {
          parts.push(`(no matches for "${query}")`);
        } else {
          for (const m of queryResult.matches) {
            parts.push(`## Page ${m.pageNo} — ${m.matchCount} match${m.matchCount === 1 ? '' : 'es'}${m.matchCount === MAX_MATCHES_PER_PAGE ? ' (capped)' : ''}`);
            parts.push('');
            parts.push('```');
            parts.push(m.snippet);
            parts.push('```');
            parts.push('');
          }
        }
      } else {
        parts.push('');
        parts.push('### Content');
        parts.push('');
        if (pageBlocks.length === 0) {
          parts.push('(no extractable text)');
        } else {
          for (const page of pageBlocks) {
            parts.push(`## Page ${page.pageNo}`);
            parts.push('');
            parts.push(page.text || '(no extractable text on this page)');
            parts.push('');
          }
        }
      }

      if (doc.warnings.length > 0) {
        parts.push('### Warnings');
        for (const warning of doc.warnings) {
          parts.push(`- [${warning.code}] ${warning.message}`);
        }
      }

      doc.metadata.processingTimeMs = Date.now() - startMs;
      logger.debug(`[ReadPdf] ${filename}: ${pageBlocks.length} page(s) extracted from ${totalPages}`);

      let output = parts.join('\n');
      if (output.length > outputLimit) {
        output = output.slice(0, outputLimit) + `\n\n[truncated: output exceeded ${outputLimit} characters]`;
      }
      output = truncateToBudget(output, getToolOutputBudgetTokens(ctx), {
        sourceLabel: `ReadPdf(${filename})`,
        continuationHint: 'page_range / max_pages / max_chars で範囲を絞ってください',
      }).text;
      return { output, isError: false };
    } catch (e) {
      return { output: `Failed to read PDF file: ${(e as Error).message}`, isError: true };
    } finally {
      await parser.destroy().catch(() => undefined);
    }
  })();

  return Promise.race([mainPromise, timeoutPromise]);
}

// --- PdfToImages Python script ---

const PDF_TO_IMAGES_PYTHON = `
import sys, json, os

def main():
    try:
        import fitz
    except ImportError:
        print(json.dumps({'error': 'PyMuPDF (fitz) is not installed. Run: pip install pymupdf'}))
        sys.exit(1)

    raw = os.environ.get('PDF_TO_IMAGES_ARGS', '')
    if not raw:
        print(json.dumps({'error': 'PDF_TO_IMAGES_ARGS env var is not set'}))
        sys.exit(1)

    args = json.loads(raw)
    pdf_path   = args['pdf_path']
    output_dir = args['output_dir']
    dpi        = int(args.get('dpi', 150))
    page_range = args.get('page_range')  # [min_1indexed, max_1indexed] or null

    os.makedirs(output_dir, exist_ok=True)

    try:
        doc = fitz.open(pdf_path)
    except Exception as e:
        print(json.dumps({'error': f'Failed to open PDF: {e}'}))
        sys.exit(1)

    total_pages = len(doc)

    if page_range:
        start_idx = max(0, page_range[0] - 1)
        end_idx   = min(total_pages, page_range[1])
        indices   = list(range(start_idx, end_idx))
    else:
        indices = list(range(total_pages))

    # DPI を scale factor に変換（PDF の基本単位は 72 DPI）
    scale = dpi / 72
    mat   = fitz.Matrix(scale, scale)
    generated = []

    for idx in indices:
        page     = doc[idx]
        pix      = page.get_pixmap(matrix=mat)
        out_path = os.path.join(output_dir, f'page-{idx + 1:04d}.png')
        pix.save(out_path)
        generated.append(out_path)

    print(json.dumps({'total_pages': total_pages, 'pages': generated}))

main()
`;

// --- PdfToImages implementation ---

async function executePdfToImages(
  input: Record<string, unknown>,
  ctx: ToolContext,
): Promise<ToolResult> {
  if (!ctx.editAllowed) {
    return { output: 'PdfToImages is not allowed: edit flag is false', isError: true };
  }

  const filePath = input['path'] as string;
  if (!filePath) {
    return { output: 'path is required', isError: true };
  }

  // ページ範囲パース（既存の parsePageRange を流用）
  const pageRangeInput = typeof input['page_range'] === 'string' ? input['page_range'] : undefined;
  const pageRange = pageRangeInput ? parsePageRange(pageRangeInput) : undefined;
  if (pageRangeInput && !pageRange) {
    return { output: `Invalid page_range: ${pageRangeInput}`, isError: true };
  }

  const dpiRaw = typeof input['dpi'] === 'number' ? input['dpi'] : 150;
  const dpi = Math.min(300, Math.max(72, Math.floor(dpiRaw)));

  // ファイルパス解決
  let resolved: string;
  try {
    resolved = resolveAndGuard(ctx.workspacePath, filePath);
  } catch (e) {
    return { output: (e as Error).message, isError: true };
  }
  if (!fs.existsSync(resolved)) {
    return { output: `File not found: ${filePath}`, isError: true };
  }

  // 出力ディレクトリ決定
  const basename = path.parse(resolved).name;
  const outputDirRel = typeof input['output_dir'] === 'string'
    ? input['output_dir']
    : path.posix.join('output', 'ReadPdf', basename);

  let resolvedOutputDir: string;
  try {
    resolvedOutputDir = resolveOutputPathWithin(ctx.workspacePath, outputDirRel, ['output/ReadPdf']);
  } catch (e) {
    return { output: (e as Error).message, isError: true };
  }
  fs.mkdirSync(resolvedOutputDir, { recursive: true });

  // Python に渡す引数（環境変数経由）
  const pyArgs = {
    pdf_path: resolved,
    output_dir: resolvedOutputDir,
    dpi,
    page_range: pageRange ? [pageRange.min, pageRange.max] : null,
  };

  logger.debug(`[PdfToImages] calling python3 for ${filePath} dpi=${dpi}`);

  // Python 実行: スクリプトを stdin で渡し、引数は環境変数で渡す
  const result = await new Promise<ToolResult>((resolve) => {
    let settled = false;
    const settle = (r: ToolResult) => { if (settled) return; settled = true; resolve(r); };

    const proc = child_process.spawn('python3', ['-'], {
      stdio: ['pipe', 'pipe', 'pipe'],
      env: { ...process.env, PDF_TO_IMAGES_ARGS: JSON.stringify(pyArgs) },
    });

    let stdout = '';
    let stderr = '';
    proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
    proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });

    proc.on('error', (err) => {
      clearTimeout(timer);
      settle({ output: `PdfToImages process error: ${err.message}`, isError: true });
    });

    const timer = setTimeout(() => {
      proc.kill();
      settle({ output: `PdfToImages timed out after ${PDF_RENDER_TIMEOUT_MS / 1000}s`, isError: true });
    }, PDF_RENDER_TIMEOUT_MS);

    proc.on('close', (code) => {
      clearTimeout(timer);
      if (code !== 0) {
        settle({ output: `PdfToImages failed (exit ${code}): ${stderr.trim() || stdout.trim()}`, isError: true });
        return;
      }
      try {
        const json = JSON.parse(stdout.trim()) as { error?: string; total_pages?: number; pages?: string[] };
        if (json.error) {
          settle({ output: `PdfToImages error: ${json.error}`, isError: true });
          return;
        }
        const totalPages = json.total_pages ?? 0;
        const pages = json.pages ?? [];

        // ワークスペース相対パスに変換
        const relPages = pages.map((p) => path.relative(ctx.workspacePath, p));

        const lines = [
          `PDF を ${pages.length} ページ分の画像に変換しました（合計 ${totalPages} ページ中）。`,
          '',
          '生成された画像:',
          ...relPages.map((p) => `- ${p}`),
          '',
          `次のステップ: ReadImage で各画像を確認してください。`,
        ];
        settle({ output: lines.join('\n'), isError: false });
      } catch {
        settle({ output: `PdfToImages: failed to parse python output: ${stdout}`, isError: true });
      }
    });

    proc.stdin.write(PDF_TO_IMAGES_PYTHON);
    proc.stdin.end();
  });

  logger.debug(`[PdfToImages] done: isError=${result.isError}`);
  return result;
}

// --- PPTX helpers ---

/** "1-5" → {min:1, max:5}, "3" → {min:3, max:3}, undefined → null */
function parseSlideRange(rangeStr: string | undefined): { min: number; max: number } | null {
  if (!rangeStr) return null;
  const rangeMatch = rangeStr.match(/^(\d+)-(\d+)$/);
  if (rangeMatch) {
    return { min: parseInt(rangeMatch[1]!, 10), max: parseInt(rangeMatch[2]!, 10) };
  }
  const singleMatch = rangeStr.match(/^(\d+)$/);
  if (singleMatch) {
    const n = parseInt(singleMatch[1]!, 10);
    return { min: n, max: n };
  }
  return null;
}

/**
 * XML パーサー（名前空間プレフィックスを除去）
 * fast-xml-parser で removeNSPrefix: true を使うと "a:t" → "t" になる。
 */
const xmlParser = new XMLParser({
  ignoreAttributes: false,
  attributeNamePrefix: '@_',
  removeNSPrefix: true,
  isArray: (name) => ['p', 'r', 'sp', 'graphicFrame', 'tr', 'tc', 'br', 'sldId'].includes(name),
  textNodeName: '#text',
});

/** 任意の値を文字列に変換（number / boolean / null 対応） */
function toStr(v: unknown): string {
  if (v == null) return '';
  if (typeof v === 'string') return v;
  if (typeof v === 'number' || typeof v === 'boolean') return String(v);
  return '';
}

/**
 * `a:p` ノードからテキストを抽出する。
 * a:r → a:t の連結、a:br は改行として扱う。
 */
function extractParagraphText(para: Record<string, unknown>): string {
  const parts: string[] = [];

  // runs: para.r (配列)
  const runs = para['r'];
  const brs = para['br'];

  // br の位置を考慮するため、元の XML 順序に近い処理が必要。
  // fast-xml-parser は配列にまとめてしまうので、br があれば runs の間に改行を挿入する形にする。
  // 実用上、runs を連結し、br が存在する場合は末尾に改行を付ける。
  if (Array.isArray(runs)) {
    for (const run of runs) {
      if (run && typeof run === 'object') {
        const t = (run as Record<string, unknown>)['t'];
        parts.push(toStr(t));
      }
    }
  }

  // br（改行要素）が存在する場合は段落内改行として扱う
  if (Array.isArray(brs) && brs.length > 0) {
    parts.push('\n');
  }

  return parts.join('');
}

/** `a:tbl` テーブルノードからセルテキストを抽出してフォーマットする */
function extractTableText(tbl: Record<string, unknown>): string {
  const rows = tbl['tr'];
  if (!Array.isArray(rows) || rows.length === 0) return '';

  const tableRows: string[][] = [];
  for (const row of rows) {
    if (!row || typeof row !== 'object') continue;
    const cells = (row as Record<string, unknown>)['tc'];
    if (!Array.isArray(cells)) continue;
    const rowTexts: string[] = [];
    for (const cell of cells) {
      if (!cell || typeof cell !== 'object') continue;
      const txBody = (cell as Record<string, unknown>)['txBody'];
      if (!txBody || typeof txBody !== 'object') {
        rowTexts.push('');
        continue;
      }
      const paras = (txBody as Record<string, unknown>)['p'];
      const cellText = Array.isArray(paras)
        ? paras.map((p) => extractParagraphText(p as Record<string, unknown>)).join('\n')
        : '';
      rowTexts.push(cellText.trim());
    }
    tableRows.push(rowTexts);
  }

  if (tableRows.length === 0) return '';

  // シンプルなテキスト形式で出力（列を " | " で区切る）
  return tableRows.map((row) => row.join(' | ')).join('\n');
}

/**
 * スライド XML（slide.xml の中身）からテキストを抽出する。
 * p:sp（テキストボックス・プレースホルダー）と graphicFrame（テーブル）を対象とする。
 */
function extractTextsFromSlideXml(xmlContent: string): { texts: { shapeId?: string; text: string }[]; error?: string } {
  let parsed: Record<string, unknown>;
  try {
    parsed = xmlParser.parse(xmlContent) as Record<string, unknown>;
  } catch (e) {
    return { texts: [], error: `XML parse error: ${(e as Error).message}` };
  }

  const texts: { shapeId?: string; text: string }[] = [];

  // パスを辿る: sld > cSld > spTree
  const sld = parsed['sld'] as Record<string, unknown> | undefined;
  if (!sld) return { texts };
  const cSld = sld['cSld'] as Record<string, unknown> | undefined;
  if (!cSld) return { texts };
  const spTree = cSld['spTree'] as Record<string, unknown> | undefined;
  if (!spTree) return { texts };

  // テキストボックス・プレースホルダー (p:sp)
  const shapes = spTree['sp'];
  if (Array.isArray(shapes)) {
    for (const sp of shapes) {
      if (!sp || typeof sp !== 'object') continue;
      const shape = sp as Record<string, unknown>;
      const shapeId = (() => {
        const nvSpPr = shape['nvSpPr'] as Record<string, unknown> | undefined;
        if (!nvSpPr) return undefined;
        const cNvPr = nvSpPr['cNvPr'] as Record<string, unknown> | undefined;
        return cNvPr ? toStr(cNvPr['@_id']) : undefined;
      })();

      const txBody = shape['txBody'] as Record<string, unknown> | undefined;
      if (!txBody) continue;
      const paras = txBody['p'];
      if (!Array.isArray(paras)) continue;

      const paraTexts = paras
        .map((p) => extractParagraphText(p as Record<string, unknown>))
        .filter((t) => t.length > 0);

      if (paraTexts.length > 0) {
        texts.push({ shapeId, text: paraTexts.join('\n') });
      }
    }
  }

  // テーブル (p:graphicFrame)
  const graphicFrames = spTree['graphicFrame'];
  if (Array.isArray(graphicFrames)) {
    for (const gf of graphicFrames) {
      if (!gf || typeof gf !== 'object') continue;
      const gfObj = gf as Record<string, unknown>;
      const graphic = gfObj['graphic'] as Record<string, unknown> | undefined;
      if (!graphic) continue;
      const graphicData = graphic['graphicData'] as Record<string, unknown> | undefined;
      if (!graphicData) continue;
      const tbl = graphicData['tbl'] as Record<string, unknown> | undefined;
      if (!tbl) continue;

      const tableText = extractTableText(tbl);
      if (tableText) {
        texts.push({ text: `[Table]\n${tableText}` });
      }
    }
  }

  return { texts };
}

/** notesSlide XML からノートテキストを抽出する */
function extractNotesFromXml(xmlContent: string): string {
  let parsed: Record<string, unknown>;
  try {
    parsed = xmlParser.parse(xmlContent) as Record<string, unknown>;
  } catch {
    return '';
  }

  const notesSld = parsed['notes'] as Record<string, unknown> | undefined;
  if (!notesSld) return '';
  const cSld = notesSld['cSld'] as Record<string, unknown> | undefined;
  if (!cSld) return '';
  const spTree = cSld['spTree'] as Record<string, unknown> | undefined;
  if (!spTree) return '';

  const shapes = spTree['sp'];
  if (!Array.isArray(shapes)) return '';

  const noteParts: string[] = [];
  for (const sp of shapes) {
    if (!sp || typeof sp !== 'object') continue;
    const shape = sp as Record<string, unknown>;

    // ph type="body" がノート本文（idx=1）。type="dt","ftr","sldNum" は除外
    const nvSpPr = shape['nvSpPr'] as Record<string, unknown> | undefined;
    const nvPr = nvSpPr?.['nvPr'] as Record<string, unknown> | undefined;
    const ph = nvPr?.['ph'] as Record<string, unknown> | undefined;
    if (ph) {
      const phType = toStr(ph['@_type']);
      // "dt", "ftr", "sldNum" はスキップ（日付・フッター・スライド番号）
      if (phType === 'dt' || phType === 'ftr' || phType === 'sldNum') continue;
    }

    const txBody = shape['txBody'] as Record<string, unknown> | undefined;
    if (!txBody) continue;
    const paras = txBody['p'];
    if (!Array.isArray(paras)) continue;

    const paraTexts = paras
      .map((p) => extractParagraphText(p as Record<string, unknown>))
      .filter((t) => t.length > 0);
    if (paraTexts.length > 0) {
      noteParts.push(paraTexts.join('\n'));
    }
  }

  return noteParts.join('\n').trim();
}

/**
 * ppt/presentation.xml からスライドの順序（rId → slide path）を解決する。
 * 返り値: スライドの ZIP 内パス配列（順序通り）
 */
function resolveSlideOrder(zip: AdmZip): string[] {
  const presEntry = zip.getEntry('ppt/presentation.xml');
  if (!presEntry) return [];

  let presXml: string;
  try {
    presXml = presEntry.getData().toString('utf8');
  } catch {
    return [];
  }

  let parsed: Record<string, unknown>;
  try {
    parsed = xmlParser.parse(presXml) as Record<string, unknown>;
  } catch {
    return [];
  }

  // rId の順序を sldIdLst から取得
  const presentation = parsed['presentation'] as Record<string, unknown> | undefined;
  if (!presentation) return [];
  const sldIdLst = presentation['sldIdLst'] as Record<string, unknown> | undefined;
  if (!sldIdLst) return [];
  const sldIds = sldIdLst['sldId'];
  if (!Array.isArray(sldIds)) return [];

  const rIds: string[] = sldIds.map((s) => {
    const obj = s as Record<string, unknown>;
    return toStr(obj['@_r:id'] ?? obj['@_id']);
  }).filter((id) => id.startsWith('rId'));

  // presentation.xml.rels からrId → slide パスを解決
  const relsEntry = zip.getEntry('ppt/_rels/presentation.xml.rels');
  if (!relsEntry) return [];

  let relsXml: string;
  try {
    relsXml = relsEntry.getData().toString('utf8');
  } catch {
    return [];
  }

  let relsParsed: Record<string, unknown>;
  try {
    relsParsed = xmlParser.parse(relsXml) as Record<string, unknown>;
  } catch {
    return [];
  }

  const relationships = (relsParsed['Relationships'] as Record<string, unknown> | undefined);
  if (!relationships) return [];

  // Relationship は配列またはオブジェクトの場合がある
  const rels = relationships['Relationship'];
  const relArray = Array.isArray(rels) ? rels : [rels];

  // rId → target パスのマップを構築
  const rIdToTarget = new Map<string, string>();
  for (const rel of relArray) {
    if (!rel || typeof rel !== 'object') continue;
    const r = rel as Record<string, unknown>;
    const id = toStr(r['@_Id']);
    const target = toStr(r['@_Target']);
    const type = toStr(r['@_Type']);
    if (type.endsWith('/slide') && id && target) {
      // target は "../slides/slide1.xml" 形式なのでパスを正規化
      const normalized = target.startsWith('../')
        ? `ppt/${target.slice(3)}`
        : `ppt/${target}`;
      rIdToTarget.set(id, normalized);
    }
  }

  // sldIdLst の順序に従って並べる
  const slidePaths: string[] = [];
  for (const rId of rIds) {
    const slidePath = rIdToTarget.get(rId);
    if (slidePath) slidePaths.push(slidePath);
  }

  return slidePaths;
}

/**
 * スライドの .rels ファイルから notesSlide のパスを取得する。
 * 例: ppt/slides/slide1.xml → ppt/slides/_rels/slide1.xml.rels
 */
function resolveNotesPath(zip: AdmZip, slidePath: string): string | null {
  // slidePath: "ppt/slides/slide1.xml"
  const dir = path.dirname(slidePath);      // "ppt/slides"
  const base = path.basename(slidePath);    // "slide1.xml"
  const relsPath = `${dir}/_rels/${base}.rels`;

  const relsEntry = zip.getEntry(relsPath);
  if (!relsEntry) return null;

  let relsXml: string;
  try {
    relsXml = relsEntry.getData().toString('utf8');
  } catch {
    return null;
  }

  let parsed: Record<string, unknown>;
  try {
    parsed = xmlParser.parse(relsXml) as Record<string, unknown>;
  } catch {
    return null;
  }

  const relationships = parsed['Relationships'] as Record<string, unknown> | undefined;
  if (!relationships) return null;

  const rels = relationships['Relationship'];
  const relArray = Array.isArray(rels) ? rels : [rels];

  for (const rel of relArray) {
    if (!rel || typeof rel !== 'object') continue;
    const r = rel as Record<string, unknown>;
    const type = toStr(r['@_Type']);
    const target = toStr(r['@_Target']);
    if (type.endsWith('/notesSlide') && target) {
      // target は "../notesSlides/notesSlide1.xml" 形式
      const normalized = target.startsWith('../')
        ? `ppt/${target.slice(3)}`
        : `${dir}/${target}`;
      return normalized;
    }
  }

  return null;
}

// --- ReadPPTX implementation ---

async function executeReadPptx(
  input: Record<string, unknown>,
  ctx: ToolContext,
): Promise<ToolResult> {
  const filePath = input['path'] as string;
  const slideRangeStr = typeof input['slideRange'] === 'string' ? input['slideRange'] : undefined;

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

  if (!fs.existsSync(resolved)) {
    return { output: `File not found: ${filePath}`, isError: true };
  }

  const formatError = validateFileFormat(resolved, {
    expectedExts: ['.pptx', '.pptm'],
    expectedFormat: 'ooxml',
    toolName: 'ReadPPTX',
  });
  if (formatError) {
    return { output: formatError, isError: true };
  }

  const warnings = validateOfficeFile(resolved, ['.pptx', '.pptm'], resolveMaxSize(ctx, 'officePptxMaxSizeMb', DEFAULT_PPTX_MAX_SIZE_MB));
  const hasTooLarge = warnings.some((w) => w.code === 'FILE_TOO_LARGE');
  if (hasTooLarge) {
    return { output: warnings.map((w) => w.message).join('\n'), isError: true };
  }

  const fileSizeBytes = (() => {
    try { return fs.statSync(resolved).size; } catch { return undefined; }
  })();

  const timeoutPromise = new Promise<ToolResult>((resolve) =>
    setTimeout(() => resolve({ output: 'Processing timeout: file took longer than 30 seconds', isError: true }), PROCESSING_TIMEOUT_MS)
  );

  const mainPromise = (async (): Promise<ToolResult> => {
    const startMs = Date.now();
    const filename = path.basename(resolved);
    const slideRange = parseSlideRange(slideRangeStr);

    let zip: AdmZip;
    try {
      zip = new AdmZip(resolved);
    } catch (e) {
      return { output: `Failed to open PPTX file: ${(e as Error).message}`, isError: true };
    }

    // ZIP bomb detection: check total uncompressed size
    const maxUncompressed = resolveMaxSize(ctx, 'officePptxMaxUncompressedMb', DEFAULT_PPTX_MAX_UNCOMPRESSED_MB);
    const totalUncompressed = zip.getEntries().reduce((sum, e) => sum + e.header.size, 0);
    if (totalUncompressed > maxUncompressed) {
      const limitMB = (maxUncompressed / 1024 / 1024).toFixed(0);
      return {
        output: `ZIP bomb detected: total uncompressed size ${(totalUncompressed / 1024 / 1024).toFixed(1)}MB exceeds limit of ${limitMB}MB`,
        isError: true,
      };
    }

    // スライドの順序を presentation.xml から解決
    const orderedSlidePaths = resolveSlideOrder(zip);

    // フォールバック: presentation.xml が読めなかった場合は ZIP 内の slide ファイルを列挙
    let slidePaths = orderedSlidePaths;
    if (slidePaths.length === 0) {
      slidePaths = zip.getEntries()
        .map((e) => e.entryName)
        .filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name))
        .sort((a, b) => {
          const na = parseInt(a.match(/(\d+)/)![1]!, 10);
          const nb = parseInt(b.match(/(\d+)/)![1]!, 10);
          return na - nb;
        });
    }

    const slideBlocks: PptxSlideBlock[] = [];

    for (let i = 0; i < slidePaths.length; i++) {
      const slideNo = i + 1;

      // スライド範囲フィルタ
      if (slideRange && (slideNo < slideRange.min || slideNo > slideRange.max)) continue;

      const slidePath = slidePaths[i]!;
      const entry = zip.getEntry(slidePath);
      if (!entry) {
        warnings.push({
          code: 'PARSE_PARTIAL_SUCCESS',
          message: `Slide ${slideNo}: entry not found in ZIP (${slidePath})`,
        });
        continue;
      }

      let slideXml: string;
      try {
        slideXml = entry.getData().toString('utf8');
      } catch (e) {
        warnings.push({
          code: 'PARSE_PARTIAL_SUCCESS',
          message: `Slide ${slideNo}: failed to read XML: ${(e as Error).message}`,
        });
        continue;
      }

      const { texts, error } = extractTextsFromSlideXml(slideXml);
      if (error) {
        warnings.push({ code: 'PARSE_PARTIAL_SUCCESS', message: `Slide ${slideNo}: ${error}` });
      }

      // ノートの取得（.rels 経由で解決）
      let notes: string | undefined;
      const notesPath = resolveNotesPath(zip, slidePath);
      if (notesPath) {
        const notesEntry = zip.getEntry(notesPath);
        if (notesEntry) {
          try {
            const notesXml = notesEntry.getData().toString('utf8');
            const notesText = extractNotesFromXml(notesXml);
            if (notesText) notes = notesText;
          } catch (e) {
            warnings.push({
              code: 'PARSE_PARTIAL_SUCCESS',
              message: `Slide ${slideNo}: failed to read notes: ${(e as Error).message}`,
            });
          }
        }
      }

      slideBlocks.push({ type: 'pptx.slide', slideNo, texts, notes });
    }

    const doc: OfficeDocument = {
      kind: 'pptx',
      source: { path: resolved, filename },
      metadata: { slideCount: slidePaths.length, fileSizeBytes, processingTimeMs: Date.now() - startMs },
      blocks: slideBlocks,
      warnings,
    };

    // フォーマット出力
    const parts: string[] = [`# ${filename}`, ''];

    if (slideBlocks.length === 0) {
      const msg = slideRange
        ? `No slides found in range ${slideRangeStr}`
        : 'No slides found in file';
      parts.push(msg);
    } else {
      for (const slide of slideBlocks) {
        // スライドタイトルは最初のテキスト要素から推測
        const titleText = slide.texts[0]?.text ?? '';
        const titleLine = titleText
          ? `## Slide ${slide.slideNo}: ${titleText.split('\n')[0]}`
          : `## Slide ${slide.slideNo}`;
        parts.push(titleLine);
        parts.push('');

        // タイトル以降のテキスト（最初の要素はタイトルとして使ったが全文も出力）
        for (const shape of slide.texts) {
          const lines = shape.text.split('\n').filter((l) => l.trim());
          if (lines.length > 0) {
            parts.push(lines.join('\n'));
            parts.push('');
          }
        }

        if (slide.notes) {
          parts.push(`**Notes:** ${slide.notes}`);
          parts.push('');
        }
      }
    }

    if (doc.warnings.length > 0) {
      parts.push('### Warnings');
      for (const w of doc.warnings) {
        parts.push(`- [${w.code}] ${w.message}`);
      }
    }

    doc.metadata.processingTimeMs = Date.now() - startMs;
    logger.debug(`[ReadPPTX] ${filename}: ${slideBlocks.length} slide(s) extracted`);

    let output = parts.join('\n');
    if (output.length > MAX_OUTPUT_CHARS) {
      output = output.slice(0, MAX_OUTPUT_CHARS) + `\n\n[truncated: output exceeded ${MAX_OUTPUT_CHARS} characters]`;
    }
    output = truncateToBudget(output, getToolOutputBudgetTokens(ctx), {
      sourceLabel: `ReadPPTX(${filename})`,
      continuationHint: 'スライド範囲を絞るか、max_slides を調整してください',
    }).text;
    return { output, isError: false };
  })();

  return Promise.race([mainPromise, timeoutPromise]);
}

// --- Exports ---

export async function executeTool(
  name: string,
  input: Record<string, unknown>,
  ctx: ToolContext,
): Promise<ToolResult | null> {
  switch (name) {
    case 'ReadExcel':
      return executeReadExcel(input, ctx);
    case 'ReadDocx':
      return executeReadDocx(input, ctx);
    case 'ReadPdf':
      return executeReadPdf(input, ctx);
    case 'ReadPPTX':
      return executeReadPptx(input, ctx);
    case 'ReadMsg':
      return executeReadMsg(input, ctx);
    case 'SplitExcelSheets':
      return executeSplitExcelSheets(input, ctx);
    case 'SplitDocxSections':
      return executeSplitDocxSections(input, ctx);
    case 'PdfToImages':
      return executePdfToImages(input, ctx);
    default:
      return null;
  }
}
