#!/usr/bin/env node

/**
 * PLH Scanner — Plugin Health
 * Validates Claude Code plugin structure, frontmatter, and cross-plugin coherence.
 * Finding IDs: CA-PLH-NNN
 * NOT included in scan-orchestrator — runs independently on plugin directories.
 * Zero external dependencies.
 */

import { readdir, stat, readFile } from 'node:fs/promises';
import { join, basename, resolve } from 'node:path';
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { parseFrontmatter } from './lib/yaml-parser.mjs';
import { humanizeFindings } from './lib/humanizer.mjs';

const SCANNER = 'PLH';

const REQUIRED_PLUGIN_JSON_FIELDS = ['name', 'description', 'version'];
const RECOMMENDED_CLAUDE_MD_SECTIONS = ['commands', 'agents', 'hooks'];
// Keys as they appear after yaml-parser normalizeKey (hyphens → underscores)
const REQUIRED_COMMAND_FRONTMATTER = [
  { key: 'name', display: 'name' },
  { key: 'description', display: 'description' },
  { key: 'model', display: 'model' },
  { key: 'allowed_tools', display: 'allowed-tools' },
];
const REQUIRED_AGENT_FRONTMATTER = [
  { key: 'name', display: 'name' },
  { key: 'description', display: 'description' },
  { key: 'model', display: 'model' },
  { key: 'tools', display: 'tools' },
];

/**
 * Discover plugins under a path.
 * Looks for .claude-plugin/plugin.json pattern.
 * @param {string} targetPath
 * @returns {Promise<string[]>} Array of plugin root directories
 */
export async function discoverPlugins(targetPath) {
  const plugins = [];

  // Check if targetPath itself is a plugin
  if (await isPlugin(targetPath)) {
    plugins.push(targetPath);
    return plugins;
  }

  // Look for plugins in subdirectories (marketplace layout: plugins/<name>/)
  try {
    const entries = await readdir(targetPath, { withFileTypes: true });
    for (const entry of entries) {
      if (!entry.isDirectory()) continue;
      const subDir = join(targetPath, entry.name);
      if (await isPlugin(subDir)) {
        plugins.push(subDir);
        continue;
      }
      // Also check one level deeper (plugins/<name>/ layout)
      try {
        const subEntries = await readdir(subDir, { withFileTypes: true });
        for (const subEntry of subEntries) {
          if (!subEntry.isDirectory()) continue;
          const deepDir = join(subDir, subEntry.name);
          if (await isPlugin(deepDir)) {
            plugins.push(deepDir);
          }
        }
      } catch { /* skip */ }
    }
  } catch { /* skip */ }

  return plugins;
}

/**
 * Check if a directory is a Claude Code plugin.
 * @param {string} dir
 * @returns {Promise<boolean>}
 */
async function isPlugin(dir) {
  try {
    await stat(join(dir, '.claude-plugin', 'plugin.json'));
    return true;
  } catch {
    return false;
  }
}

/**
 * Scan a single plugin for health issues.
 * @param {string} pluginDir - Plugin root directory
 * @returns {Promise<{ name: string, findings: object[], commandCount: number, agentCount: number }>}
 */
