/**
 * HKV Scanner — Hook Validator
 * Validates hooks.json format, script existence, event validity, timeouts.
 * Finding IDs: CA-HKV-NNN
 */

import { readTextFile, discoverConfigFiles } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { parseJson } from './lib/yaml-parser.mjs';
import { stat } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';

const SCANNER = 'HKV';

/** All valid hook events (as of April 2026) */
const VALID_EVENTS = new Set([
  'SessionStart', 'InstructionsLoaded', 'UserPromptSubmit',
  'PreToolUse', 'PermissionRequest', 'PermissionDenied',
  'PostToolUse', 'PostToolUseFailure',
  'SubagentStart', 'SubagentStop',
  'TaskCreated', 'TaskCompleted',
  'Stop', 'StopFailure',
  'TeammateIdle', 'Notification',
  'ConfigChange', 'CwdChanged', 'FileChanged',
  'WorktreeCreate', 'WorktreeRemove',
  'PreCompact', 'PostCompact',
  'Elicitation', 'ElicitationResult',
  'SessionEnd',
]);

/** Valid hook handler types */
const VALID_TYPES = new Set(['command', 'http', 'prompt', 'agent']);

/** Reasonable timeout range */
const MIN_TIMEOUT = 1000;
const MAX_TIMEOUT = 300000; // 5 minutes

/** v5 M5: hook scripts that flood stdout fragment the cache prefix on every
 * fire and slow Claude Code's UI. Static heuristic — count log lines. */
