/**
 * @file src/tools/codeMapTool.js
 * @description Code Map Tool — structural code exploration with multi-level skeleton extraction
 * and line-range reading. Enables agents to understand large codebases without reading entire files.
 *
 * Workflow: skeleton scan → identify interesting areas → read-range for details
 */

import { BaseTool } from './baseTool.js';
import fs from 'fs';
import path from 'path';
import {
  isWhitespace,
  skipWhitespace,
  readIdent,
  startsWithKeyword,
  consumeKeyword,
  consumeAnyKeyword,
  trimmedStartsWithAny,
  indexOfWord,
  containsWord,
  stripStringsAndLineComments,
  countChar,
  consumeDottedAssignTail,
  trimmedEndsWith,
  isIdentStart,
  isWordChar,
} from './parserHelpers.js';

const CODE_MAP_CONFIG = {
  SUPPORTED_EXTENSIONS: new Set([
    '.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', '.py',
    // C / C++ via a focused regex pass — see _parseC below. The
    // proper tree-sitter migration that would replace the JS + Python
    // regex parsers also covers these and adds full accuracy; this
    // pass is the v1 that unblocks `code-map` on C/CPP codebases today.
    '.c', '.h', '.cc', '.cpp', '.cxx', '.hpp', '.hh', '.hxx',
  ]),
  MAX_FILES: 500,
  MAX_FILE_SIZE: 5 * 1024 * 1024, // 5MB per file
  MAX_READ_RANGE: 500,            // Max lines per read-range call
  MAX_DIRECTORY_DEPTH: 20,
  VALID_LEVELS: ['A.0', 'A.1', 'B.0', 'B.1'],
  DEFAULT_LEVEL: 'B.0',
  SKIP_DIRS: new Set(['node_modules', '__pycache__', '.git', 'dist', 'build', 'coverage', '.next', '.nuxt'])
};

class CodeMapTool extends BaseTool {
  constructor(config = {}, logger = null) {
    super(config, logger);

    this.id = 'code-map';
    this.requiresProject = true;
    this.isAsync = true;
    this.timeout = config.timeout || 120000;
  }

  getDescription() {
    return `Code Map Tool: Explore code structure without reading entire files. Two-step workflow:

1. **skeleton** — Extract structural overview (signatures, classes, functions) with line numbers
2. **read-range** — Read specific line ranges identified from the skeleton

SKELETON LEVELS:
- A.0: Public/exported signatures only
- A.1: Public/exported signatures + comments/docstrings
- B.0: All signatures (public + private + methods) — DEFAULT
- B.1: All signatures + comments/docstrings

Supported files: .js, .ts, .jsx, .tsx, .mjs, .cjs, .py, .c, .h, .cc, .cpp, .cxx, .hpp, .hh, .hxx
Respects .gitignore rules. Skips node_modules, __pycache__, .git, dist, build.

USAGE — Skeleton scan:
\`\`\`json
{
  "toolId": "code-map",
  "parameters": {
    "action": "skeleton",
    "path": "src/",
    "level": "B.0",
    "includeImports": false
  }
}
\`\`\`

Or for a single file:
\`\`\`json
{
  "toolId": "code-map",
  "parameters": {
    "action": "skeleton",
    "path": "src/services/aiService.js",
    "level": "A.0"
  }
}
\`\`\`

USAGE — Read specific lines (use line numbers from skeleton output):
\`\`\`json
{
  "toolId": "code-map",
  "parameters": {
    "action": "read-range",
    "filePath": "src/services/aiService.js",
    "startLine": 42,
    "endLine": 90
  }
}
\`\`\`

XML format also supported:
<code-map>
  <action>skeleton</action>
  <path>src/</path>
  <level>B.0</level>
</code-map>

<code-map>
  <action>read-range</action>
  <file-path>src/index.js</file-path>
  <start-line>100</start-line>
  <end-line>150</end-line>
</code-map>`;
  }

  parseParameters(content) {
    try {
      if (content.trim().startsWith('{')) {
        const parsed = JSON.parse(content);
        return {
          action: parsed.action || parsed.parameters?.action,
          path: parsed.path || parsed.parameters?.path,
          level: parsed.level || parsed.parameters?.level,
          includeImports: parsed.includeImports ?? parsed.parameters?.includeImports ?? false,
          filePath: parsed.filePath || parsed.parameters?.filePath,
          startLine: parsed.startLine ?? parsed.parameters?.startLine,
          endLine: parsed.endLine ?? parsed.parameters?.endLine
        };
      }

      // XML parsing
      const params = {};
      const actionMatch = /<action>(.*?)<\/action>/i.exec(content);
      if (actionMatch) params.action = actionMatch[1].trim();

      const pathMatch = /<path>(.*?)<\/path>/i.exec(content);
      if (pathMatch) params.path = pathMatch[1].trim();

      const levelMatch = /<level>(.*?)<\/level>/i.exec(content);
      if (levelMatch) params.level = levelMatch[1].trim();

      const importsMatch = /<include-imports>(.*?)<\/include-imports>/i.exec(content);
      if (importsMatch) params.includeImports = importsMatch[1].trim() === 'true';

      const filePathMatch = /<file-path>(.*?)<\/file-path>/i.exec(content);
      if (filePathMatch) params.filePath = filePathMatch[1].trim();

      const startLineMatch = /<start-line>(.*?)<\/start-line>/i.exec(content);
      if (startLineMatch) params.startLine = parseInt(startLineMatch[1].trim(), 10);

      const endLineMatch = /<end-line>(.*?)<\/end-line>/i.exec(content);
      if (endLineMatch) params.endLine = parseInt(endLineMatch[1].trim(), 10);

      return params;
    } catch (error) {
      this.logger?.error('Failed to parse code-map parameters', { error: error.message });
      return { parseError: error.message };
    }
  }

  getRequiredParameters() {
    return ['action'];
  }

  customValidateParameters(params) {
    const errors = [];

    if (!params.action) {
      errors.push('action is required (skeleton or read-range)');
    } else if (!['skeleton', 'read-range'].includes(params.action)) {
      errors.push(`Invalid action "${params.action}". Must be "skeleton" or "read-range"`);
    }

    if (params.action === 'skeleton') {
      if (!params.path) errors.push('path is required for skeleton action');
      if (params.level && !CODE_MAP_CONFIG.VALID_LEVELS.includes(params.level.toUpperCase())) {
        errors.push(`Invalid level "${params.level}". Must be one of: ${CODE_MAP_CONFIG.VALID_LEVELS.join(', ')}`);
      }
    }

    if (params.action === 'read-range') {
      if (!params.filePath) errors.push('filePath is required for read-range action');
      if (params.startLine == null) errors.push('startLine is required for read-range action');
      if (params.endLine == null) errors.push('endLine is required for read-range action');
      if (params.startLine != null && params.endLine != null) {
        if (params.startLine < 1) errors.push('startLine must be >= 1');
        if (params.endLine < params.startLine) errors.push('endLine must be >= startLine');
        if (params.endLine - params.startLine + 1 > CODE_MAP_CONFIG.MAX_READ_RANGE) {
          errors.push(`Range too large. Maximum ${CODE_MAP_CONFIG.MAX_READ_RANGE} lines per request`);
        }
      }
    }

    return { valid: errors.length === 0, errors };
  }

