#!/usr/bin/env node
// Build-time mirror of the runtime specialist drift assertion (Task 546).
//
// The runtime gate lives in
// platform/services/claude-session-manager/src/specialist-drift.ts and fires
// at manager boot on the Pi. By then the broken payload has already shipped,
// so a specialist `tools:` typo or stale-rename surfaces as a refused boot
// on the device — Task 544 was caught only after 1,311 cascading boot
// refusals on realagent-code. This script is the matching laptop-side gate:
// it walks every brand the installer can publish, builds the brand-effective
// tool surface (platform plugins minus `plugins.excluded`, plus premium
// bundles allowed by `shipsPremiumBundles`), and classifies every specialist
// `.md tools:` entry with the same rules `assertSpecialistDrift` uses.
//
// Defects classified `brand-excluded-plugin` do not fail — they are the
// runtime strip-list-eligible path the spawn already handles. Anything else
// (`unknown-plugin-namespace`, `unknown-tool-in-plugin`, `malformed-name`)
// exits non-zero with one defect line per `(brand, specialist, tool, reason)`
// in the same log shape the runtime emits, so the same grep substrings work
// on both surfaces.
//
// PURE-JS port of the TS classifier rather than a dynamic import: this gate
// runs in `prepublishOnly` *before* `npm run build`/`npm run bundle`, so the
// compiled session-manager dist is not yet available. The parity test at
// platform/services/claude-session-manager/src/__tests__/check-specialist-tool-surface.test.ts
// exercises identical fixtures through both this script and the live
// `assertSpecialistDrift` and asserts the defect lists agree; a future
// classifier change that lands in only one path fails CI there.

import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join, resolve, basename, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
  normalisePremiumGate,
  partitionBundles,
} from '../../packages/create-maxy-code/scripts/premium-bundle-gate.mjs';

const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const DEFAULT_MAXY_CODE_ROOT = resolve(SCRIPT_DIR, '..', '..');

function rootsFor(maxyCodeRoot) {
  return {
    PLATFORM_PLUGINS: join(maxyCodeRoot, 'platform', 'plugins'),
    PREMIUM_ROOT: join(maxyCodeRoot, 'premium-plugins'),
    SPECIALIST_TEMPLATES_DIR: join(maxyCodeRoot, 'platform', 'templates', 'specialists', 'agents'),
    BRANDS_DIR: join(maxyCodeRoot, 'brands'),
  };
}

// Mirrors CC_NATIVE_TOOLS in specialist-drift.ts. Listed exhaustively so a
// new CC-native tool forces an explicit ledger update in both surfaces.
const CC_NATIVE_TOOLS = new Set([
  'Read', 'Write', 'Edit', 'MultiEdit', 'Glob', 'Grep', 'Bash', 'BashOutput', 'KillShell',
  'Skill', 'WebSearch', 'WebFetch', 'Task', 'TodoWrite', 'NotebookEdit',
  'NotebookRead', 'AskUserQuestion',
]);

const PLUGIN_BRIDGE_PREFIX = 'mcp__plugin_';
const SKIP_AGENT_BASENAMES = new Set(['IDENTITY', 'SOUL', 'AGENTS', 'LEARNINGS']);

// Canonical long-prefix name constructor — mirrors canonicalToolName().
function canonicalToolName(plugin, tool) {
  return `mcp__plugin_${plugin}_${plugin}__${tool}`;
}
function qualifyShortToolName(plugin, tool) {
  return `mcp__${plugin}__${tool}`;
}