const VERBOSE_HOOK_LINE_THRESHOLD = 50;
const VERBOSE_HOOK_LINE_RX = /\b(?:console\.log|process\.stdout\.write)\s*\(/;

/**
 * Scan all hooks.json files and hook configs in settings.json.
 * @param {string} targetPath
 * @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery
 * @returns {Promise<object>}
 */
export async function scan(targetPath, discovery) {
  const start = Date.now();
  const hooksFiles = discovery.files.filter(f => f.type === 'hooks-json');
  const settingsFiles = discovery.files.filter(f => f.type === 'settings-json');
  const findings = [];
  let filesScanned = 0;

  // Scan standalone hooks.json files
  for (const file of hooksFiles) {
    const content = await readTextFile(file.absPath);
    if (!content) continue;
    filesScanned++;

    const parsed = parseJson(content);
    if (parsed === null) {
      findings.push(finding({
        scanner: SCANNER,
        severity: SEVERITY.critical,
        title: 'Invalid JSON in hooks.json',
        description: `${file.relPath} contains invalid JSON. All hooks in this file will be ignored.`,
        file: file.absPath,
        recommendation: 'Fix JSON syntax errors.',
        autoFixable: false,
      }));
      continue;
    }

    const hooksConfig = parsed.hooks || parsed;
    await validateHooksObject(hooksConfig, file, findings, dirname(file.absPath));
  }

  // Scan hooks in settings.json files
  for (const file of settingsFiles) {
    const content = await readTextFile(file.absPath);
    if (!content) continue;

    const parsed = parseJson(content);
    if (!parsed || !parsed.hooks) continue;
    filesScanned++;

    if (Array.isArray(parsed.hooks)) {
      // Already reported by settings-validator, skip here
      continue;
    }

    await validateHooksObject(parsed.hooks, file, findings, dirname(file.absPath));
  }

  if (hooksFiles.length === 0 && !settingsFiles.some(async f => {
    const c = await readTextFile(f.absPath);
    const p = c ? parseJson(c) : null;
    return p && p.hooks;
  })) {
    // No hooks at all — this is noted but not an error
    return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
  }

  return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
}

/**
 * Validate a hooks object (event key → handler array).
 */
async function validateHooksObject(hooks, file, findings, baseDir) {
  if (typeof hooks !== 'object' || Array.isArray(hooks)) {
    findings.push(finding({
      scanner: SCANNER,
      severity: SEVERITY.critical,
      title: 'Hooks must be an object with event keys',
      description: `${file.relPath}: hooks is ${Array.isArray(hooks) ? 'an array' : typeof hooks}. Expected object with event names as keys.`,
      file: file.absPath,
      recommendation: 'Use format: { "PreToolUse": [...], "Stop": [...] }',
      autoFixable: false,
    }));
    return;
  }

  for (const [event, handlers] of Object.entries(hooks)) {
    // Validate event name
    if (!VALID_EVENTS.has(event)) {
      findings.push(finding({
        scanner: SCANNER,
        severity: SEVERITY.high,
        title: 'Unknown hook event',
        description: `${file.relPath}: "${event}" is not a valid hook event. This hook will never fire.`,
        file: file.absPath,
        evidence: event,
        recommendation: `Valid events: ${[...VALID_EVENTS].slice(0, 8).join(', ')}... (26 total)`,
        autoFixable: false,
      }));
      continue;
    }

    if (!Array.isArray(handlers)) {
      findings.push(finding({
        scanner: SCANNER,
        severity: SEVERITY.high,
        title: 'Hook handlers must be an array',
        description: `${file.relPath}: handlers for "${event}" is not an array.`,
        file: file.absPath,
        evidence: `"${event}": ${typeof handlers}`,
        recommendation: `Use format: "${event}": [{ "hooks": [...] }]`,
        autoFixable: false,
      }));
      continue;
    }

    for (const handlerGroup of handlers) {
      // Validate matcher format
      if (handlerGroup.matcher !== undefined) {
        if (typeof handlerGroup.matcher === 'object') {
          findings.push(finding({
            scanner: SCANNER,
            severity: SEVERITY.high,
            title: 'Matcher must be a string, not an object',
            description: `${file.relPath}: "${event}" has a matcher that is an object. Matcher should be a simple string like "Bash" or "Edit|Write".`,
            file: file.absPath,
            evidence: JSON.stringify(handlerGroup.matcher),
            recommendation: 'Change matcher to a string: "matcher": "Bash"',
            autoFixable: true,
          }));
        }
      }

      if (!handlerGroup.hooks || !Array.isArray(handlerGroup.hooks)) {
        findings.push(finding({
          scanner: SCANNER,
          severity: SEVERITY.high,
          title: 'Missing hooks array in handler group',
          description: `${file.relPath}: "${event}" handler group is missing the "hooks" array.`,
          file: file.absPath,
          recommendation: 'Add "hooks": [{ "type": "command", "command": "..." }]',
          autoFixable: false,
        }));
        continue;
      }

      for (const hook of handlerGroup.hooks) {
        // Validate handler type
        if (!hook.type || !VALID_TYPES.has(hook.type)) {
          findings.push(finding({
            scanner: SCANNER,
            severity: SEVERITY.high,
            title: 'Invalid hook handler type',
            description: `${file.relPath}: "${event}" has handler with type "${hook.type || '(missing)'}".`,
            file: file.absPath,
            evidence: `type: "${hook.type || ''}"`,
            recommendation: `Valid types: ${[...VALID_TYPES].join(', ')}`,
            autoFixable: false,
          }));
        }

        // For command hooks, check script existence
        if (hook.type === 'command' && hook.command) {
          const scriptPath = extractScriptPath(hook.command, baseDir);
          if (scriptPath) {
            let scriptExists = false;
            try {
              await stat(scriptPath);
              scriptExists = true;
            } catch {
              findings.push(finding({
                scanner: SCANNER,
                severity: SEVERITY.high,
                title: 'Hook script not found',
                description: `${file.relPath}: "${event}" references script that does not exist.`,
                file: file.absPath,
                evidence: hook.command,
                recommendation: `Create the script at: ${scriptPath}`,
                autoFixable: false,
              }));
            }

            // v5 M5: count verbose stdout writes when the script exists.
            if (scriptExists) {
              const verboseCount = await countVerboseLines(scriptPath);
              if (verboseCount > VERBOSE_HOOK_LINE_THRESHOLD) {
                findings.push(finding({
                  scanner: SCANNER,
                  severity: SEVERITY.low,
                  title: 'Verbose hook output (loud script)',
                  description:
                    `${file.relPath}: "${event}" runs ${scriptPath.split('/').slice(-2).join('/')} ` +
                    `which has ${verboseCount} console.log / process.stdout.write lines ` +
                    `(>${VERBOSE_HOOK_LINE_THRESHOLD}). Loud hooks slow the UI and bloat ` +
                    'session transcripts on every fire.',
                  file: scriptPath,
                  evidence:
                    `console_log_or_stdout_lines=${verboseCount}; ` +
                    `threshold=${VERBOSE_HOOK_LINE_THRESHOLD}`,
                  recommendation:
                    'Trim debug logging from hooks. Keep hook output to actionable signals; ' +
                    'route verbose diagnostics to a log file instead of stdout.',
                  autoFixable: false,
                }));
              }
            }
          }
        }

        // Timeout validation
        if (hook.timeout !== undefined) {
          if (typeof hook.timeout !== 'number') {
            findings.push(finding({
              scanner: SCANNER,
              severity: SEVERITY.medium,
              title: 'Hook timeout must be a number',
              description: `${file.relPath}: "${event}" has non-numeric timeout.`,
              file: file.absPath,
              evidence: `timeout: ${JSON.stringify(hook.timeout)}`,
              recommendation: 'Set timeout to a number (milliseconds).',
              autoFixable: true,
            }));
          } else if (hook.timeout < MIN_TIMEOUT || hook.timeout > MAX_TIMEOUT) {
            findings.push(finding({
              scanner: SCANNER,
              severity: SEVERITY.low,
              title: 'Hook timeout outside recommended range',
              description: `${file.relPath}: "${event}" timeout is ${hook.timeout}ms. Recommended range: ${MIN_TIMEOUT}-${MAX_TIMEOUT}ms.`,
              file: file.absPath,
              evidence: `timeout: ${hook.timeout}`,
              recommendation: `Set timeout between ${MIN_TIMEOUT} and ${MAX_TIMEOUT}ms.`,
              autoFixable: false,
            }));
          }
        }
      }
    }
  }
}

/**
 * Count lines containing console.log( or process.stdout.write( in a hook script.
 * Static heuristic — does not execute the script.
 */
async function countVerboseLines(scriptPath) {
  const content = await readTextFile(scriptPath);
  if (!content) return 0;
  let count = 0;
  for (const line of content.split('\n')) {
    if (VERBOSE_HOOK_LINE_RX.test(line)) count++;
  }
  return count;
}

/**
 * Extract a filesystem path from a hook command string.
 * Handles ${CLAUDE_PLUGIN_ROOT} variable substitution.
 */
function extractScriptPath(command, baseDir) {
  // Extract the script path from common patterns:
  // "bash /path/to/script.sh"
  // "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/foo.mjs"
  const match = command.match(/(?:bash|node|sh)\s+(.+?)(?:\s|$)/);
  if (!match) return null;

  let scriptPath = match[1].trim();

  // Replace ${CLAUDE_PLUGIN_ROOT} with baseDir (best guess)
  scriptPath = scriptPath.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, resolve(baseDir, '..'));
  scriptPath = scriptPath.replace(/\$CLAUDE_PLUGIN_ROOT/g, resolve(baseDir, '..'));

  // Don't validate absolute paths that use env vars we can't resolve
  if (scriptPath.includes('$')) return null;

  return resolve(baseDir, scriptPath);
}