async function scanSinglePlugin(pluginDir) {
  const findings = [];
  const pluginName = basename(pluginDir);
  let commandCount = 0;
  let agentCount = 0;

  // 1. Validate plugin.json
  const pluginJsonPath = join(pluginDir, '.claude-plugin', 'plugin.json');
  try {
    const content = await readFile(pluginJsonPath, 'utf-8');
    let parsed;
    try {
      parsed = JSON.parse(content);
    } catch {
      findings.push(finding({
        scanner: SCANNER,
        severity: SEVERITY.critical,
        title: 'Invalid plugin.json',
        description: `plugin.json is not valid JSON in ${pluginName}`,
        file: pluginJsonPath,
      }));
      parsed = null;
    }

    if (parsed) {
      for (const field of REQUIRED_PLUGIN_JSON_FIELDS) {
        if (!parsed[field]) {
          findings.push(finding({
            scanner: SCANNER,
            severity: SEVERITY.high,
            title: `Missing required field in plugin.json: ${field}`,
            description: `Plugin "${pluginName}" plugin.json is missing required field "${field}"`,
            file: pluginJsonPath,
            recommendation: `Add "${field}" to plugin.json`,
          }));
        }
      }
    }
  } catch {
    findings.push(finding({
      scanner: SCANNER,
      severity: SEVERITY.critical,
      title: 'Missing plugin.json',
      description: `No .claude-plugin/plugin.json found in ${pluginName}`,
      file: pluginDir,
      recommendation: 'Create .claude-plugin/plugin.json with name, description, version',
    }));
  }

  // 2. Validate CLAUDE.md
  const claudeMdPath = join(pluginDir, 'CLAUDE.md');
  try {
    const content = await readFile(claudeMdPath, 'utf-8');
    const lower = content.toLowerCase();

    for (const section of RECOMMENDED_CLAUDE_MD_SECTIONS) {
      // Look for markdown table header or section header
      const hasSection = lower.includes(`## ${section}`) ||
        lower.includes(`| ${section}`) ||
        lower.includes(`|${section}`);
      if (!hasSection) {
        findings.push(finding({
          scanner: SCANNER,
          severity: SEVERITY.medium,
          title: `CLAUDE.md missing ${section} section`,
          description: `Plugin "${pluginName}" CLAUDE.md should have a ${section} table or section`,
          file: claudeMdPath,
          recommendation: `Add a "## ${section.charAt(0).toUpperCase() + section.slice(1)}" section with a table`,
        }));
      }
    }
  } catch {
    findings.push(finding({
      scanner: SCANNER,
      severity: SEVERITY.high,
      title: 'Missing CLAUDE.md',
      description: `Plugin "${pluginName}" has no CLAUDE.md`,
      file: pluginDir,
      recommendation: 'Create CLAUDE.md with Commands, Agents, and Hooks tables',
    }));
  }

  // 3. Validate commands frontmatter
  const commandsDir = join(pluginDir, 'commands');
  try {
    const entries = await readdir(commandsDir);
    const mdFiles = entries.filter(f => f.endsWith('.md'));
    commandCount = mdFiles.length;

    for (const file of mdFiles) {
      const filePath = join(commandsDir, file);
      const content = await readFile(filePath, 'utf-8');
      const { frontmatter } = parseFrontmatter(content);

      if (!frontmatter) {
        findings.push(finding({
          scanner: SCANNER,
          severity: SEVERITY.high,
          title: 'Command missing frontmatter',
          description: `Command "${file}" in plugin "${pluginName}" has no frontmatter`,
          file: filePath,
          recommendation: 'Add YAML frontmatter with name, description, model',
        }));
        continue;
      }

      for (const { key, display } of REQUIRED_COMMAND_FRONTMATTER) {
        if (!frontmatter[key]) {
          findings.push(finding({
            scanner: SCANNER,
            severity: SEVERITY.medium,
            title: `Command missing frontmatter field: ${display}`,
            description: `Command "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
            file: filePath,
            recommendation: `Add "${display}" to frontmatter`,
          }));
        }
      }
    }
  } catch { /* no commands dir */ }

  // 4. Validate agents frontmatter
  const agentsDir = join(pluginDir, 'agents');
  try {
    const entries = await readdir(agentsDir);
    const mdFiles = entries.filter(f => f.endsWith('.md'));
    agentCount = mdFiles.length;

    for (const file of mdFiles) {
      const filePath = join(agentsDir, file);
      const content = await readFile(filePath, 'utf-8');
      const { frontmatter } = parseFrontmatter(content);

      if (!frontmatter) {
        findings.push(finding({
          scanner: SCANNER,
          severity: SEVERITY.high,
          title: 'Agent missing frontmatter',
          description: `Agent "${file}" in plugin "${pluginName}" has no frontmatter`,
          file: filePath,
          recommendation: 'Add YAML frontmatter with name, description, model, tools',
        }));
        continue;
      }

      for (const { key, display } of REQUIRED_AGENT_FRONTMATTER) {
        if (!frontmatter[key]) {
          findings.push(finding({
            scanner: SCANNER,
            severity: SEVERITY.medium,
            title: `Agent missing frontmatter field: ${display}`,
            description: `Agent "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
            file: filePath,
            recommendation: `Add "${display}" to frontmatter`,
          }));
        }
      }
    }
  } catch { /* no agents dir */ }

  // 5. Validate hooks.json (if exists)
  const hooksJsonPath = join(pluginDir, 'hooks', 'hooks.json');
  try {
    const content = await readFile(hooksJsonPath, 'utf-8');
    try {
      const parsed = JSON.parse(content);
      if (!parsed.hooks || typeof parsed.hooks !== 'object') {
        findings.push(finding({
          scanner: SCANNER,
          severity: SEVERITY.high,
          title: 'Invalid hooks.json structure',
          description: `hooks.json in "${pluginName}" missing "hooks" object`,
          file: hooksJsonPath,
          recommendation: 'hooks.json must have a "hooks" key with event-keyed object',
        }));
      } else if (Array.isArray(parsed.hooks)) {
        findings.push(finding({
          scanner: SCANNER,
          severity: SEVERITY.high,
          title: 'hooks.json uses array instead of object',
          description: `hooks.json "hooks" in "${pluginName}" is an array — must be object with event keys`,
          file: hooksJsonPath,
          recommendation: 'Change hooks from array to object: { "PreToolUse": [...], ... }',
        }));
      }
    } catch {
      findings.push(finding({
        scanner: SCANNER,
        severity: SEVERITY.high,
        title: 'Invalid hooks.json',
        description: `hooks.json is not valid JSON in "${pluginName}"`,
        file: hooksJsonPath,
      }));
    }
  } catch { /* no hooks.json — fine */ }

  // 6. Check for unknown files in .claude-plugin/
  const pluginMetaDir = join(pluginDir, '.claude-plugin');
  try {
    const entries = await readdir(pluginMetaDir);
    const known = new Set(['plugin.json']);
    for (const entry of entries) {
      if (!known.has(entry)) {
        findings.push(finding({
          scanner: SCANNER,
          severity: SEVERITY.low,
          title: 'Unknown file in .claude-plugin/',
          description: `Unexpected file "${entry}" in .claude-plugin/ of "${pluginName}"`,
          file: join(pluginMetaDir, entry),
          recommendation: 'Only plugin.json should be in .claude-plugin/',
        }));
      }
    }
  } catch { /* skip */ }

  return { name: pluginName, findings, commandCount, agentCount };
}