// Frontmatter walker for PLUGIN.md tools: + hidden: blocks. Returns
// {tools: [string], hidden: [string], hasTools: boolean}. Names only;
// the gate does not consult allowlist booleans because every declared
// tool contributes to the `all` surface.
function parsePluginManifest(rawManifest) {
  const fmMatch = rawManifest.match(/^---\r?\n([\s\S]*?)\r?\n---/);
  if (!fmMatch) return { tools: [], hidden: [], hasTools: false };
  const lines = fmMatch[1].split(/\r?\n/);
  const tools = [];
  const hidden = [];
  let mode = null; // 'tools' | 'hidden' | null
  let hasTools = false;
  for (const line of lines) {
    if (/^tools:\s*\[\s*\]\s*$/.test(line)) { mode = null; continue; }
    if (/^tools:\s*$/.test(line)) { mode = 'tools'; hasTools = true; continue; }
    if (/^hidden:\s*$/.test(line)) { mode = 'hidden'; continue; }
    if (/^[A-Za-z_]/.test(line)) { mode = null; continue; }
    if (mode === 'tools') {
      const nameLine = line.match(/^\s+-\s+name:\s*(.+?)\s*$/);
      if (nameLine) { tools.push(nameLine[1]); continue; }
    }
    if (mode === 'hidden') {
      const item = line.match(/^\s+-\s+(.+?)\s*$/);
      if (item) hidden.push(item[1]);
    }
  }
  return { tools, hidden, hasTools };
}

// Per-brand tool surface: walk platform/plugins (minus excludedPlugins) +
// allowed premium bundle PLUGIN.md (and any nested plugins/<sub>/PLUGIN.md)
// for the brand's shipsPremiumBundles allowlist. Returns:
//   { all: Set<string>, maxyPluginSet: Set<string> }
// `maxyPluginSet` is every plugin directory that registered any tool — the
// gate uses it to decide whether `mcp__plugin_<x>_<x>__` is a maxy tool
// (validate against `all`) or an upstream bridge (pass).
export function loadBrandToolSurface(maxyCodeRoot, excludedPlugins, shippedPremiumBundles) {
  const { PLATFORM_PLUGINS, PREMIUM_ROOT } = rootsFor(maxyCodeRoot);
  const all = new Set();
  const maxyPluginSet = new Set();
  const visit = (root, name) => {
    if (excludedPlugins.has(name)) return;
    const manifestPath = join(root, name, 'PLUGIN.md');
    if (!existsSync(manifestPath)) return;
    const { tools, hidden, hasTools } = parsePluginManifest(readFileSync(manifestPath, 'utf-8'));
    if (!hasTools && hidden.length === 0) return;
    maxyPluginSet.add(name);
    for (const t of tools) all.add(canonicalToolName(name, t));
    for (const h of hidden) all.add(canonicalToolName(name, h));
  };
  if (existsSync(PLATFORM_PLUGINS)) {
    for (const name of readdirSync(PLATFORM_PLUGINS).sort()) {
      try { if (!statSync(join(PLATFORM_PLUGINS, name)).isDirectory()) continue; } catch { continue; }
      visit(PLATFORM_PLUGINS, name);
    }
  }
  if (existsSync(PREMIUM_ROOT)) {
    for (const bundle of readdirSync(PREMIUM_ROOT).sort()) {
      if (!shippedPremiumBundles.has(bundle)) continue;
      try { if (!statSync(join(PREMIUM_ROOT, bundle)).isDirectory()) continue; } catch { continue; }
      visit(PREMIUM_ROOT, bundle);
      const subPluginsDir = join(PREMIUM_ROOT, bundle, 'plugins');
      try { if (!statSync(subPluginsDir).isDirectory()) continue; } catch { continue; }
      for (const sub of readdirSync(subPluginsDir).sort()) {
        try { if (!statSync(join(subPluginsDir, sub)).isDirectory()) continue; } catch { continue; }
        visit(subPluginsDir, sub);
      }
    }
  }
  return { all, maxyPluginSet };
}

// Per-brand specialist-agent dirs: platform templates ship with every brand;
// premium bundles' agents ship only when the bundle is allowlisted. Mirrors
// the runtime walker in claude-session-manager src/index.ts.
export function collectSpecialistDirs(maxyCodeRoot, shippedPremiumBundles) {
  const { PREMIUM_ROOT, SPECIALIST_TEMPLATES_DIR } = rootsFor(maxyCodeRoot);
  const dirs = [];
  if (existsSync(SPECIALIST_TEMPLATES_DIR)) dirs.push(SPECIALIST_TEMPLATES_DIR);
  if (existsSync(PREMIUM_ROOT)) {
    for (const bundle of readdirSync(PREMIUM_ROOT).sort()) {
      if (!shippedPremiumBundles.has(bundle)) continue;
      const bundleAgents = join(PREMIUM_ROOT, bundle, 'agents');
      try { if (statSync(bundleAgents).isDirectory()) dirs.push(bundleAgents); } catch { /* skip */ }
      const subPluginsDir = join(PREMIUM_ROOT, bundle, 'plugins');
      try {
        if (statSync(subPluginsDir).isDirectory()) {
          for (const sub of readdirSync(subPluginsDir).sort()) {
            const subAgents = join(subPluginsDir, sub, 'agents');
            try { if (statSync(subAgents).isDirectory()) dirs.push(subAgents); } catch { /* skip */ }
          }
        }
      } catch { /* skip */ }
    }
  }
  return dirs;
}

