/**
 * engine/discover-project-skills.js — W-mq1cczi90006b21f
 *
 * Cheap, bounded filesystem walk that surfaces project-local agent tooling
 * (skills, slash-commands, copilot-instructions slash-command mentions) so
 * Minions dispatches across the FULL lifecycle (review / implement / fix /
 * plan / etc.) can reliably steer agents toward the right purpose-built
 * tooling instead of reinventing flows from first principles.
 *
 * This module is the generalized successor to engine/discover-review-skills.js
 * (PR #82, W-mq16xtdx001a347e). PR 82 hard-scoped discovery to review-flavored
 * skills via a single KEYWORD_RE; this module discovers EVERY project skill /
 * command / documented slash-command, classifies each one into a small closed
 * intent vocabulary, and lets callers filter to the intents they care about.
 *
 * Walk surfaces (root-level):
 *   - <projectRoot>/.claude/skills/*\/SKILL.md
 *   - <projectRoot>/.claude/commands/*.md
 *   - <projectRoot>/.github/copilot-instructions.md       (slash-command mentions)
 *   - <projectRoot>/CLAUDE.md                              (slash-command mentions)
 *
 * Plus per-area (one level deep — NOT recursive):
 *   - <projectRoot>/<area>/.claude/skills/*\/SKILL.md
 *   - <projectRoot>/<area>/.claude/commands/*.md
 *
 * Returns a deterministically-ordered, deduplicated array of:
 *   { kind: 'skill'|'command'|'slash-command',
 *     name, path, oneLineDescription,
 *     intents: string[] }
 *
 * Intent vocabulary (CLOSED — do not let this sprawl):
 *   review | build | fix | test | plan | research | deploy | observability | meta
 *
 * Multi-intent classification is allowed (e.g. `code-reviewer` may be both
 * `review` AND `test`). When a skill matches zero intent keywords, `intents`
 * is `[]` — the skill is still discovered, just not surfaced by any
 * intent-filtered playbook. NEVER default to `meta`; empty intents is the
 * honest signal that the heuristic didn't fire.
 *
 * See docs/project-skills.md for the intent vocabulary spec.
 *
 * Contract:
 *   - Worktree missing / unreadable → returns [] (never throws)
 *   - Single bounded readdir per surface (no recursive grep)
 *   - File-count + walltime budget caps the walk in pathological repos
 *   - Pure: no engine state mutations, no logging beyond debug
 *   - Deterministic ordering: alphabetical by name (stable across runs / OSes)
 */

const fs = require('fs');
const path = require('path');

// Slash-command extractor — pulls `/foo`, `/bump-android-deps`, etc. out of
// markdown prose. Anchored so `/path/to/file` doesn't match (must look like
// a slash-command token: leading `/` immediately followed by an id with no
// trailing `/`).
const SLASH_COMMAND_RE = /(?<![A-Za-z0-9/_-])\/([a-z][a-z0-9-]*)(?![A-Za-z0-9/])/g;

const DEFAULTS = {
  maxFilesPerSurface: 50,    // hard cap on skill packs / command files we'll read
  maxBytesPerFile: 8 * 1024, // only need frontmatter + first heading
  docsScanMaxBytes: 32 * 1024, // CLAUDE.md / copilot-instructions.md top window
  walltimeMs: 250,           // bail rather than block dispatch on pathological FS
  maxAreas: 50,              // cap how many <area>/ dirs we'll probe for nested .claude
};

// Intent vocabulary — CLOSED. Each entry maps an intent to the keyword/regex
// list that must be searched in skill name + description + filename + first
// heading. Multi-intent is allowed: a skill matching multiple keyword sets
// will carry multiple intents.
//
// IMPORTANT: keep keyword lists tight. False-positives here become noise in
// every playbook's prompt. Use \b word boundaries to avoid matching inside
// unrelated tokens (e.g. `previewer` should NOT match `review`).
const INTENT_KEYWORDS = {
  review:        /\b(review|review-swarm|swarm|code-review|pr-review|constellation-review|code-reviewer|review-pr|cover-pr)\b/i,
  build:         /\b(build|scaffold|generate|bump|migrate|codemod|refactor|workspace|setup-steps)\b/i,
  fix:           /\b(fix|debug|triage|repro|regress|flake|deflake)\b/i,
  test:          /\b(test|tests|coverage|qa|validate|validation|integration-tests|unit-tests)\b/i,
  plan:          /\b(plan|prd|adr|design-doc|design|decompose|roadmap)\b/i,
  research:      /\b(research|investigate|gather-impact|explore|audit|analyze|reflect-and-grow)\b/i,
  deploy:        /\b(deploy|release|publish|buddy-build|rollout|ship|track-pr)\b/i,
  observability: /\b(telemetry|kusto|logs|monitoring|metrics|observability|dashboard|weekly-repo-summary)\b/i,
  // meta is intentionally absent from the heuristic — only authors who
  // explicitly tag a skill as `meta` via frontmatter get the meta intent.
};

