#!/usr/bin/env node
// Task 581 — bundle-time existence check for `references/<file>.md` paths
// cited in PLUGIN.md, SKILL.md, and specialist agent template bodies.
//
// Why: the `plugin-read` tool resolves `pluginName + file` against
// `<platform>/plugins/<pluginName>/<file>`. When a doc body cites
// `references/foo.md` (or `plugins/<other>/references/foo.md`) and that file
// does not exist, the agent burns a tool call only to receive
// "Plugin file not found". This module fails the bundle before such a payload
// is assembled.
//
// Two citation shapes are extracted:
//   1. Bare `references/<file>.md` — resolves under the citing file's owning
//      plugin directory (PLUGIN.md and skills/<slug>/SKILL.md only; specialist
//      templates have no plugin owner, so the bare shape there is itself a
//      violation).
//   2. `plugins/<name>/references/<file>.md` (optionally prefixed by
//      `platform/`) — resolves at `<platformRoot>/plugins/<name>/references/
//      <file>.md`.

import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { resolve, relative, join, dirname } from "node:path";
import { pathToFileURL } from "node:url";

// Citation shapes captured (all must end in `references/<file>.md`):
//   references/<file>.md
//   plugins/<name>/references/<file>.md
//   plugins/<name>/skills/<slug>/references/<file>.md
//   skills/<slug>/references/<file>.md
//   platform/<any of the above starting with plugins/>
//
// The lookbehind excludes [a-z0-9_/-] so the capture cannot start mid-path
// (`foo/references/bar.md` does not match). `/` is in the excluded set —
// that prevents a mid-path `references/...` from being captured without its
// leading `skills/<slug>/` or `plugins/<name>/` prefix. Every legitimate
// citation root we want to recognise is named explicitly in the alternation.
const REF_PATTERN =
  /(?:^|[^a-z0-9_/-])((?:platform\/)?(?:plugins\/[a-z0-9][a-z0-9-]*\/)?(?:skills\/[a-z0-9][a-z0-9-]*\/)?references\/[a-z0-9][a-z0-9-]*\.md)/g;

// Walk `dir` recursively, collecting every file whose basename matches `predicate`.
function walk(dir, predicate, out = []) {
  if (!existsSync(dir)) return out;
  let entries;
  try {
    entries = readdirSync(dir);
  } catch {
    return out;
  }
  for (const entry of entries) {
    const full = join(dir, entry);
    let st;
    try {
      st = statSync(full);
    } catch {
      continue;
    }
    if (st.isDirectory()) {
      walk(full, predicate, out);
    } else if (predicate(entry, full)) {
      out.push(full);
    }
  }
  return out;
}

// Plugin root for a citing file, or null if there is none (specialist
// templates). For SKILL.md, walks dirname up past `skills/<slug>/` to the
// ancestor that contains the `skills` segment as a direct child.
function owningPluginRoot(filePath) {
  if (filePath.endsWith("/PLUGIN.md")) {
    return dirname(filePath);
  }
  if (filePath.endsWith("/SKILL.md")) {
    let cursor = dirname(filePath);
    while (cursor && cursor !== "/" && cursor !== ".") {
      const parts = cursor.split("/");
      if (parts[parts.length - 1] === "skills") {
        return dirname(cursor);
      }
      cursor = dirname(cursor);
    }
  }
  return null;
}

// Directories to resolve a bare `references/<file>.md` citation against.
// Returns an array of candidate directories; the citation passes if the file
// exists under ANY of them. Both candidate paths for SKILL.md are reachable
// via `plugin-read` — the agent may construct `file=references/foo.md`
// (plugin-root form) or `file=skills/<slug>/references/foo.md` (skill-local
// form), depending on how the plugin organises its references.
//
//   PLUGIN.md       — plugin-root only.       → [<plugin-root>]
//   SKILL.md        — skill-local OR plugin-root.
//                                              → [<skill-dir>, <plugin-root>]
//   specialist template — no owning context.   → []
function bareReferenceContextDirs(filePath) {
  if (filePath.endsWith("/PLUGIN.md")) {
    return [dirname(filePath)];
  }
  if (filePath.endsWith("/SKILL.md")) {
    const skillDir = dirname(filePath);
    const pluginRoot = owningPluginRoot(filePath);
    return pluginRoot ? [skillDir, pluginRoot] : [skillDir];
  }
  return [];
}