  async execute(params, context) {
    const { projectDir, directoryAccess } = context;

    let workingDir = projectDir || process.cwd();
    if (directoryAccess?.workingDirectory) {
      workingDir = directoryAccess.workingDirectory;
    }

    const accessibleDirs = this._getAccessibleDirectories(directoryAccess, workingDir);

    switch (params.action) {
      case 'skeleton':
        return await this._executeSkeleton(params, workingDir, accessibleDirs);
      case 'read-range':
        return await this._executeReadRange(params, workingDir, accessibleDirs);
      default:
        throw new Error(`Unknown action: ${params.action}`);
    }
  }

  // ── Directory access helpers ──────────────────────────────────────────────

  _getAccessibleDirectories(directoryAccess, fallbackDir) {
    const dirs = new Set();
    dirs.add(fallbackDir);

    if (directoryAccess) {
      if (directoryAccess.workingDirectory) dirs.add(directoryAccess.workingDirectory);
      if (directoryAccess.readOnlyDirectories) {
        for (const d of directoryAccess.readOnlyDirectories) dirs.add(d);
      }
      if (directoryAccess.writeEnabledDirectories) {
        for (const d of directoryAccess.writeEnabledDirectories) dirs.add(d);
      }
    }

    return Array.from(dirs);
  }

  _isPathAccessible(targetPath, accessibleDirs) {
    for (const dir of accessibleDirs) {
      const relative = path.relative(dir, targetPath);
      if (!relative.startsWith('..') && !path.isAbsolute(relative)) return true;
    }
    return false;
  }

  // ── Skeleton action ───────────────────────────────────────────────────────

  async _executeSkeleton(params, workingDir, accessibleDirs) {
    const targetPath = path.resolve(workingDir, params.path);

    if (!this._isPathAccessible(targetPath, accessibleDirs)) {
      throw new Error(`Path not accessible: ${params.path}`);
    }

    const level = (params.level || CODE_MAP_CONFIG.DEFAULT_LEVEL).toUpperCase();
    const publicOnly = level.startsWith('A');
    const withComments = level.endsWith('.1');
    const includeImports = params.includeImports || false;
    const parseOptions = { publicOnly, withComments, includeImports };

    let stat;
    try {
      stat = await fs.promises.stat(targetPath);
    } catch {
      throw new Error(`Path not found: ${params.path}`);
    }

    let files;
    if (stat.isFile()) {
      const ext = path.extname(targetPath).toLowerCase();
      if (!CODE_MAP_CONFIG.SUPPORTED_EXTENSIONS.has(ext)) {
        throw new Error(`Unsupported file type: ${ext}. Supported: ${[...CODE_MAP_CONFIG.SUPPORTED_EXTENSIONS].join(', ')}`);
      }
      files = [targetPath];
    } else {
      files = await this._discoverFiles(targetPath, accessibleDirs);
    }

    if (files.length === 0) {
      return {
        success: true,
        action: 'skeleton',
        level,
        path: params.path,
        files: [],
        totalFiles: 0,
        totalEntries: 0,
        message: 'No supported files found in the specified path.',
        guidance: 'Try a different path or verify the directory contains supported files (.js, .ts, .jsx, .tsx, .mjs, .cjs, .py, .c, .h, .cc, .cpp, .cxx, .hpp, .hh, .hxx).'
      };
    }

    if (files.length > CODE_MAP_CONFIG.MAX_FILES) {
      files = files.slice(0, CODE_MAP_CONFIG.MAX_FILES);
    }

    const basePath = stat.isDirectory() ? targetPath : path.dirname(targetPath);
    const result = [];
    let totalEntries = 0;

    for (const file of files) {
      try {
        const fileStat = await fs.promises.stat(file);
        if (fileStat.size > CODE_MAP_CONFIG.MAX_FILE_SIZE) continue;

        const content = await fs.promises.readFile(file, 'utf-8');
        const lines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
        const lang = this._langOf(file);

        const entries =
          lang === 'python' ? this._parsePython(lines, parseOptions) :
          lang === 'c'      ? this._parseC(lines, parseOptions) :
                              this._parseJS(lines, parseOptions);

        if (entries.length === 0) continue;

        const relPath = path.relative(basePath, file);
        result.push({
          file: relPath,
          totalLines: lines.length,
          entries
        });
        totalEntries += entries.length;
      } catch (err) {
        this.logger?.debug(`Skipping file ${file}: ${err.message}`);
      }
    }

    return {
      success: true,
      action: 'skeleton',
      level,
      path: params.path,
      files: result,
      totalFiles: result.length,
      totalEntries,
      guidance: totalEntries > 0
        ? (() => {
            const sampleFile = result[0];
            const sampleLine = sampleFile?.entries[0]?.line || 1;
            const examplePath = stat.isDirectory()
              ? (params.path.endsWith('/') ? params.path : params.path + '/') + sampleFile.file
              : params.path;
            return `Found ${totalEntries} entries across ${result.length} files. Use code-map read-range to examine specific sections by line number. Example: {"toolId":"code-map","parameters":{"action":"read-range","filePath":"${examplePath}","startLine":${sampleLine},"endLine":${sampleLine + 30}}}`;
          })()
        : 'No code signatures found at this level. Try level B.0 or B.1 for more detail.'
    };
  }

  // ── Read-range action ─────────────────────────────────────────────────────

  async _executeReadRange(params, workingDir, accessibleDirs) {
    const filePath = path.resolve(workingDir, params.filePath);

    if (!this._isPathAccessible(filePath, accessibleDirs)) {
      throw new Error(`Path not accessible: ${params.filePath}`);
    }

    let content;
    try {
      content = await fs.promises.readFile(filePath, 'utf-8');
    } catch {
      throw new Error(`File not found: ${params.filePath}`);
    }

    const allLines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
    const totalLines = allLines.length;
    const startLine = Math.max(1, params.startLine);
    const endLine = Math.min(totalLines, params.endLine);

    if (startLine > totalLines) {
      throw new Error(`startLine ${startLine} exceeds file length (${totalLines} lines)`);
    }

    const selectedLines = allLines.slice(startLine - 1, endLine);
    const maxWidth = String(endLine).length;
    const formatted = selectedLines.map((line, i) => {
      const num = String(startLine + i).padStart(maxWidth);
      return `${num}│${line}`;
    }).join('\n');

    return {
      success: true,
      action: 'read-range',
      filePath: params.filePath,
      startLine,
      endLine,
      linesReturned: selectedLines.length,
      totalLines,
      content: formatted,
      guidance: `Showing lines ${startLine}-${endLine} of ${totalLines}. ` +
        (endLine < totalLines
          ? `Use read-range with startLine:${endLine + 1} to continue reading. `
          : '') +
        'Use code-map skeleton to discover more code structure in other files.'
    };
  }