const INTENT_VOCABULARY = Object.freeze([
  'review', 'build', 'fix', 'test', 'plan', 'research', 'deploy', 'observability', 'meta',
]);

function _now() { return Date.now(); }

function _safeReadHead(filePath, maxBytes) {
  let fd;
  try {
    fd = fs.openSync(filePath, 'r');
    const buf = Buffer.alloc(maxBytes);
    const n = fs.readSync(fd, buf, 0, maxBytes, 0);
    return buf.slice(0, n).toString('utf8');
  } catch { return ''; }
  finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* ignore */ } } }
}

function _parseFrontmatter(content) {
  const m = String(content || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
  if (!m) return {};
  const out = {};
  for (const line of m[1].split(/\r?\n/)) {
    const lm = line.match(/^([\w-]+):\s*(.*)$/);
    if (!lm) continue;
    out[lm[1].toLowerCase()] = lm[2].trim().replace(/^["']|["']$/g, '');
  }
  return out;
}

function _firstHeading(content) {
  const lines = String(content || '').split(/\r?\n/);
  for (const line of lines) {
    const m = line.match(/^#+\s+(.+?)\s*$/);
    if (m) return m[1].trim();
  }
  return '';
}

function _firstNonEmptyLine(content) {
  const lines = String(content || '').split(/\r?\n/);
  for (const line of lines) {
    const t = line.trim();
    if (t && !t.startsWith('---') && !t.startsWith('#')) return t;
  }
  return '';
}

function _truncate(s, max) {
  const text = String(s || '').trim();
  if (text.length <= max) return text;
  return text.slice(0, max - 1).trim() + '…';
}

// Parse an "intents:" or "tags:" frontmatter line into a normalized array of
// intent strings restricted to INTENT_VOCABULARY. Accepts either YAML inline
// list ("[a, b]"), bracket-less csv ("a, b, c"), or single value ("a").
function _parseExplicitIntents(fm) {
  const raw = (fm && (fm.intents || fm.tags || fm.intent || fm.tag)) || '';
  if (!raw) return [];
  const stripped = String(raw).replace(/^\[|\]$/g, '').trim();
  if (!stripped) return [];
  const parts = stripped.split(/[,\s]+/).map(s => s.trim().toLowerCase()).filter(Boolean);
  const out = [];
  for (const p of parts) {
    if (INTENT_VOCABULARY.includes(p) && !out.includes(p)) out.push(p);
  }
  return out;
}

// Classify a (name, description, extraText) tuple into the closed intent
// vocabulary. Always returns a (possibly empty) array. NEVER returns ['meta']
// by default — empty array is the honest signal that the heuristic didn't fire.
function classifyIntents({ name = '', description = '', extra = '', explicit = [] } = {}) {
  const hay = `${name} ${description} ${extra}`;
  const intents = [];
  // Honor explicit frontmatter first (preserves author intent).
  for (const i of explicit) if (!intents.includes(i)) intents.push(i);
  for (const intent of Object.keys(INTENT_KEYWORDS)) {
    if (intents.includes(intent)) continue;
    if (INTENT_KEYWORDS[intent].test(hay)) intents.push(intent);
  }
  return intents;
}

// Walk <baseDir>/.claude/skills/*\/SKILL.md
function _discoverSkillsAt(baseDir, projectPath, opts, deadline, originLabel) {
  const out = [];
  const skillsDir = path.join(baseDir, '.claude', 'skills');
  let entries;
  try { entries = fs.readdirSync(skillsDir, { withFileTypes: true }); } catch { return out; }
  let scanned = 0;
  for (const ent of entries) {
    if (scanned >= opts.maxFilesPerSurface) break;
    if (_now() > deadline) break;
    if (!ent.isDirectory()) continue;
    const skillPath = path.join(skillsDir, ent.name, 'SKILL.md');
    let stat;
    try { stat = fs.statSync(skillPath); } catch { continue; }
    if (!stat.isFile()) continue;
    scanned += 1;
    const head = _safeReadHead(skillPath, opts.maxBytesPerFile);
    if (!head) continue;
    const fm = _parseFrontmatter(head);
    const name = fm.name || ent.name;
    const desc = fm.description || '';
    const explicit = _parseExplicitIntents(fm);
    const heading = _firstHeading(head);
    const intents = classifyIntents({ name, description: desc, extra: heading, explicit });
    out.push({
      kind: 'skill',
      name: String(name),
      path: path.relative(projectPath, skillPath).split(path.sep).join('/'),
      oneLineDescription: _truncate(desc || heading || name, 200),
      intents,
      _originLabel: originLabel,
    });
  }
  return out;
}

// Walk <baseDir>/.claude/commands/*.md
function _discoverCommandsAt(baseDir, projectPath, opts, deadline, originLabel) {
  const out = [];
  const cmdDir = path.join(baseDir, '.claude', 'commands');
  let entries;
  try { entries = fs.readdirSync(cmdDir, { withFileTypes: true }); } catch { return out; }
  let scanned = 0;
  for (const ent of entries) {
    if (scanned >= opts.maxFilesPerSurface) break;
    if (_now() > deadline) break;
    if (!ent.isFile()) continue;
    if (!/\.md$/i.test(ent.name)) continue;
    scanned += 1;
    const base = ent.name.replace(/\.md$/i, '');
    const cmdPath = path.join(cmdDir, ent.name);
    const head = _safeReadHead(cmdPath, opts.maxBytesPerFile);
    const heading = _firstHeading(head);
    const intents = classifyIntents({ name: base, description: heading, extra: _firstNonEmptyLine(head) });
    out.push({
      kind: 'command',
      name: `/${base}`,
      path: path.relative(projectPath, cmdPath).split(path.sep).join('/'),
      oneLineDescription: _truncate(heading || _firstNonEmptyLine(head) || base, 200),
      intents,
      _originLabel: originLabel,
    });
  }
  return out;
}

function _extractSlashCommandsFromDoc(content) {
  const found = new Map();
  const text = String(content || '');
  let m;
  SLASH_COMMAND_RE.lastIndex = 0;
  while ((m = SLASH_COMMAND_RE.exec(text)) !== null) {
    const id = m[1];
    if (!found.has(id)) found.set(id, m.index);
  }
  return [...found.keys()];
}

function _discoverDocSlashCommands(projectPath, opts, deadline, alreadySeen) {
  const out = [];
  const docPaths = [
    path.join(projectPath, '.github', 'copilot-instructions.md'),
    path.join(projectPath, 'CLAUDE.md'),
  ];
  for (const docPath of docPaths) {
    if (_now() > deadline) break;
    let stat;
    try { stat = fs.statSync(docPath); } catch { continue; }
    if (!stat.isFile()) continue;
    const head = _safeReadHead(docPath, opts.docsScanMaxBytes);
    if (!head) continue;
    const ids = _extractSlashCommandsFromDoc(head);
    const relDoc = path.relative(projectPath, docPath).split(path.sep).join('/');
    for (const id of ids) {
      const fullName = `/${id}`;
      if (alreadySeen.has(fullName)) continue;
      // For doc-scrape slash-commands, only surface entries that classify to
      // at least one intent — keeps the discovery output bounded even when
      // CLAUDE.md / copilot-instructions.md mention many slash-commands.
      const intents = classifyIntents({ name: id });
      if (intents.length === 0) continue;
      alreadySeen.add(fullName);
      out.push({
        kind: 'slash-command',
        name: fullName,
        path: relDoc,
        oneLineDescription: `Documented entrypoint in ${relDoc}`,
        intents,
        _originLabel: 'docs',
      });
    }
  }
  return out;
}

// First-level subdirs that look like project "areas" (e.g. loop/, officemobile/,
// fluid-client-framework/). Returns dirent names only — caller probes for
// .claude/ underneath. Bounded by opts.maxAreas.
function _listAreas(projectPath, opts, deadline) {
  const out = [];
  let entries;
  try { entries = fs.readdirSync(projectPath, { withFileTypes: true }); } catch { return out; }
  // Sort for deterministic ordering across OSes.
  entries.sort((a, b) => a.name.localeCompare(b.name));
  for (const ent of entries) {
    if (out.length >= opts.maxAreas) break;
    if (_now() > deadline) break;
    if (!ent.isDirectory()) continue;
    // Skip dot-dirs (.git, .claude itself, .github, node_modules-like).
    if (ent.name.startsWith('.')) continue;
    if (ent.name === 'node_modules') continue;
    out.push(ent.name);
  }
  return out;
}

/**
 * @param {object} args
 * @param {string} args.projectPath — absolute path to the project worktree / checkout
 * @param {object} [args.opts] — override defaults (mostly for tests)
 * @returns {Array<{kind:string,name:string,path:string,oneLineDescription:string,intents:string[]}>}
 */
function discoverProjectSkills(args) {
  const projectPath = args && args.projectPath;
  if (!projectPath || typeof projectPath !== 'string') return [];
  try { if (!fs.statSync(projectPath).isDirectory()) return []; } catch { return []; }

  const opts = Object.assign({}, DEFAULTS, args.opts || {});
  const deadline = _now() + Math.max(1, opts.walltimeMs);

  const seenKey = new Set(); // dedupe across surfaces by `${kind}:${name}`
  const seenSlash = new Set();
  const all = [];

  // 1. Root-level skills.
  for (const entry of _discoverSkillsAt(projectPath, projectPath, opts, deadline, 'root')) {
    const key = `skill:${entry.name}`;
    if (seenKey.has(key)) continue;
    seenKey.add(key);
    all.push(entry);
  }

  // 2. Root-level commands.
  for (const entry of _discoverCommandsAt(projectPath, projectPath, opts, deadline, 'root')) {
    const key = `command:${entry.name}`;
    if (seenKey.has(key)) continue;
    seenKey.add(key);
    seenSlash.add(entry.name); // /foo from .claude/commands subsumes doc-mention
    all.push(entry);
  }

  // 3. Per-area (one level deep) skills + commands. This is how
  //    OCM/loop/officemobile organize their skill packs today and is the
  //    primary gap PR 82's root-only walk had.
  if (_now() <= deadline) {
    const areas = _listAreas(projectPath, opts, deadline);
    for (const area of areas) {
      if (_now() > deadline) break;
      const areaBase = path.join(projectPath, area);
      for (const entry of _discoverSkillsAt(areaBase, projectPath, opts, deadline, `area:${area}`)) {
        const key = `skill:${entry.name}`;
        if (seenKey.has(key)) continue;
        seenKey.add(key);
        all.push(entry);
      }
      if (_now() > deadline) break;
      for (const entry of _discoverCommandsAt(areaBase, projectPath, opts, deadline, `area:${area}`)) {
        const key = `command:${entry.name}`;
        if (seenKey.has(key)) continue;
        seenKey.add(key);
        seenSlash.add(entry.name);
        all.push(entry);
      }
    }
  }

  // 4. Doc-scrape slash-commands (only surface entries that classify to an
  //    intent — see _discoverDocSlashCommands).
  for (const entry of _discoverDocSlashCommands(projectPath, opts, deadline, seenSlash)) {
    const key = `slash-command:${entry.name}`;
    if (seenKey.has(key)) continue;
    seenKey.add(key);
    all.push(entry);
  }

  // Deterministic ordering: alphabetical by name, then by path as tiebreaker
  // so prompt rendering is stable across runs and OSes (readdir order varies).
  all.sort((a, b) => {
    if (a.name !== b.name) return a.name.localeCompare(b.name);
    return String(a.path || '').localeCompare(String(b.path || ''));
  });

  // Strip the internal _originLabel before returning (debugging-only).
  return all.map(({ _originLabel, ...rest }) => rest); // eslint-disable-line no-unused-vars
}

/**
 * Filter a discovery result list to a given intent set. An entry passes if
 * any of its intents is in the requested set. Empty-intent entries do NOT
 * pass an intent-filtered render (the honest signal from classifyIntents).
 *
 * @param {Array} entries — output of discoverProjectSkills()
 * @param {string[]|null} intents — intent vocabulary subset; null/empty/['*']
 *   means "all" (no filtering).
 * @returns {Array}
 */
function filterByIntents(entries, intents) {
  if (!Array.isArray(entries) || entries.length === 0) return [];
  if (!intents || intents.length === 0 || intents.includes('*')) {
    return entries.slice();
  }
  const want = new Set(intents);
  return entries.filter(e => Array.isArray(e.intents) && e.intents.some(i => want.has(i)));
}

/**
 * Format a (pre-filtered) discovery result list as a Markdown block ready to
 * splice into a playbook. Returns empty string when the list is empty so the
 * caller can no-op cleanly (no stray header, no blank padding lines).
 *
 * @param {Array} entries — pre-filtered output of discoverProjectSkills()
 * @returns {string}
 */
function renderProjectSkillsBlock(entries) {
  if (!Array.isArray(entries) || entries.length === 0) return '';
  const lines = [];
  lines.push('## Project skills (prefer these when applicable)');
  lines.push('');
  lines.push("This project ships purpose-built tooling for this kind of work. When your task is within scope of one of these skills, INVOKE IT FIRST and use its findings/output as the primary signal — your own work can then build on top of what the skill produced.");
  lines.push('');
  for (const e of entries) {
    const kindHint = e.kind === 'skill' ? `skill: \`${e.name}\``
      : e.kind === 'command' ? `\`${e.name}\``
      : `\`${e.name}\``;
    const pathHint = e.path ? ` (\`${e.path}\`)` : '';
    const desc = e.oneLineDescription ? ` — ${e.oneLineDescription}` : '';
    const intentTag = Array.isArray(e.intents) && e.intents.length > 0
      ? ` [intent: ${e.intents.join(', ')}]`
      : '';
    lines.push(`- ${kindHint}${pathHint}${desc}${intentTag}`);
  }
  lines.push('');
  lines.push("Record the skill outcome in your completion report's `meta.skill` block (`invoked` or `skipped` — see `docs/completion-reports.md`) so the engine can later measure skill-vs-first-principles signal.");
  return lines.join('\n');
}

// ── Backward-compatibility shims for PR #82 callers (W-mq16xtdx001a347e) ──
// These keep the discover-review-skills.js import surface working without
// requiring every consumer to migrate in lockstep.

function discoverReviewSkills(args) {
  // Old API: returns review-flavored entries only, without the new `intents`
  // field shape that callers might not understand. We still return entries
  // with `intents` populated (additive change — old callers ignore the field).
  return filterByIntents(discoverProjectSkills(args), ['review']);
}

function renderReviewSkillsBlock(entries) {
  // Old API renders the original PR-82 header copy verbatim so existing
  // tests/agents that grep on "## Project review skills" keep matching.
  if (!Array.isArray(entries) || entries.length === 0) return '';
  const lines = [];
  lines.push('## Project review skills (prefer these when applicable)');
  lines.push('');
  lines.push("This project ships purpose-built review tooling. When the diff under review is within scope of one of these skills, INVOKE IT FIRST and use its findings as the primary signal — your verdict can then be anchored to what the skill returned plus any gaps you spot on top.");
  lines.push('');
  for (const e of entries) {
    const kindHint = e.kind === 'skill' ? `skill: \`${e.name}\``
      : e.kind === 'command' ? `\`${e.name}\``
      : `\`${e.name}\``;
    const pathHint = e.path ? ` (\`${e.path}\`)` : '';
    const desc = e.oneLineDescription ? ` — ${e.oneLineDescription}` : '';
    lines.push(`- ${kindHint}${pathHint}${desc}`);
  }
  lines.push('');
  lines.push("Record the skill outcome in your completion report's `meta.review` block (`skillInvoked` or `skillSkipped` — see `docs/completion-reports.md`) so the engine can later measure skill-vs-first-principles signal.");
  return lines.join('\n');
}

module.exports = {
  discoverProjectSkills,
  filterByIntents,
  renderProjectSkillsBlock,
  classifyIntents,
  INTENT_VOCABULARY,
  // Backward-compat (PR #82) — see discover-review-skills.js shim.
  discoverReviewSkills,
  renderReviewSkillsBlock,
  // exported for tests
  _internal: {
    INTENT_KEYWORDS,
    SLASH_COMMAND_RE,
    DEFAULTS,
    _parseFrontmatter,
    _parseExplicitIntents,
    _extractSlashCommandsFromDoc,
    _listAreas,
  },
};