/**
 * Scan one or more plugins and return aggregated results.
 * @param {string} targetPath - Plugin dir or marketplace root
 * @returns {Promise<object>} Scanner result
 */
export async function scan(targetPath) {
  const start = Date.now();
  resetCounter();

  const pluginDirs = await discoverPlugins(resolve(targetPath));

  if (pluginDirs.length === 0) {
    return scannerResult(SCANNER, 'ok', [
      finding({
        scanner: SCANNER,
        severity: SEVERITY.info,
        title: 'No plugins found',
        description: `No Claude Code plugins found under ${targetPath}`,
        recommendation: 'Ensure plugins have .claude-plugin/plugin.json',
      }),
    ], 0, Date.now() - start);
  }

  const allFindings = [];
  const pluginResults = [];

  for (const dir of pluginDirs) {
    const result = await scanSinglePlugin(dir);
    pluginResults.push(result);
    allFindings.push(...result.findings);
  }

  // Cross-plugin checks: command name conflicts
  const commandNames = new Map(); // name → plugin
  for (let idx = 0; idx < pluginResults.length; idx++) {
    const pr = pluginResults[idx];
    const commandsDir = join(pluginDirs[idx], 'commands');
    try {
      const entries = await readdir(commandsDir);
      for (const file of entries.filter(f => f.endsWith('.md'))) {
        const filePath = join(commandsDir, file);
        const content = await readFile(filePath, 'utf-8');
        const { frontmatter } = parseFrontmatter(content);
        if (frontmatter && frontmatter.name) {
          const cmdName = frontmatter.name;
          if (commandNames.has(cmdName)) {
            allFindings.push(finding({
              scanner: SCANNER,
              severity: SEVERITY.high,
              title: 'Cross-plugin command name conflict',
              description: `Command "${cmdName}" exists in both "${commandNames.get(cmdName)}" and "${pr.name}"`,
              file: filePath,
              recommendation: `Rename one of the conflicting commands to avoid ambiguity`,
            }));
          } else {
            commandNames.set(cmdName, pr.name);
          }
        }
      }
    } catch { /* no commands dir */ }
  }

  return scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start);
}

/**
 * Format a plugin health report for terminal output.
 * @param {object} scanResult - Scanner result from scan()
 * @param {Array<{ name: string, findings: object[], commandCount: number, agentCount: number }>} pluginResults
 * @returns {string}
 */
export function formatPluginHealthReport(pluginResults, crossPluginFindings) {
  const lines = [];
  lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
  lines.push(' Plugin Health Report');
  lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
  lines.push('');

  for (const p of pluginResults) {
    const issueCount = p.findings.length;
    const score = Math.max(0, 100 - issueCount * 10);
    const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
    const padding = '.'.repeat(Math.max(1, 25 - p.name.length));
    lines.push(` ${p.name} ${padding} ${grade} (${score})  ${p.commandCount} commands, ${p.agentCount} agents`);
  }

  lines.push('');

  if (crossPluginFindings.length > 0) {
    lines.push(` Cross-plugin issues (${crossPluginFindings.length}):`);
    for (const f of crossPluginFindings) {
      lines.push(`  - [${f.severity}] ${f.title}`);
    }
  } else {
    lines.push(' Cross-plugin issues (0):');
    lines.push(' (none)');
  }

  lines.push('');
  lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');

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

// --- CLI entry point ---
async function main() {
  const args = process.argv.slice(2);
  let targetPath = '.';
  let jsonMode = false;
  let rawMode = false;

  for (let i = 0; i < args.length; i++) {
    if (args[i] === '--json') {
      jsonMode = true;
    } else if (args[i] === '--raw') {
      rawMode = true;
    } else if (!args[i].startsWith('-')) {
      targetPath = args[i];
    }
  }

  const humanizedProgress = !jsonMode && !rawMode;
  process.stderr.write(humanizedProgress ? `Plugin Health v2.1.0\n` : `Plugin Health Scanner v2.1.0\n`);
  process.stderr.write(`Target: ${resolve(targetPath)}\n\n`);

  const result = await scan(targetPath);

  if (jsonMode || rawMode) {
    // --json and --raw both write the v5.0.0-shape result (byte-identical).
    process.stdout.write(JSON.stringify(result, null, 2) + '\n');
  } else {
    // Default mode humanizes finding titles before writing the brief summary.
    const findings = humanizeFindings(result.findings);
    const count = findings.length;
    process.stderr.write(`Findings: ${count}\n`);
    for (const f of findings) {
      process.stderr.write(`  [${f.severity}] ${f.title}\n`);
    }
  }
}

const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
  main().catch(err => {
    process.stderr.write(`Fatal: ${err.message}\n`);
    process.exit(3);
  });
}
