#!/usr/bin/env node
// .claude/skills/regression/run.mjs
// Regression sweep orchestrator for the /regression skill.
// Design: docs/superpowers/specs/2026-05-16-regression-skill-design.md
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import process from 'node:process';

// ── pure: platform binary resolution ───────────────────────────────
export function resolveBinary(name, platform = process.platform) {
  return platform === 'win32' ? `bin\\${name}.exe` : name;
}

// ── pure: output header line ───────────────────────────────────────
export function buildHeader(tier) {
  const head = `─ /regression ${tier} `;
  return head + '─'.repeat(Math.max(3, 48 - head.length));
}

// ── pure: exit-code token ──────────────────────────────────────────
export function parseExit(label, code) {
  return `${label} ${code}`;
}

// ── pure: test-count parsers ───────────────────────────────────────
export function parsePest(stdout) {
  // pest --parallel emits a single JSON line: {"tool":"pest","result":...,"tests":N,"passed":N,"skipped":N,...}
  const jsonMatch = stdout.match(/\{"tool"\s*:\s*"pest"[^}]+\}/);
  if (jsonMatch) {
    try {
      const j = JSON.parse(jsonMatch[0]);
      const passed = Number(j.passed ?? 0);
      const skipped = Number(j.skipped ?? 0);
      const total = Number(j.tests ?? passed + skipped);
      const failed = total - passed - skipped;
      return `Pest ${total}/${passed}/${skipped}sk/${Math.max(0, failed)}`;
    } catch { /* fall through to regex */ }
  }
  const passed = Number(stdout.match(/(\d+)\s+passed/)?.[1] ?? 0);
  const skipped = Number(stdout.match(/(\d+)\s+skipped/)?.[1] ?? 0);
  const failed = Number(stdout.match(/(\d+)\s+failed/)?.[1] ?? 0);
  return `Pest ${passed + skipped + failed}/${passed}/${skipped}sk/${failed}`;
}

export function parseVitest(stdout) {
  const filesLine = stdout.match(/^.*Test Files.+$/m)?.[0] ?? '';
  const files = Number(filesLine.match(/(\d+)\s+passed/)?.[1] ?? 0);
  const line = stdout.match(/^\s*Tests\s+.+$/m)?.[0] ?? '';
  const passed = Number(line.match(/(\d+)\s+passed/)?.[1] ?? 0);
  const skipped = Number(line.match(/(\d+)\s+skipped/)?.[1] ?? 0);
  const failed = Number(line.match(/(\d+)\s+failed/)?.[1] ?? 0);
  return `Vitest ${files}f/${passed}/${skipped}sk/${failed}`;
}

// ── pure: content parsers ──────────────────────────────────────────
export function parseViteBuild(stdout) {
  const m = stdout.match(/built in ([\d.]+)\s*s/i);
  return `Vite build ${m ? m[1] : '?'}s`;
}

export function parseLarastan(stdout) {
  const m = stdout.match(/Found (\d+) error/i);
  return `Larastan ${m ? m[1] : 0}`;
}

export function parseGitleaks(stdout, code) {
  const commits = stdout.match(/(\d+)\s+commits?\s+scanned/i)?.[1] ?? '?';
  const leaks = code === 0
    ? '0'
    : (stdout.match(/(\d+)\s+leaks?\s+found/i)?.[1]
       ?? stdout.match(/leaks?\s+found:?\s*(\d+)/i)?.[1]
       ?? '≥1');
  return `gitleaks ${leaks}/${commits}`;
}

export function parseLychee(stdout) {
  const ok = stdout.match(/(\d+)\s+OK/)?.[1] ?? '?';
  const errors = stdout.match(/(\d+)\s+Errors?/i)?.[1] ?? '0';
  return `lychee ${ok}/${errors}`;
}

// ── pure: verdict ──────────────────────────────────────────────────
export function computeVerdict(results) {
  const skipped = results.filter((r) => r.skipped).map((r) => r.label);
  const failed = results
    .filter((r) => !r.skipped && r.code !== 0)
    .map((r) => r.label);
  if (skipped.length) return { verdict: 'RED-INCOMPLETE', exitCode: 1, failed, skipped };
  if (failed.length) return { verdict: 'RED', exitCode: 1, failed, skipped };
  return { verdict: 'GREEN', exitCode: 0, failed, skipped };
}

// ── pure: output formatting ────────────────────────────────────────
export function buildCanonicalLine(results) {
  return results.map((r) => r.token).join(' / ');
}

export function formatRow(r) {
  const mark = r.skipped ? '⚠' : r.code === 0 ? '✅' : '❌';
  const label = r.label.padEnd(14);
  const status = r.skipped
    ? 'SKIPPED — binary not found'
    : `${r.code}   ${(r.ms / 1000).toFixed(1)}s`;
  return `[${mark}] ${label}${status}`;
}

export function verdictLine(v, total) {
  if (v.verdict === 'GREEN') {
    return `🟢 GREEN — все ${total} проверок passed`;
  }
  if (v.verdict === 'RED-INCOMPLETE') {
    const tail = v.failed.length ? `; провал: ${v.failed.join(', ')}` : '';
    return `🟠 RED-INCOMPLETE — не прогналось: ${v.skipped.join(', ')}${tail}`;
  }
  return `🔴 RED — ${v.failed.length}/${total} failed: ${v.failed.join(', ')}`;
}