// Return every candidate absolute path a citation could resolve to. The
// citation passes the existence check if ANY candidate exists. Returns an
// empty array if the citation has no resolvable shape (bare reference cited
// from a non-plugin file).
function candidateTargets({ raw, sourcePath, platformRoot }) {
  // Strip an optional leading `platform/` so `platform/plugins/<name>/...`
  // and `plugins/<name>/...` resolve identically.
  const normalised = raw.replace(/^platform\//, "");

  // Cross-plugin (and cross-plugin-sub-skill) references resolve at the
  // platform root regardless of the citing file's owning plugin.
  if (normalised.startsWith("plugins/")) {
    return [resolve(platformRoot, normalised)];
  }

  // `skills/<slug>/references/<file>.md` resolves under the citing file's
  // owning plugin directory. Used by PLUGIN.md bodies that index references
  // owned by sub-skills (e.g. premium-plugins/teaching/PLUGIN.md).
  if (normalised.startsWith("skills/")) {
    const pluginRoot = owningPluginRoot(sourcePath);
    return pluginRoot ? [resolve(pluginRoot, normalised)] : [];
  }

  // Bare `references/<file>.md`.
  const ctxDirs = bareReferenceContextDirs(sourcePath);
  return ctxDirs.map(d => resolve(d, normalised));
}

function scanFile(filePath, { platformRoot, repoRoot }) {
  let body;
  try {
    body = readFileSync(filePath, "utf-8");
  } catch {
    return { violations: [], citations: 0 };
  }
  const violations = [];
  let citations = 0;
  const lines = body.split("\n");
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    REF_PATTERN.lastIndex = 0;
    let m;
    while ((m = REF_PATTERN.exec(line)) !== null) {
      const raw = m[1];
      // 1-based column, pointing at the start of the captured citation.
      const column = (m.index + m[0].indexOf(raw)) + 1;
      citations++;
      const candidates = candidateTargets({ raw, sourcePath: filePath, platformRoot });
      if (candidates.length === 0) {
        violations.push({
          source: relative(repoRoot, filePath),
          line: i + 1,
          column,
          target: "<no plugin owner — bare references/ cited from non-plugin file>",
          raw,
        });
        continue;
      }
      if (!candidates.some(p => existsSync(p))) {
        // Report the most informative candidate — for SKILL.md citations the
        // first candidate is the skill-local path; if both exist as candidates,
        // listing both makes the fix unambiguous.
        const targetMsg = candidates.length === 1
          ? candidates[0]
          : `none of [${candidates.join(", ")}]`;
        violations.push({
          source: relative(repoRoot, filePath),
          line: i + 1,
          column,
          target: targetMsg,
          raw,
        });
      }
    }
  }
  return { violations, citations };
}

export function runPluginReferenceCheck({ platformRoot, premiumRoot }) {
  const repoRoot = resolve(platformRoot, "..");
  const sources = [];

  // platform: every PLUGIN.md and every SKILL.md under plugins/
  const platformPlugins = resolve(platformRoot, "plugins");
  walk(
    platformPlugins,
    (name) => name === "PLUGIN.md" || name === "SKILL.md",
    sources,
  );

  // platform: every specialist template
  const specialistDir = resolve(platformRoot, "templates", "specialists", "agents");
  walk(specialistDir, (name) => name.endsWith(".md"), sources);

  // premium: every PLUGIN.md and SKILL.md under any bundle (standalone or sub)
  if (premiumRoot && existsSync(premiumRoot)) {
    walk(
      premiumRoot,
      (name) => name === "PLUGIN.md" || name === "SKILL.md",
      sources,
    );
  }

  const violations = [];
  let citations = 0;
  for (const src of sources) {
    const r = scanFile(src, { platformRoot, repoRoot });
    violations.push(...r.violations);
    citations += r.citations;
  }

  return {
    ok: violations.length === 0,
    violations,
    scanned: sources.length,
    citations,
  };
}

// CLI entry — `node check-plugin-references.mjs [platformRoot] [premiumRoot]`.
// Use `pathToFileURL` so the self-detect survives paths with spaces or non-
// ASCII characters; the string-concat form fails because `process.argv[1]` is
// raw while `import.meta.url` is percent-encoded. When this module is loaded
// via `import` (e.g. from `bundle.js`), `process.argv[1]` may still be set
// to the importing script, in which case the URLs differ and the CLI block
// is skipped.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  const defaultPlatform = resolve(import.meta.dirname, "..");
  const defaultPremium = resolve(import.meta.dirname, "..", "..", "premium-plugins");
  const platformRoot = process.argv[2] ? resolve(process.argv[2]) : defaultPlatform;
  const premiumRoot = process.argv[3] ? resolve(process.argv[3]) : defaultPremium;
  const r = runPluginReferenceCheck({ platformRoot, premiumRoot });
  if (!r.ok) {
    for (const v of r.violations) {
      console.error(`[bundle-validator] reference not found`);
      console.error(`  source: ${v.source}:${v.line}:${v.column}`);
      console.error(`  cites:  ${v.raw}`);
      console.error(`  target: ${v.target}`);
    }
    console.error(
      `FATAL: ${r.violations.length} broken reference citation(s) across ${r.scanned} scanned file(s) (${r.citations} citations).`,
    );
    process.exit(1);
  }
  console.log(
    `[bundle-validator] plugin references ok scanned=${r.scanned} citations=${r.citations}`,
  );
}