  // ── File discovery with .gitignore ────────────────────────────────────────

  async _discoverFiles(rootDir, accessibleDirs) {
    const gi = await this._loadGitignoreRules(rootDir);
    const results = [];
    let depth = 0;

    const walk = async (dir) => {
      if (depth > CODE_MAP_CONFIG.MAX_DIRECTORY_DEPTH) return;
      if (results.length >= CODE_MAP_CONFIG.MAX_FILES) return;

      const rel = path.relative(rootDir, dir);
      if (rel) gi.loadNested(dir, rel);

      let entries;
      try {
        entries = await fs.promises.readdir(dir, { withFileTypes: true });
      } catch {
        return;
      }

      // Sort for deterministic output
      entries.sort((a, b) => a.name.localeCompare(b.name));

      for (const entry of entries) {
        if (results.length >= CODE_MAP_CONFIG.MAX_FILES) break;
        if (entry.name.startsWith('.')) continue;

        const full = path.join(dir, entry.name);
        const entryRel = path.relative(rootDir, full);

        if (entry.isDirectory()) {
          if (CODE_MAP_CONFIG.SKIP_DIRS.has(entry.name)) continue;
          if (gi.isIgnored(entryRel, true) && !gi.hasNegationUnder(entryRel)) continue;
          if (!this._isPathAccessible(full, accessibleDirs)) continue;

          depth++;
          await walk(full);
          depth--;
        } else if (CODE_MAP_CONFIG.SUPPORTED_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
          if (gi.isIgnored(entryRel, false)) continue;
          results.push(full);
        }
      }
    };

    await walk(rootDir);
    return results;
  }

  // ── .gitignore support ────────────────────────────────────────────────────