// Classifier — mirrors classifyTool() in specialist-drift.ts.
// Returns 'ok' or one of the SpecialistDriftDefect['reason'] strings.
function classifyTool(tool, allSet, maxyPluginSet, excludedPlugins) {
  if (CC_NATIVE_TOOLS.has(tool)) return 'ok';
  if (tool.startsWith(PLUGIN_BRIDGE_PREFIX)) {
    const m = tool.match(/^mcp__plugin_([A-Za-z0-9-]+)_([A-Za-z0-9-]+)__/);
    const plugin = m ? m[1] : null;
    if (plugin && maxyPluginSet.has(plugin)) {
      if (allSet.has(tool)) return 'ok';
      let hasNamespace = false;
      for (const t of allSet) {
        if (t.startsWith(`mcp__plugin_${plugin}_${plugin}__`)) { hasNamespace = true; break; }
      }
      if (hasNamespace) return 'unknown-tool-in-plugin';
      if (excludedPlugins.has(plugin)) return 'brand-excluded-plugin';
      return 'unknown-plugin-namespace';
    }
    // Plugin segment not present in the maxy set. Two cases:
    //   - The plugin exists on disk but is excluded for this brand
    //     (telegram on realagent-code) — strip-list-eligible.
    //   - True third-party bridge (Playwright) — upstream-owned, pass.
    if (plugin && excludedPlugins.has(plugin)) return 'brand-excluded-plugin';
    return 'ok';
  }
  if (allSet.has(tool)) return 'ok';
  const mcpMatch = tool.match(/^mcp__([^_]+(?:_[^_]+)*)__/);
  if (!mcpMatch) return 'malformed-name';
  const ns = mcpMatch[1];
  if (excludedPlugins.has(ns)) return 'brand-excluded-plugin';
  return 'unknown-plugin-namespace';
}

function parseSpecialistToolsLine(frontmatter) {
  const m = frontmatter.match(/^tools:\s*(.+)$/m);
  if (!m) return null;
  return m[1].trim();
}

function splitToolList(raw) {
  return raw.split(',').map(t => t.trim()).filter(t => t.length > 0);
}

// Mirrors assertSpecialistDrift(): walks the given dirs, returns
// {defects, inspected}. excludedPlugins gates the brand-excluded
// classification path; everything else fails.
export function assertSpecialistDrift(agentDirs, allSet, maxyPluginSet, excludedPlugins) {
  const defects = [];
  let inspected = 0;
  for (const dir of agentDirs) {
    let entries;
    try { entries = readdirSync(dir); } catch { continue; }
    for (const entry of entries.sort()) {
      const fullPath = join(dir, entry);
      let st;
      try { st = statSync(fullPath); } catch { continue; }
      if (!st.isFile() || !entry.endsWith('.md')) continue;
      const upperBase = basename(entry, '.md').toUpperCase();
      if (SKIP_AGENT_BASENAMES.has(upperBase)) continue;
      const raw = readFileSync(fullPath, 'utf-8');
      const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
      if (!fmMatch) continue;
      const toolsLine = parseSpecialistToolsLine(fmMatch[1]);
      if (!toolsLine) continue;
      inspected += 1;
      const specialistName = basename(entry, '.md');
      for (const tool of splitToolList(toolsLine)) {
        const verdict = classifyTool(tool, allSet, maxyPluginSet, excludedPlugins);
        if (verdict === 'ok') continue;
        defects.push({ specialistPath: fullPath, specialistName, tool, reason: verdict });
      }
    }
  }
  return { defects, inspected };
}