// ── data: checks registry ──────────────────────────────────────────
// Script-based checks carry `cmd`; binary-based checks carry `bin` + `argv`.
// `parse(combinedOutput, exitCode)` → canonical token. `cwd`: '.' = repo root,
// 'app' = the Laravel app. Execution order: quick checks first, then heavy.
export const CHECKS = [
  {
    id: 'pint', label: 'Pint', tiers: ['quick', 'full'], cwd: 'app',
    cmd: 'composer pint:test', parse: (_o, c) => parseExit('Pint', c),
  },
  {
    id: 'eslint', label: 'ESLint', tiers: ['quick', 'full'], cwd: 'app',
    cmd: 'npm run lint:vue', parse: (_o, c) => parseExit('ESLint', c),
  },
  {
    id: 'prettier', label: 'Prettier', tiers: ['quick', 'full'], cwd: 'app',
    cmd: 'npm run format:check', parse: (_o, c) => parseExit('Prettier', c),
  },
  {
    id: 'vue-tsc', label: 'vue-tsc', tiers: ['quick', 'full'], cwd: 'app',
    cmd: 'npm run type-check', parse: (_o, c) => parseExit('vue-tsc', c),
  },
  {
    id: 'markdownlint', label: 'markdownlint', tiers: ['quick', 'full'], cwd: '.',
    cmd: 'npm run lint:md', parse: (_o, c) => parseExit('markdownlint', c),
  },
  {
    id: 'cspell', label: 'cspell', tiers: ['quick', 'full'], cwd: '.',
    cmd: 'npm run spell', parse: (_o, c) => parseExit('cspell', c),
  },
  {
    id: 'larastan', label: 'Larastan', tiers: ['full'], cwd: 'app',
    cmd: 'composer stan', parse: (o) => parseLarastan(o),
  },
  {
    id: 'pest', label: 'Pest', tiers: ['full'], cwd: 'app',
    cmd: 'composer test:parallel', parse: (o) => parsePest(o),
  },
  {
    id: 'vitest', label: 'Vitest', tiers: ['full'], cwd: 'app',
    cmd: 'npm run test:vue', parse: (o) => parseVitest(o),
  },
  {
    id: 'vite-build', label: 'Vite build', tiers: ['full'], cwd: 'app',
    cmd: 'npm run build', parse: (o) => parseViteBuild(o),
  },
  {
    id: 'lychee', label: 'lychee', tiers: ['full'], cwd: '.',
    bin: 'lychee',
    argv: ['--config', '.lychee.toml', 'docs/**/*.md', 'db/**/*.md', '*.md'],
    parse: (o) => parseLychee(o),
  },
  {
    id: 'gitleaks', label: 'gitleaks', tiers: ['full'], cwd: '.',
    bin: 'gitleaks',
    argv: ['detect', '--source', '.', '--no-banner', '--config', '.gitleaks.toml', '--redact'],
    parse: (o, c) => parseGitleaks(o, c),
  },
];

// ── I/O: run one check ─────────────────────────────────────────────
function runCheck(check, repoRoot) {
  const cwd = check.cwd === '.' ? repoRoot : path.join(repoRoot, check.cwd);
  const start = Date.now();
  const skippedResult = (reason) => ({
    id: check.id, label: check.label, skipped: true, code: null,
    ms: Date.now() - start, token: `${check.label} SKIPPED`, stdout: '', stderr: reason,
  });

  let command;
  if (check.bin) {
    const bin = resolveBinary(check.bin);
    // bin/ executables: existsSync pre-check on Windows (the project ships
    // bin\gitleaks.exe / bin\lychee.exe; on POSIX they come from PATH).
    if (process.platform === 'win32' && !existsSync(path.join(repoRoot, bin))) {
      return skippedResult(`${bin} not found`);
    }
    command = [bin, ...check.argv].join(' ');
  } else {
    command = check.cmd;
  }

  const res = spawnSync(command, {
    cwd, shell: true, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
  });
  const ms = Date.now() - start;
  // ENOENT (POSIX missing binary), POSIX shell exit 127 ("command not found"),
  // or the Windows cmd.exe "is not recognized" message → SKIPPED.
  const notFound = (res.error && res.error.code === 'ENOENT')
    || res.status === 127
    || /is not recognized as an internal or external command/i.test(res.stderr ?? '');
  if (notFound) {
    return skippedResult(`command not found: ${command}`);
  }
  const stdout = res.stdout ?? '';
  const stderr = res.stderr ?? '';
  const code = res.status ?? 1;
  const token = check.parse(`${stdout}\n${stderr}`, code);
  return { id: check.id, label: check.label, skipped: false, code, ms, token, stdout, stderr };
}

// ── orchestrator ───────────────────────────────────────────────────
export function main(argv) {
  const tier = argv[0] ?? 'full';
  if (tier !== 'quick' && tier !== 'full') {
    process.stderr.write(
      `regression: unknown argument "${tier}". Usage: run.mjs [quick|full]\n`,
    );
    process.exitCode = 2;
    return;
  }
  const repoRoot = fileURLToPath(new URL('../../../', import.meta.url));
  const checks = CHECKS.filter((c) => c.tiers.includes(tier));

  process.stdout.write(`${buildHeader(tier)}\n`);
  const results = [];
  for (const check of checks) {
    const r = runCheck(check, repoRoot);
    results.push(r);
    process.stdout.write(`${formatRow(r)}\n`);
  }
  process.stdout.write(`${'─'.repeat(48)}\n`);
  process.stdout.write(`Canonical: ${buildCanonicalLine(results)}\n`);

  const v = computeVerdict(results);
  process.stdout.write(`VERDICT: ${verdictLine(v, results.length)}\n`);

  // Full output of failed checks, so failures are visible with file:line.
  for (const r of results) {
    if (!r.skipped && r.code !== 0) {
      process.stdout.write(`\n── ${r.label} output ──\n${r.stdout}\n${r.stderr}\n`);
    }
  }
  process.exitCode = v.exitCode;
}

// Run main only when executed directly (not when imported by run.test.mjs).
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
  main(process.argv.slice(2));
}