  async _loadGitignoreRules(rootDir) {
    const allRules = [];

    const giPath = path.join(rootDir, '.gitignore');
    try {
      const content = await fs.promises.readFile(giPath, 'utf-8');
      allRules.push(...this._parseGitignore(content, ''));
    } catch {
      // No .gitignore at root — that's fine
    }

    return {
      rules: allRules,

      loadNested: (subDir, relPath) => {
        const nested = path.join(subDir, '.gitignore');
        try {
          const content = fs.readFileSync(nested, 'utf-8');
          const prefix = relPath ? relPath + '/' : '';
          allRules.push(...this._parseGitignore(content, prefix));
        } catch {
          // No nested .gitignore
        }
      },

      isIgnored: (relPath, isDir) => {
        let ignored = false;
        for (const rule of allRules) {
          if (rule.dirOnly && !isDir) {
            const parts = relPath.split('/');
            let parentMatch = false;
            for (let k = 1; k < parts.length; k++) {
              const parentPath = parts.slice(0, k).join('/');
              if (rule.regex.test(parentPath)) { parentMatch = true; break; }
            }
            if (!parentMatch) continue;
          } else if (!rule.regex.test(relPath)) {
            continue;
          }
          ignored = !rule.negate;
        }
        return ignored;
      },

      hasNegationUnder: (dirRelPath) => {
        const prefix = dirRelPath + '/';
        for (const rule of allRules) {
          if (!rule.negate) continue;
          if (rule.regex.test(prefix + 'x') || rule.regex.test(prefix + 'x.js')) return true;
          if (rule.regex.source.includes(dirRelPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) return true;
        }
        return false;
      }
    };
  }

  _parseGitignore(content, prefix) {
    const rules = [];
    for (const rawLine of content.split('\n')) {
      let line = rawLine.trim();
      if (!line || line.startsWith('#')) continue;

      let negate = false;
      if (line.startsWith('!')) {
        negate = true;
        line = line.slice(1);
      }

      line = line.replace(/\\(\s)$/, '$1');

      let dirOnly = false;
      if (line.endsWith('/')) {
        dirOnly = true;
        line = line.slice(0, -1);
      }

      const hasSlash = line.includes('/');
      const rooted = line.startsWith('/');
      if (rooted) line = line.slice(1);

      let reStr;
      if (hasSlash || rooted) {
        reStr = '^' + prefix + this._gitignorePatternToRegex(line) + '(/.*)?$';
      } else {
        reStr = '(^|.+/)' + prefix + this._gitignorePatternToRegex(line) + '(/.*)?$';
      }

      try {
        rules.push({ regex: new RegExp(reStr), negate, dirOnly });
      } catch {
        // skip invalid patterns
      }
    }
    return rules;
  }

  _gitignorePatternToRegex(pattern) {
    let re = '';
    let i = 0;
    while (i < pattern.length) {
      const ch = pattern[i];
      if (ch === '*' && pattern[i + 1] === '*') {
        if (pattern[i + 2] === '/') {
          re += '(.+/)?';
          i += 3;
        } else {
          re += '.*';
          i += 2;
        }
      } else if (ch === '*') {
        re += '[^/]*';
        i++;
      } else if (ch === '?') {
        re += '[^/]';
        i++;
      } else if (ch === '[') {
        const end = pattern.indexOf(']', i + 1);
        if (end === -1) { re += '\\['; i++; }
        else { re += pattern.slice(i, end + 1); i = end + 1; }
      } else if ('.+^${}()|\\'.includes(ch)) {
        re += '\\' + ch;
        i++;
      } else {
        re += ch;
        i++;
      }
    }
    return re;
  }

  // ── Language detection ────────────────────────────────────────────────────

  _langOf(file) {
    const ext = path.extname(file).toLowerCase();
    if (ext === '.py') return 'python';
    if (['.c', '.h', '.cc', '.cpp', '.cxx', '.hpp', '.hh', '.hxx'].includes(ext)) return 'c';
    return 'js';
  }

  // ── JS / TS parser ────────────────────────────────────────────────────────
  //
  // No-regex implementation. Pattern matchers below each return either
  // `true`/`false` or `null`/idx, using the primitives from
  // parserHelpers.js. This trades five lines of regex for ten lines of
  // explicit cursor-walking, but the result is far more debuggable and
  // every behavior is locked by parserHelpers.test.js + codeMapTool.test.js.

  // Modifier keywords that can precede a class-method name (used by the
  // "method inside class" matcher).
  static _METHOD_MODIFIERS = new Set([
    'private', 'protected', 'public', 'readonly', 'abstract',
    'override', 'async', 'static', 'get', 'set',
  ]);

  // Reserved words that look like methods but aren't — `if (x) {` etc.
  static _METHOD_KEYWORD_EXCLUDE = new Set([
    'if', 'for', 'while', 'switch', 'catch', 'return', 'throw', 'new',
    'typeof', 'delete', 'void', 'await', 'class', 'function',
    'const', 'let', 'var', 'super', 'this',
  ]);

  /**
   * Does the RHS of an assignment (starting at idx) look like a function
   * or arrow expression? Recognizes:
   *   function …
   *   async function …
   *   () => …       or       (a, b) => …
   *   ident => …    or       async ident => …
   *
   * Returns true on hit. Allocation-free.
   */
  _rhsLooksLikeFunction(s, idx) {
    let i = skipWhitespace(s, idx);
    // optional `async`
    const afterAsync = consumeKeyword(s, 'async', i);
    if (afterAsync !== -1) i = skipWhitespace(s, afterAsync);
    if (startsWithKeyword(s, 'function', i)) return true;

    // `(…) =>`  — find a balanced paren run, then `=>`
    if (s[i] === '(') {
      let depth = 1; let j = i + 1;
      while (j < s.length && depth > 0) {
        if (s[j] === '(') depth += 1;
        else if (s[j] === ')') depth -= 1;
        j += 1;
      }
      const k = skipWhitespace(s, j);
      if (s[k] === '=' && s[k + 1] === '>') return true;
      return false;
    }

    // `ident =>`
    const id = readIdent(s, i);
    if (id) {
      const k = skipWhitespace(s, id.nextIdx);
      if (s[k] === '=' && s[k + 1] === '>') return true;
    }
    return false;
  }

  /** Line is `import ...` (ESM). */
  _isImportLine(raw) {
    const i = skipWhitespace(raw, 0);
    return startsWithKeyword(raw, 'import', i);
  }
  /** Line contains `require(` as a whole word call. */
  _hasRequireCall(raw) {
    const i = indexOfWord(raw, 'require');
    if (i === -1) return false;
    const j = skipWhitespace(raw, i + 'require'.length);
    return raw[j] === '(';
  }

  /** Walks across the prefix keywords `export default abstract async ...`. */
  _skipDecoratorPrefix(raw) {
    let i = skipWhitespace(raw, 0);
    // export, then optional `default`
    const afterExport = consumeKeyword(raw, 'export', i);
    if (afterExport !== -1) i = skipWhitespace(raw, afterExport);
    const afterDefault = consumeKeyword(raw, 'default', i);
    if (afterDefault !== -1) i = skipWhitespace(raw, afterDefault);
    const afterAbstract = consumeKeyword(raw, 'abstract', i);
    if (afterAbstract !== -1) i = skipWhitespace(raw, afterAbstract);
    const afterAsync = consumeKeyword(raw, 'async', i);
    if (afterAsync !== -1) i = skipWhitespace(raw, afterAsync);
    return i;
  }

  /**
   * The line starts with one of these declaration keywords (after the
   * optional `export [default] [abstract] [async]` prefix run):
   *   function | class | const | let | var | enum | interface | type
   *
   * Returns true if so.
   */
  _isExportableDecl(raw) {
    const i = this._skipDecoratorPrefix(raw);
    return (
      startsWithKeyword(raw, 'function',  i) ||
      startsWithKeyword(raw, 'class',     i) ||
      startsWithKeyword(raw, 'const',     i) ||
      startsWithKeyword(raw, 'let',       i) ||
      startsWithKeyword(raw, 'var',       i) ||
      startsWithKeyword(raw, 'enum',      i) ||
      startsWithKeyword(raw, 'interface', i) ||
      startsWithKeyword(raw, 'type',      i) ||
      startsWithKeyword(raw, 'abstract',  i)    // bare `export abstract foo`
    );
  }

  /** `module.exports = …` (whole or property: `module.exports.foo = …`). */
  _isModuleExportsAssign(raw) {
    const i = skipWhitespace(raw, 0);
    if (!startsWithKeyword(raw, 'module', i)) return false;
    let j = i + 'module'.length;
    if (raw[j] !== '.') return false;
    j += 1;
    if (!startsWithKeyword(raw, 'exports', j)) return false;
    j += 'exports'.length;
    // optional `.ident`
    if (raw[j] === '.') {
      const id = readIdent(raw, j + 1);
      if (!id) return false;
      j = id.nextIdx;
    }
    j = skipWhitespace(raw, j);
    return raw[j] === '=' && raw[j + 1] !== '=';
  }

  /** `exports.foo = …` */
  _isExportsAssign(raw) {
    const i = skipWhitespace(raw, 0);
    if (!startsWithKeyword(raw, 'exports', i)) return false;
    let j = i + 'exports'.length;
    if (raw[j] !== '.') return false;
    const id = readIdent(raw, j + 1);
    if (!id) return false;
    j = skipWhitespace(raw, id.nextIdx);
    return raw[j] === '=' && raw[j + 1] !== '=';
  }

  /** `(async) function name(…)` top-level form. */
  _isTopLevelFunctionDecl(raw) {
    let i = skipWhitespace(raw, 0);
    const aa = consumeKeyword(raw, 'async', i);
    if (aa !== -1) i = skipWhitespace(raw, aa);
    if (!startsWithKeyword(raw, 'function', i)) return false;
    i = skipWhitespace(raw, i + 'function'.length);
    return readIdent(raw, i) !== null;
  }

  /** `(abstract) class Name`. */
  _isTopLevelClassDecl(raw) {
    let i = skipWhitespace(raw, 0);
    const aa = consumeKeyword(raw, 'abstract', i);
    if (aa !== -1) i = skipWhitespace(raw, aa);
    if (!startsWithKeyword(raw, 'class', i)) return false;
    i = skipWhitespace(raw, i + 'class'.length);
    return readIdent(raw, i) !== null;
  }

  /** `const|let|var name = <function-ish>`. */
  _isConstAssignedFunction(raw) {
    let i = skipWhitespace(raw, 0);
    const dk = consumeAnyKeyword(raw, ['const', 'let', 'var'], i);
    if (!dk) return false;
    i = skipWhitespace(raw, dk.nextIdx);
    const id = readIdent(raw, i);
    if (!id) return false;
    i = skipWhitespace(raw, id.nextIdx);
    if (raw[i] !== '=' || raw[i + 1] === '=' || raw[i + 1] === '>') return false;
    return this._rhsLooksLikeFunction(raw, i + 1);
  }

  /** Class-with-an-opening-brace check for the brace stack. */
  _isClassDeclWithOpenBrace(raw, opens) {
    if (opens <= 0) return false;
    let i = skipWhitespace(raw, 0);
    const ex = consumeKeyword(raw, 'export', i);
    if (ex !== -1) i = skipWhitespace(raw, ex);
    const df = consumeKeyword(raw, 'default', i);
    if (df !== -1) i = skipWhitespace(raw, df);
    const ab = consumeKeyword(raw, 'abstract', i);
    if (ab !== -1) i = skipWhitespace(raw, ab);
    return startsWithKeyword(raw, 'class', i);
  }

  /** IIFE prefix-op form: `!function`, `+function`, `~function`, `-function`. */
  _isIifePrefixOp(raw) {
    const i = skipWhitespace(raw, 0);
    const ch = raw[i];
    if (ch !== '!' && ch !== '+' && ch !== '~' && ch !== '-') return false;
    let j = skipWhitespace(raw, i + 1);
    const aa = consumeKeyword(raw, 'async', j);
    if (aa !== -1) j = skipWhitespace(raw, aa);
    return startsWithKeyword(raw, 'function', j);
  }

  /** IIFE paren-wrapped form: `(function ...)` or `(async function ...)`. */
  _isIifeParens(raw) {
    const i = skipWhitespace(raw, 0);
    if (raw[i] !== '(') return false;
    let j = skipWhitespace(raw, i + 1);
    const aa = consumeKeyword(raw, 'async', j);
    if (aa !== -1) j = skipWhitespace(raw, aa);
    return startsWithKeyword(raw, 'function', j);
  }

  /**
   * Global/namespace assignment of a function-ish value:
   *   window.X.Y = function   /   globalThis.X = () =>   /   MyApp.foo = …
   * Conditions:
   *   - LHS head is `window|globalThis|self` (and optional dotted tail)
   *     OR a TitleCase identifier followed by AT LEAST one dotted tail
   *     segment (so we don't grab bare `Foo = …`)
   *   - RHS is function or arrow
   */
  _isGlobalAssign(raw) {
    let i = skipWhitespace(raw, 0);
    const headKw = consumeAnyKeyword(raw, ['window', 'globalThis', 'self'], i);
    let after;
    let hadGlobalHead = false;
    if (headKw) {
      hadGlobalHead = true;
      after = consumeDottedAssignTail(raw, headKw.nextIdx);
    } else {
      // TitleCase ident + at least one dotted tail
      const id = readIdent(raw, i);
      if (!id) return false;
      // TitleCase = uppercase first char
      const first = id.ident[0];
      if (first < 'A' || first > 'Z') return false;
      // Need at least one '.ident' before '='
      if (raw[id.nextIdx] !== '.') return false;
      after = consumeDottedAssignTail(raw, id.nextIdx);
    }
    if (after === -1) return false;
    return this._rhsLooksLikeFunction(raw, after);
  }

  /**
   * Prototype-method assignment: `Foo.prototype.bar = function|=>…`.
   * Note: leverages `_isGlobalAssign`'s tail logic but requires
   * `.prototype.<ident>` somewhere in the LHS.
   */
  _isPrototypeAssign(raw) {
    let i = skipWhitespace(raw, 0);
    const id = readIdent(raw, i);
    if (!id) return false;
    let j = id.nextIdx;
    // walk `.ident*` until we hit `.prototype`
    let sawPrototype = false;
    while (raw[j] === '.') {
      // peek the next ident
      if (startsWithKeyword(raw, 'prototype', j + 1)) {
        sawPrototype = true;
        j = j + 1 + 'prototype'.length;
        break;
      }
      const more = readIdent(raw, j + 1);
      if (!more) return false;
      j = more.nextIdx;
    }
    if (!sawPrototype) return false;
    // Need `.ident` + `=` + function-ish
    if (raw[j] !== '.') return false;
    const methodId = readIdent(raw, j + 1);
    if (!methodId) return false;
    j = skipWhitespace(raw, methodId.nextIdx);
    if (raw[j] !== '=' || raw[j + 1] === '=') return false;
    return this._rhsLooksLikeFunction(raw, j + 1);
  }

  /** jQuery-style plugin: `$.fn.X = function …` / `jQuery.fn.X = function …`. */
  _isJqueryPluginAssign(raw) {
    let i = skipWhitespace(raw, 0);
    let afterHead;
    if (raw[i] === '$') {
      afterHead = i + 1;
    } else if (startsWithKeyword(raw, 'jQuery', i)) {
      afterHead = i + 'jQuery'.length;
    } else {
      return false;
    }
    if (raw[afterHead] !== '.') return false;
    if (!startsWithKeyword(raw, 'fn', afterHead + 1)) return false;
    let j = afterHead + 1 + 'fn'.length;
    if (raw[j] !== '.') return false;
    const id = readIdent(raw, j + 1);
    if (!id) return false;
    j = skipWhitespace(raw, id.nextIdx);
    if (raw[j] !== '=' || raw[j + 1] === '=') return false;
    // We only insist on `function` here (matches the original regex).
    let r = skipWhitespace(raw, j + 1);
    const aa = consumeKeyword(raw, 'async', r);
    if (aa !== -1) r = skipWhitespace(raw, aa);
    return startsWithKeyword(raw, 'function', r);
  }

  /** `document.addEventListener(` or `window.addEventListener(`. */
  _isAddEventListenerCall(raw) {
    const i = skipWhitespace(raw, 0);
    const head = consumeAnyKeyword(raw, ['document', 'window'], i);
    if (!head) return false;
    let j = head.nextIdx;
    if (raw[j] !== '.') return false;
    if (!startsWithKeyword(raw, 'addEventListener', j + 1)) return false;
    j = skipWhitespace(raw, j + 1 + 'addEventListener'.length);
    return raw[j] === '(';
  }

  /**
   * Method inside a class. Caller already established `classDepth > 0`
   * and `braceDepthBeforeLine === methodDepth`. We just decide if the
   * line LOOKS like a method declaration vs a method call.
   *
   * Returns the method name or null.
   */
  _detectMethodName(raw) {
    // Must start with at least one space (methods are indented).
    if (raw[0] !== ' ' && raw[0] !== '\t') return null;
    let i = skipWhitespace(raw, 0);
    // Walk through any sequence of modifier keywords.
    while (true) {
      const id = readIdent(raw, i);
      if (!id) return null;
      if (!CodeMapTool._METHOD_MODIFIERS.has(id.ident)) {
        // This is the method name candidate.
        const afterName = skipWhitespace(raw, id.nextIdx);
        if (raw[afterName] !== '(' && raw[afterName] !== '<') return null;
        // Excluded reserved words (`if`, `for`, …)
        if (CodeMapTool._METHOD_KEYWORD_EXCLUDE.has(id.ident)) return null;
        // Is this a call rather than a definition?
        // Calls end with `)` (optionally `;`) and have NO `) {` or `) : …`.
        const trimmed = raw.trim();
        const endsWithClose = trimmedEndsWith(trimmed, ')');
        const endsWithCloseBrace = trimmedEndsWith(trimmed, ') {');
        // `): Type` — a TS-style return-type annotation continuation
        const hasReturnTypeAnnotation = (() => {
          // Walk past the open paren to find the matching `)`, then check
          // if the next non-ws char is `:` followed by a non-empty token.
          let k = afterName + 1;
          let depth = 1;
          while (k < raw.length && depth > 0) {
            if (raw[k] === '(') depth += 1;
            else if (raw[k] === ')') depth -= 1;
            k += 1;
          }
          k = skipWhitespace(raw, k);
          return raw[k] === ':' && raw[k + 1] !== '\0' && raw[k + 1] !== undefined;
        })();
        if (endsWithClose && !endsWithCloseBrace && !hasReturnTypeAnnotation) return null;
        return id.ident;
      }
      // It WAS a modifier — consume + continue.
      i = skipWhitespace(raw, id.nextIdx);
    }
  }

  _parseJS(lines, { publicOnly, withComments, includeImports }) {
    const entries = [];
    let pendingComments = [];
    let inBlockComment = false;
    let blockCommentLines = [];

    let classDepth = 0;
    let braceStack = [];
    let braceDepth = 0;
    let methodDepths = [];

    for (let i = 0; i < lines.length; i += 1) {
      const lineNum = i + 1;
      const raw = lines[i];
      const start = skipWhitespace(raw, 0);

      // block-comment tracking
      if (inBlockComment) {
        blockCommentLines.push({ line: lineNum, text: raw, kind: 'comment' });
        if (raw.includes('*/')) {
          inBlockComment = false;
          pendingComments = blockCommentLines;
          blockCommentLines = [];
        }
        continue;
      }
      if (raw[start] === '/' && raw[start + 1] === '*') {
        blockCommentLines = [{ line: lineNum, text: raw, kind: 'comment' }];
        if (raw.slice(start + 2).includes('*/')) {
          pendingComments = blockCommentLines;
          blockCommentLines = [];
        } else {
          inBlockComment = true;
        }
        continue;
      }
      if (raw[start] === '/' && raw[start + 1] === '/') {
        pendingComments.push({ line: lineNum, text: raw, kind: 'comment' });
        continue;
      }

      // imports
      if (includeImports) {
        if (this._isImportLine(raw) || this._hasRequireCall(raw)) {
          entries.push({ line: lineNum, text: raw, kind: 'import' });
          pendingComments = [];
          continue;
        }
      }

      // Brace tracking for class context — mask strings + line comments
      // before counting so e.g. `var s = "class Foo {"` doesn't push a
      // bogus class frame.
      const masked = stripStringsAndLineComments(raw);
      const opens  = countChar(masked, '{');
      const closes = countChar(masked, '}');
      const braceDepthBeforeLine = braceDepth;

      if (this._isClassDeclWithOpenBrace(raw, opens)) {
        braceStack.push({ depth: braceDepth, type: 'class' });
        methodDepths.push(braceDepth + 1);
        classDepth += 1;
      }
      braceDepth += opens - closes;
      while (braceStack.length > 0 && braceDepth <= braceStack[braceStack.length - 1].depth) {
        const popped = braceStack.pop();
        if (popped.type === 'class') {
          classDepth -= 1;
          methodDepths.pop();
        }
      }

      // Signature detection
      const isExport         = startsWithKeyword(raw, 'export', start);
      const isModuleExports  = this._isModuleExportsAssign(raw);
      const isExportsAssign  = this._isExportsAssign(raw);
      const isPublic         = isExport || isModuleExports || isExportsAssign;

      let matched = false;

      if (isExport && this._isExportableDecl(raw))   matched = true;
      if (isModuleExports || isExportsAssign)        matched = true;

      if (!matched && !publicOnly) {
        if (this._isTopLevelFunctionDecl(raw))      matched = true;
        if (this._isTopLevelClassDecl(raw))          matched = true;
        if (this._isConstAssignedFunction(raw))      matched = true;

        // Browser-JS / pre-ESM patterns
        if (this._isIifePrefixOp(raw))               matched = true;
        if (this._isIifeParens(raw))                 matched = true;
        if (this._isGlobalAssign(raw))               matched = true;
        if (this._isPrototypeAssign(raw))            matched = true;
        if (this._isJqueryPluginAssign(raw))         matched = true;
        if (this._isAddEventListenerCall(raw))       matched = true;

        // Method inside class
        const atMethodDepth = methodDepths.length > 0 &&
                              braceDepthBeforeLine === methodDepths[methodDepths.length - 1];
        if (classDepth > 0 && atMethodDepth && !isExport) {
          if (this._detectMethodName(raw)) matched = true;
        }
      }

      if (!matched && isPublic) matched = true;
      if (matched && publicOnly && !isPublic) {
        pendingComments = [];
        continue;
      }

      if (matched) {
        if (withComments && pendingComments.length > 0) {
          for (const c of pendingComments) entries.push(c);
        }
        entries.push({ line: lineNum, text: raw, kind: 'signature' });
        pendingComments = [];
      } else {
        pendingComments = [];
      }
    }
    return entries;
  }

  // ── Python parser ─────────────────────────────────────────────────────────
  //
  // No-regex implementation. Identifiers are ASCII per PEP 8's
  // recommendation; this is a documented tradeoff vs. the previous
  // Unicode-aware (\p{L}) regex. The alternative — bundling a Unicode
  // category table — adds ~50 KB for a feature ~0.1 % of Python
  // codebases use. If users surface a non-ASCII-identifier Python
  // codebase the fix is to bring in tree-sitter (already planned for
  // #5 follow-on) which is Unicode-correct by default.

  /** Try to read a `def` or `async def` head. Returns the function name or null. */
  _readPythonDefName(raw) {
    let i = skipWhitespace(raw, 0);
    // optional `async`
    const aa = consumeKeyword(raw, 'async', i);
    if (aa !== -1) i = skipWhitespace(raw, aa);
    if (!startsWithKeyword(raw, 'def', i)) return null;
    i = skipWhitespace(raw, i + 'def'.length);
    const id = readIdent(raw, i);
    if (!id) return null;
    i = skipWhitespace(raw, id.nextIdx);
    if (raw[i] !== '(') return null;
    return id.ident;
  }
  /** Try to read a `class Foo` head. Returns class name or null. */
  _readPythonClassName(raw) {
    let i = skipWhitespace(raw, 0);
    if (!startsWithKeyword(raw, 'class', i)) return null;
    i = skipWhitespace(raw, i + 'class'.length);
    const id = readIdent(raw, i);
    return id ? id.ident : null;
  }

  /**
   * Returns true if `s` (at or after leading whitespace) starts with a
   * Python triple-quote prefix (`"""` or `'''`). Returns null otherwise.
   * On hit returns the 3-char quote string.
   */
  _pythonTripleQuoteAtStart(s) {
    const i = skipWhitespace(s, 0);
    if (s[i] === '"' && s[i + 1] === '"' && s[i + 2] === '"') return '"""';
    if (s[i] === "'" && s[i + 1] === "'" && s[i + 2] === "'") return "'''";
    return null;
  }
  /** True if the single-line docstring on a triple-quote line is closed on the same line. */
  _pythonTripleQuoteClosedOnLine(s, quote) {
    const i = skipWhitespace(s, 0);
    return s.indexOf(quote, i + 3) !== -1;
  }

  _parsePython(lines, { publicOnly, withComments, includeImports }) {
    const entries = [];
    let pendingComments = [];
    let i = 0;

    while (i < lines.length) {
      const lineNum = i + 1;
      const raw = lines[i];
      const start = skipWhitespace(raw, 0);

      // imports — `import …` or `from <module> import …`
      if (includeImports) {
        if (startsWithKeyword(raw, 'import', start)) {
          entries.push({ line: lineNum, text: raw, kind: 'import' });
          pendingComments = [];
          i += 1; continue;
        }
        if (startsWithKeyword(raw, 'from', start)) {
          // require a non-empty module then `import`
          let j = skipWhitespace(raw, start + 'from'.length);
          // Read the dotted module path; allow leading dots (relative).
          let progressed = false;
          while (j < raw.length && raw[j] === '.') { j += 1; progressed = true; }
          const id = readIdent(raw, j);
          if (id) { j = id.nextIdx; progressed = true; }
          while (raw[j] === '.') {
            const more = readIdent(raw, j + 1);
            if (!more) { progressed = true; break; }
            j = more.nextIdx;
          }
          j = skipWhitespace(raw, j);
          if (progressed && startsWithKeyword(raw, 'import', j)) {
            entries.push({ line: lineNum, text: raw, kind: 'import' });
            pendingComments = [];
            i += 1; continue;
          }
        }
      }

      // # comments
      if (raw[start] === '#') {
        pendingComments.push({ line: lineNum, text: raw, kind: 'comment' });
        i += 1; continue;
      }
      // decorators
      if (raw[start] === '@') {
        pendingComments.push({ line: lineNum, text: raw, kind: 'decorator' });
        i += 1; continue;
      }

      // def / async def / class
      const defName   = this._readPythonDefName(raw);
      const className = defName ? null : this._readPythonClassName(raw);
      const name = defName || className;

      if (name) {
        const isPublic = !name.startsWith('_');

        // Helper: continuation lines for a multi-line signature like
        //   def foo(
        //     x: int,
        //     y: int,
        //   ) -> int:
        // The original advanced past the line that closes with `):` or
        // a line starting with `)` containing `:`. Preserve that logic.
        const advancePastMultilineSig = () => {
          // Only def's have parameter parens; class doesn't.
          if (!defName) return;
          if (raw.includes('):')) return;
          if (raw.includes(')'))  return;
          // walk forward
          while (i < lines.length) {
            const pline = lines[i].trim();
            i += 1;
            if (pline.includes('):') ||
                (pline.startsWith(')') && pline.includes(':'))) break;
          }
        };

        if (publicOnly && !isPublic) {
          pendingComments = [];
          i += 1;
          advancePastMultilineSig();
          i = this._skipPythonDocstring(lines, i);
          continue;
        }

        if (withComments && pendingComments.length > 0) {
          for (const c of pendingComments) entries.push(c);
        }
        entries.push({ line: lineNum, text: raw, kind: 'signature' });
        pendingComments = [];
        i += 1;
        advancePastMultilineSig();

        if (withComments) {
          const dsLines = this._collectPythonDocstring(lines, i);
          for (const ds of dsLines) entries.push(ds);
          i += dsLines.length;
        } else {
          i = this._skipPythonDocstring(lines, i);
        }
        continue;
      }

      // Top-level assignments (data lines):
      //   ident = expr      (only when starting at column 0; indented
      //                      lines are method-internal and ignored)
      if (raw.length > 0 && !isWhitespace(raw[0]) && raw[0] !== '\n') {
        const id = readIdent(raw, 0);
        if (id) {
          let j = skipWhitespace(raw, id.nextIdx);
          if (raw[j] === '=' && raw[j + 1] !== '=') {
            // looks like `ident = …`
            // require there to BE an RHS — `name =` alone is invalid
            const afterEq = skipWhitespace(raw, j + 1);
            if (afterEq < raw.length) {
              const isPublicVar = !id.ident.startsWith('_');
              if (!publicOnly || isPublicVar) {
                if (withComments && pendingComments.length > 0) {
                  for (const c of pendingComments) entries.push(c);
                }
                entries.push({ line: lineNum, text: raw, kind: 'data' });
              }
              pendingComments = [];
              i += 1; continue;
            }
          }
        }
      }

      pendingComments = [];
      i += 1;
    }

    return entries;
  }

  _collectPythonDocstring(lines, startIdx) {
    if (startIdx >= lines.length) return [];
    const first = lines[startIdx];
    const quote = this._pythonTripleQuoteAtStart(first);
    if (!quote) return [];
    // single-line case: triple-quote opens AND closes on the same line
    if (this._pythonTripleQuoteClosedOnLine(first, quote)) {
      return [{ line: startIdx + 1, text: first, kind: 'comment' }];
    }
    // multi-line: collect until the closing triple-quote
    const result = [{ line: startIdx + 1, text: first, kind: 'comment' }];
    for (let j = startIdx + 1; j < lines.length; j += 1) {
      result.push({ line: j + 1, text: lines[j], kind: 'comment' });
      if (lines[j].includes(quote)) break;
    }
    return result;
  }

  _skipPythonDocstring(lines, startIdx) {
    if (startIdx >= lines.length) return startIdx;
    const first = lines[startIdx];
    const quote = this._pythonTripleQuoteAtStart(first);
    if (!quote) return startIdx;
    if (this._pythonTripleQuoteClosedOnLine(first, quote)) return startIdx + 1;
    for (let j = startIdx + 1; j < lines.length; j += 1) {
      if (lines[j].includes(quote)) return j + 1;
    }
    return lines.length;
  }

  // ── C / C++ parser ────────────────────────────────────────────────────────
  //
  // Regex-based, same approach as the JS path: walk lines, match a set
  // of declaration patterns. Mirrors what _parseJS does for JS — gets
  // the agent a high-level skeleton of any C/CPP file (functions,
  // classes/structs, includes, top-level macros) without bringing in
  // a full C parser. The proper tree-sitter migration (see #5 plan)
  // replaces this for full accuracy on edge cases like multi-line
  // function signatures, templated declarations, and trailing return
  // types — those work in 90 % of real code already because most
  // declarations fit on one line.
  //
  // Patterns covered:
  //   - #include <...> / #include "..."  → 'import' kind
  //   - #define MACRO ...                → 'signature' kind
  //   - class Foo {                       → 'signature'
  //   - struct Foo {                      → 'signature'
  //   - enum class Foo {                  → 'signature'
  //   - namespace foo {                   → 'signature'
  //   - typedef … name;                   → 'signature'
  //   - using name = …;                   → 'signature'
  //   - free-function: `<type> name(args) {`  → 'signature'
  //   - method: `<type> Class::name(args) {`  → 'signature'
  //   - constructor: `Class::Class(args) {`   → 'signature'
  //   - destructor: `Class::~Class()`         → 'signature'
  //
  // Skipped (intentional):
  //   - Function prototypes (declarations w/o body) — too noisy in
  //     headers; the user usually wants the definitions
  //   - Lambdas — covered by treating the enclosing function as the
  //     signature
  _parseC(lines, { publicOnly, withComments, includeImports }) {
    const entries = [];
    let pendingComments = [];
    let inBlockComment = false;

    // C/C++ tokens we accept at the head of a function/type declaration.
    // The classifier walks them one at a time using consumeKeyword —
    // no regex. After the keyword run we require at least one
    // additional token before the `(` so we don't misidentify a bare
    // function call as a definition.
    const C_TYPE_KEYWORDS = [
      'static', 'inline', 'extern', 'constexpr', 'virtual', 'explicit',
      'friend', 'const', 'mutable', 'register', 'volatile',
      'signed', 'unsigned', 'short', 'long',
      // Built-in primitive types — frequently followed by an ident.
      'void', 'bool', 'char', 'int', 'float', 'double',
      'auto', 'wchar_t', 'char16_t', 'char32_t',
    ];

    for (let i = 0; i < lines.length; i += 1) {
      const lineNum = i + 1;
      const raw = lines[i];
      const start = skipWhitespace(raw, 0);

      // ── block + line comments ────────────────────────────────────
      if (inBlockComment) {
        if (withComments) pendingComments.push({ line: lineNum, text: raw, kind: 'comment' });
        if (raw.includes('*/')) inBlockComment = false;
        continue;
      }
      if (raw[start] === '/' && raw[start + 1] === '*') {
        if (withComments) pendingComments.push({ line: lineNum, text: raw, kind: 'comment' });
        // The block-comment scan checks anywhere on the line; templates
        // / preproc lines don't typically contain '*/' so this is safe.
        if (!raw.slice(start + 2).includes('*/')) inBlockComment = true;
        continue;
      }
      if (raw[start] === '/' && raw[start + 1] === '/') {
        if (withComments) pendingComments.push({ line: lineNum, text: raw, kind: 'comment' });
        continue;
      }

      // ── #include ─────────────────────────────────────────────────
      if (includeImports && raw[start] === '#') {
        const afterHash = skipWhitespace(raw, start + 1);
        if (startsWithKeyword(raw, 'include', afterHash)) {
          entries.push({ line: lineNum, text: raw, kind: 'import' });
          pendingComments = [];
          continue;
        }
      }

      let matched = false;

      // ── #define MACRO … ──────────────────────────────────────────
      if (raw[start] === '#') {
        const afterHash = skipWhitespace(raw, start + 1);
        if (startsWithKeyword(raw, 'define', afterHash)) {
          const afterKw = skipWhitespace(raw, afterHash + 'define'.length);
          if (readIdent(raw, afterKw)) matched = true;
        }
      }

      // ── namespace / class / struct / union / enum (incl. `enum class`) ──
      // Tip: also handle a leading `template <…>` by skipping over it.
      let probeIdx = start;
      if (startsWithKeyword(raw, 'template', probeIdx)) {
        // skip "template" + optional whitespace + `<…>` if present
        let j = skipWhitespace(raw, probeIdx + 'template'.length);
        if (raw[j] === '<') {
          let depth = 1;
          j += 1;
          while (j < raw.length && depth > 0) {
            if (raw[j] === '<') depth += 1;
            else if (raw[j] === '>') depth -= 1;
            j += 1;
          }
          probeIdx = skipWhitespace(raw, j);
        }
      }
      const typeHit = consumeAnyKeyword(raw, ['class', 'struct', 'union', 'enum', 'namespace'], probeIdx);
      if (typeHit) {
        let after = skipWhitespace(raw, typeHit.nextIdx);
        // `enum class Color` — accept either form
        if (typeHit.keyword === 'enum' && startsWithKeyword(raw, 'class', after)) {
          after = skipWhitespace(raw, after + 'class'.length);
        }
        if (readIdent(raw, after)) matched = true;
      }

      // ── typedef … name; ──────────────────────────────────────────
      if (!matched && startsWithKeyword(raw, 'typedef', start)) {
        // very permissive — just require a `;` somewhere after typedef
        if (raw.indexOf(';', start) !== -1) matched = true;
      }

      // ── using name = … ───────────────────────────────────────────
      if (!matched && startsWithKeyword(raw, 'using', start)) {
        const afterKw = skipWhitespace(raw, start + 'using'.length);
        const id = readIdent(raw, afterKw);
        if (id) {
          const afterId = skipWhitespace(raw, id.nextIdx);
          if (raw[afterId] === '=' && raw[afterId + 1] !== '=') matched = true;
        }
      }

      // ── Function definition: <type-tokens> name(…) ───────────────
      // Walk past at least one type-ish token before the function name.
      // The "type-ish" tokens are either keywords from C_TYPE_KEYWORDS
      // or any plain identifier (could be a user-defined type) — we
      // require AT LEAST TWO tokens before the `(` so a bare call
      // `foo(x)` isn't misidentified.
      if (!matched) {
        let j = probeIdx;
        let tokensBeforeParen = 0;
        let lastWasIdent = false;
        while (j < raw.length) {
          // pointer / reference / qualifiers in between tokens
          if (raw[j] === '*' || raw[j] === '&') { j += 1; continue; }
          j = skipWhitespace(raw, j);
          if (j >= raw.length) break;
          if (raw[j] === '(') break;
          // accept a templated type like `vector<int>` — skip the `<…>`
          if (raw[j] === '<' && lastWasIdent) {
            let depth = 1; j += 1;
            while (j < raw.length && depth > 0) {
              if (raw[j] === '<') depth += 1;
              else if (raw[j] === '>') depth -= 1;
              j += 1;
            }
            continue;
          }
          // qualifier keyword?
          const kw = consumeAnyKeyword(raw, C_TYPE_KEYWORDS, j);
          if (kw) { j = kw.nextIdx; tokensBeforeParen += 1; lastWasIdent = false; continue; }
          // plain identifier (possibly `Foo::bar`)
          const id = readIdent(raw, j);
          if (!id) break;
          j = id.nextIdx;
          // handle `Foo::bar` and similar
          while (raw[j] === ':' && raw[j + 1] === ':') {
            j += 2;
            const more = readIdent(raw, j);
            if (!more) break;
            j = more.nextIdx;
          }
          tokensBeforeParen += 1;
          lastWasIdent = true;
        }
        if (raw[j] === '(' && tokensBeforeParen >= 2) {
          // Reject "looks like a call" — `name(args);` ending tail.
          // For a definition we expect either `) {` or just `,` (multi-line)
          // or `)` ending the line. A call ends with `);`.
          const endsWithCallSemi = trimmedEndsWith(raw, ');');
          if (!endsWithCallSemi) matched = true;
        }
      }

      // ── Constructor / destructor: `Foo::Foo(...)` / `Foo::~Foo(...)` ──
      if (!matched) {
        const id1 = readIdent(raw, start);
        if (id1 && raw[id1.nextIdx] === ':' && raw[id1.nextIdx + 1] === ':') {
          let k = id1.nextIdx + 2;
          if (raw[k] === '~') k += 1;
          const id2 = readIdent(raw, k);
          if (id2) {
            const afterName = skipWhitespace(raw, id2.nextIdx);
            if (raw[afterName] === '(') matched = true;
          }
        }
      }

      // publicOnly is a no-op for C — left intact for API parity with
      // _parseJS. Could later mean "header-public only".
      void publicOnly;

      if (matched) {
        if (withComments && pendingComments.length > 0) {
          for (const c of pendingComments) entries.push(c);
        }
        entries.push({ line: lineNum, text: raw, kind: 'signature' });
        pendingComments = [];
      } else {
        pendingComments = [];
      }
    }

    return entries;
  }
}

export default CodeMapTool;