export function listBrands(maxyCodeRoot) {
  const { BRANDS_DIR } = rootsFor(maxyCodeRoot);
  if (!existsSync(BRANDS_DIR)) return [];
  return readdirSync(BRANDS_DIR).sort().filter(name => {
    try { return statSync(join(BRANDS_DIR, name, 'brand.json')).isFile(); } catch { return false; }
  });
}

export function listPremiumBundlesOnDisk(maxyCodeRoot) {
  const { PREMIUM_ROOT } = rootsFor(maxyCodeRoot);
  if (!existsSync(PREMIUM_ROOT)) return [];
  return readdirSync(PREMIUM_ROOT).filter(name => {
    if (name.startsWith('.')) return false;
    try { return statSync(join(PREMIUM_ROOT, name)).isDirectory(); } catch { return false; }
  });
}

// Pure-data run for a single brand. Returns
// {blocking: SpecialistDriftDefect[], stripList: SpecialistDriftDefect[], inspected: number}.
// The CLI wrapper turns blocking into stderr lines + exit-1; tests assert on
// the returned arrays directly.
export function runForBrand(maxyCodeRoot, brandName) {
  const { BRANDS_DIR } = rootsFor(maxyCodeRoot);
  const brandManifestPath = join(BRANDS_DIR, brandName, 'brand.json');
  const brand = JSON.parse(readFileSync(brandManifestPath, 'utf-8'));
  const excludedPlugins = new Set(brand.plugins?.excluded ?? []);
  const gate = normalisePremiumGate(brand.shipsPremiumBundles, brandManifestPath);
  const onDiskPremium = listPremiumBundlesOnDisk(maxyCodeRoot);
  const { shipped, missing } = partitionBundles(gate, onDiskPremium);
  if (missing.length > 0) {
    const err = new Error(`brand=${brandName} shipsPremiumBundles names missing on-disk bundles: ${missing.join(', ')}`);
    err.code = 'BRAND_MISSING_BUNDLE';
    throw err;
  }
  const shippedSet = new Set(shipped);
  const { all, maxyPluginSet } = loadBrandToolSurface(maxyCodeRoot, excludedPlugins, shippedSet);
  const agentDirs = collectSpecialistDirs(maxyCodeRoot, shippedSet);
  const { defects, inspected } = assertSpecialistDrift(agentDirs, all, maxyPluginSet, excludedPlugins);
  const blocking = defects.filter(d => d.reason !== 'brand-excluded-plugin');
  const stripList = defects.filter(d => d.reason === 'brand-excluded-plugin');
  return { blocking, stripList, inspected };
}

function main(maxyCodeRoot) {
  const brands = listBrands(maxyCodeRoot);
  if (brands.length === 0) {
    console.error('[specialist-tool-surface] no brands found under brands/');
    process.exit(1);
  }
  let totalBlocking = 0;
  for (const brandName of brands) {
    let result;
    try {
      result = runForBrand(maxyCodeRoot, brandName);
    } catch (err) {
      console.error(`[specialist-tool-surface] ${err.message}`);
      process.exit(1);
    }
    for (const d of result.blocking) {
      const rel = d.specialistPath.startsWith(maxyCodeRoot + '/')
        ? d.specialistPath.slice(maxyCodeRoot.length + 1)
        : d.specialistPath;
      console.error(`[specialist-tool-surface] specialist=${rel} tool=${d.tool} reason=${d.reason} brand=${brandName}`);
    }
    totalBlocking += result.blocking.length;
    if (result.blocking.length === 0) {
      console.log(`[specialist-tool-surface] ok brand=${brandName} inspected=${result.inspected}`);
    }
  }
  if (totalBlocking > 0) {
    console.error(`[specialist-tool-surface] FAIL: ${totalBlocking} defect(s) across ${brands.length} brand(s).`);
    console.error(`  Rename the tool in the listed file to a registered namespace, or move the plugin off the brand's plugins.excluded list.`);
    process.exit(1);
  }
}

// CLI entrypoint when invoked as `node check-specialist-tool-surface.mjs`.
// Tests import the module's exports directly and skip this branch.
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  const override = process.env.MAXY_CODE_ROOT;
  main(override ? resolve(override) : DEFAULT_MAXY_CODE_ROOT);
}
