#!/usr/bin/env bun

/**
 * Post-build mechanical checks.
 *
 * Usage:
 *   bun run scripts/post-build-check.ts                  # checks files from git diff
 *   bun run scripts/post-build-check.ts src/pages/Foo/   # checks specific paths
 *
 * Exit codes:
 *   0 — PASS (no findings or only Low)
 *   1 — FAIL (Critical or High findings)
 *   2 — WARN (only Medium findings)
 */

// ─── Types ───────────────────────────────────────────────────────────────────

type Severity = "critical" | "high" | "medium" | "low";

interface Check {
  id: string;
  description: string;
  severity: Severity;
  pattern: RegExp;
  /** Optional extra predicate: if present and returns false, the line is not reported. */
  predicate?: (line: string) => boolean;
  /** If true, skip lines that look like comments (line, block, or JSDoc continuations). */
  skipComments?: boolean;
}

interface Finding {
  checkId: string;
  description: string;
  severity: Severity;
  file: string;
  line: number;
  content: string;
}

// ─── Checks ──────────────────────────────────────────────────────────────────

const checks: Check[] = [
  // Security — Critical
  {
    id: "SEC-01",
    description: "dangerouslySetInnerHTML (XSS risk)",
    severity: "critical",
    pattern: /dangerouslySetInnerHTML/,
  },
  {
    id: "SEC-02",
    description: "eval() or new Function() (dynamic code execution)",
    severity: "critical",
    pattern: /\beval\s*\(|new\s+Function\s*\(/,
  },
  {
    id: "SEC-03",
    description: "Hardcoded secret (API key, token, password)",
    severity: "critical",
    pattern:
      /(?:api[_-]?key|apiKey|secret|password|token|authorization)\s*[:=]\s*['"][^'"]{8,}['"]/i,
    skipComments: true,
  },
  {
    id: "SEC-04",
    description: "AWS/OpenAI/Stripe secret key pattern",
    severity: "critical",
    pattern: /['"](?:sk-[a-zA-Z0-9]{20,}|AKIA[A-Z0-9]{16}|sk_(?:live|test)_[a-zA-Z0-9]{20,})['"]/,
  },

  {
    id: "SEC-05",
    description: "VITE_ env var may expose a secret (should be server-side only)",
    severity: "high",
    pattern: /import\.meta\.env\.VITE_(?:SECRET|API_KEY|TOKEN|PASSWORD|AUTH)/i,
    skipComments: true,
  },
  {
    id: "SEC-06",
    description: "Direct external fetch (use /api/ proxy instead)",
    severity: "high",
    pattern: /(?:fetch|axios\.(?:get|post|put|patch|delete))\s*\(\s*['"`]https?:\/\//,
    skipComments: true,
  },
  {
    id: "SEC-07",
    description: "Sensitive data in localStorage/sessionStorage",
    severity: "high",
    pattern: /(?:localStorage|sessionStorage)\.setItem\s*\(\s*['"](?:token|auth|secret|password|session|credential)/i,
    skipComments: true,
  },
  {
    id: "SEC-08",
    description: "Direct innerHTML assignment (XSS risk)",
    severity: "critical",
    pattern: /\.innerHTML\s*=/,
    skipComments: true,
  },
  {
    id: "SEC-09",
    description: 'target="_blank" without rel="noopener noreferrer" (reverse tabnabbing)',
    severity: "medium",
    pattern: /target\s*=\s*['"]_blank['"]/,
    predicate: (line) => {
      const relMatch = line.match(/\brel\s*=\s*['"]([^'"]*)['"]/i);
      if (!relMatch) return true;
      const tokens = relMatch[1].toLowerCase().split(/\s+/).filter(Boolean);
      return !(tokens.includes("noopener") && tokens.includes("noreferrer"));
    },
    skipComments: true,
  },

  // Imports — High
  {
    id: "IMP-01",
    description: "Direct MUI import (use material-hu instead)",
    severity: "high",
    pattern: /from\s+['"]@mui\/material/,
  },

  // Styling — High / Medium
  {
    id: "STY-01",
    description: "Hardcoded hex color (use theme tokens)",
    severity: "high",
    pattern: /['"]#[0-9a-fA-F]{3,8}['"]/,
    skipComments: true,
  },
  {
    id: "STY-02",
    description: "style prop instead of sx",
    severity: "medium",
    pattern: /\bstyle\s*=\s*\{\{/,
    skipComments: true,
  },

];

// ─── Helpers ─────────────────────────────────────────────────────────────────

const COLORS = {
  reset: "\x1b[0m",
  red: "\x1b[31m",
  yellow: "\x1b[33m",
  cyan: "\x1b[36m",
  gray: "\x1b[90m",
  bold: "\x1b[1m",
  green: "\x1b[32m",
};

function severityColor(s: Severity): string {
  switch (s) {
    case "critical":
      return COLORS.red;
    case "high":
      return COLORS.red;
    case "medium":
      return COLORS.yellow;
    case "low":
      return COLORS.gray;
  }
}

function severityLabel(s: Severity): string {
  return `${severityColor(s)}${s.toUpperCase()}${COLORS.reset}`;
}

function isCommentLine(line: string): boolean {
  const trimmed = line.trim();
  if (
    trimmed.startsWith("//") ||
    trimmed.startsWith("/*") ||
    trimmed.startsWith("*/")
  ) {
    return true;
  }
  // JSDoc continuation line: "* text" or a bare "*".
  // Require the space to avoid matching glob patterns like "**/*.ts"
  // or other uses of a leading asterisk.
  return trimmed === "*" || trimmed.startsWith("* ");
}

async function shell(cmd: string): Promise<string> {
  const proc = Bun.spawn(["sh", "-c", cmd], { stdout: "pipe", stderr: "pipe" });
  return new Response(proc.stdout).text();
}

async function getModifiedFiles(): Promise<string[]> {
  try {
    const [diff, staged, untracked] = await Promise.all([
      shell("git diff --name-only -- 'src/**/*.ts' 'src/**/*.tsx'"),
      shell("git diff --cached --name-only -- 'src/**/*.ts' 'src/**/*.tsx'"),
      shell("git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'"),
    ]);
    const all = `${diff}\n${staged}\n${untracked}`;
    return [...new Set(all.split("\n").filter((f) => f.trim().length > 0))];
  } catch (err) {
    throw new Error(
      `Failed to detect modified files via git. Aborting — security checks cannot be skipped.\n${err}`,
    );
  }
}

// ─── Main ────────────────────────────────────────────────────────────────────

async function run(): Promise<void> {
  const args = Bun.argv.slice(2);
  const files = args.length > 0 ? args : await getModifiedFiles();

  if (files.length === 0) {
    console.log(`${COLORS.green}No files to check.${COLORS.reset}`);
    process.exit(0);
  }

  console.log(
    `\n${COLORS.bold}Post-build checks${COLORS.reset} — ${files.length} file(s)\n`
  );

  const findings: Finding[] = [];

  for (const file of files) {
    const bunFile = Bun.file(file);
    if (!(await bunFile.exists())) continue;
    const content = await bunFile.text();

    const lines = content.split("\n");

    for (let i = 0; i < lines.length; i++) {
      const line = lines[i];

      for (const check of checks) {
        if (check.skipComments && isCommentLine(line)) continue;
        if (!check.pattern.test(line)) continue;
        if (check.predicate && !check.predicate(line)) continue;
        findings.push({
          checkId: check.id,
          description: check.description,
          severity: check.severity,
          file,
          line: i + 1,
          content: line.trim(),
        });
      }
    }
  }

  // ─── Report ──────────────────────────────────────────────────────────────

  if (findings.length === 0) {
    console.log(`${COLORS.green}${COLORS.bold}PASS${COLORS.reset} — no findings.\n`);
    process.exit(0);
  }

  // Group by file
  const byFile = new Map<string, Finding[]>();
  for (const f of findings) {
    const list = byFile.get(f.file) ?? [];
    list.push(f);
    byFile.set(f.file, list);
  }

  for (const [file, fileFindings] of byFile) {
    console.log(`${COLORS.bold}${file}${COLORS.reset}`);
    for (const f of fileFindings) {
      console.log(
        `  ${COLORS.gray}L${f.line}${COLORS.reset}  ${severityLabel(f.severity)}  [${f.checkId}] ${f.description}`
      );
      console.log(`       ${COLORS.gray}${f.content.substring(0, 120)}${COLORS.reset}`);
    }
    console.log();
  }

  // ─── Summary ─────────────────────────────────────────────────────────────

  const counts: Record<Severity, number> = { critical: 0, high: 0, medium: 0, low: 0 };
  for (const f of findings) counts[f.severity]++;

  const parts: string[] = [];
  if (counts.critical > 0) parts.push(`${COLORS.red}${counts.critical} critical${COLORS.reset}`);
  if (counts.high > 0) parts.push(`${COLORS.red}${counts.high} high${COLORS.reset}`);
  if (counts.medium > 0) parts.push(`${COLORS.yellow}${counts.medium} medium${COLORS.reset}`);
  if (counts.low > 0) parts.push(`${COLORS.gray}${counts.low} low${COLORS.reset}`);

  console.log(`${COLORS.bold}Summary:${COLORS.reset} ${parts.join(", ")} (${findings.length} total)\n`);

  if (counts.critical > 0 || counts.high > 0) {
    console.log(`${COLORS.red}${COLORS.bold}FAIL${COLORS.reset} — resolve Critical/High findings before commit.\n`);
    process.exit(1);
  }

  console.log(`${COLORS.yellow}${COLORS.bold}WARN${COLORS.reset} — only Medium/Low findings. Review before commit.\n`);
  process.exit(2);
}

run();
