#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";

export const STATES = [
  "present",
  "absent",
  "visible",
  "enabled",
  "disabled",
  "editable",
  "readonly",
  "required",
  "optional",
  "empty",
  "populated",
];

export const DENIALS = ["redirect", "403"];

const COUNTABLE = new Set(["present", "absent"]);
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".astro", ".worktrees"]);
const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".astro", ".vue", ".svelte"]);
const TESTID_PATTERN = /data-testid\s*=\s*(?:"([^"]+)"|'([^']+)'|\{\s*["'`]([^"'`]+)["'`]\s*\})/g;

export function collectTestIds(srcDir) {
  const found = new Set();
  const unreadable = [];
  const walk = (dir) => {
    let entries;
    try {
      entries = fs.readdirSync(dir, { withFileTypes: true });
    } catch {
      unreadable.push(dir);
      return;
    }
    for (const entry of entries) {
      const full = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        if (!SKIP_DIRS.has(entry.name)) walk(full);
        continue;
      }
      if (!SOURCE_EXT.has(path.extname(entry.name))) continue;
      let text;
      try {
        text = fs.readFileSync(full, "utf8");
      } catch {
        unreadable.push(full);
        continue;
      }
      for (const match of text.matchAll(TESTID_PATTERN)) {
        found.add(match[1] ?? match[2] ?? match[3]);
      }
    }
  };
  walk(srcDir);
  // An unreadable path means the testid set is INCOMPLETE, which would report a real
  // testid as nonexistent — a false blocker. Surface it; never scan-and-swallow.
  return { ids: found, unreadable };
}

export function validateMatrix(entries, ctx) {
  const errors = [];
  const { roles, routes, testids } = ctx;

  if (!Array.isArray(entries)) return ["matrix must be a JSON array"];
  if (entries.length === 0) return ["matrix is empty; an empty matrix asserts nothing"];

  const seen = new Set();

  entries.forEach((entry, index) => {
    const at = `entry ${index}${entry?.route ? ` (${entry.route})` : ""}`;

    if (typeof entry?.route !== "string" || !entry.route.startsWith("/")) {
      errors.push(`${at}: route must be an absolute path string`);
      return;
    }
    if (!routes.has(entry.route)) {
      errors.push(`${at}: route is not a real route; a matrix row on a nonexistent route can never fail`);
    }

    if (!Array.isArray(entry.roles) || entry.roles.length === 0) {
      errors.push(`${at}: roles must be a non-empty array`);
    } else {
      for (const role of entry.roles) {
        if (!roles.has(role)) errors.push(`${at}: role "${role}" is not a real role in this project`);
        const key = `${entry.route}::${role}`;
        if (seen.has(key)) errors.push(`${at}: duplicate row for route+role "${key}"`);
        seen.add(key);
      }
    }

    if (entry.deniedRoles !== undefined) {
      if (typeof entry.deniedRoles !== "object" || entry.deniedRoles === null || Array.isArray(entry.deniedRoles)) {
        errors.push(`${at}: deniedRoles must be an object mapping role to ${DENIALS.join(" | ")}`);
      } else {
        for (const [role, denial] of Object.entries(entry.deniedRoles)) {
          if (!roles.has(role)) errors.push(`${at}: denied role "${role}" is not a real role in this project`);
          if (String(denial) === "404") {
            errors.push(
              `${at}: denied role "${role}" declares 404; 404 is not proof of authorization — a deleted route returns 404 forever and the row passes. Declare ${DENIALS.join(" or ")}.`,
            );
          } else if (!DENIALS.includes(denial)) {
            errors.push(`${at}: denied role "${role}" declares "${denial}"; must be one of ${DENIALS.join(" | ")}`);
          }
        }
      }
    }

    if (entry.expectStatus !== undefined && !Number.isInteger(entry.expectStatus)) {
      errors.push(`${at}: expectStatus must be an exact integer status, never a range`);
    }

    const elements = entry.elements ?? [];
    if (!Array.isArray(elements)) {
      errors.push(`${at}: elements must be an array`);
      return;
    }
    if (elements.length === 0 && entry.deniedRoles === undefined) {
      errors.push(`${at}: no elements and no deniedRoles; this row asserts nothing`);
    }

    elements.forEach((el, elIndex) => {
      const elAt = `${at} element ${elIndex}`;

      const hasTestid = typeof el?.testid === "string" && el.testid.length > 0;
      const hasRoleName = typeof el?.role === "string" && typeof el?.name === "string";

      if (!hasTestid && !hasRoleName) {
        errors.push(`${elAt}: needs a testid, or both role and name`);
      }
      if (typeof el?.name === "string" && typeof el?.role !== "string") {
        errors.push(`${elAt}: name requires an explicit role; an unscoped name cannot address inputs, tables, or regions`);
      }
      if (hasTestid && !testids.has(el.testid)) {
        errors.push(
          `${elAt}: testid "${el.testid}" does not exist in source; a row pointing at a nonexistent testid is a silent no-op that always passes`,
        );
      }

      if (!STATES.includes(el?.state)) {
        errors.push(`${elAt}: state "${el?.state}" is not one of ${STATES.join(" | ")}`);
      }
      if (el?.count !== undefined) {
        if (!Number.isInteger(el.count) || el.count < 0) {
          errors.push(`${elAt}: count must be a non-negative integer`);
        }
        if (!COUNTABLE.has(el?.state)) {
          errors.push(`${elAt}: count applies only to ${[...COUNTABLE].join(" | ")}`);
        }
      }
    });
  });

  return errors;
}

function readRoutes(file) {
  const raw = fs.readFileSync(file, "utf8").trim();
  if (raw.startsWith("[")) return new Set(JSON.parse(raw));
  return new Set(raw.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#")));
}

function readRoles(file) {
  const raw = fs.readFileSync(file, "utf8").trim();
  if (raw.startsWith("[")) return new Set(JSON.parse(raw));
  return new Set(raw.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#")));
}

function flag(args, name) {
  const index = args.indexOf(name);
  return index >= 0 ? args[index + 1] : undefined;
}

if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
  const args = process.argv.slice(2);
  const matrixFile = args[0];
  const routesFile = flag(args, "--routes");
  const rolesFile = flag(args, "--roles");
  const srcDir = flag(args, "--src");

  if (!matrixFile || !routesFile || !rolesFile || !srcDir) {
    console.error(
      "usage: validate-matrix.mjs <matrix.json> --routes <routes-file> --roles <roles-file> --src <source-dir>\n" +
        "Every flag is required. Each one is a fact source the matrix is checked against;\n" +
        "omitting one would silently disable the check it powers.",
    );
    process.exit(2);
  }

  let entries;
  try {
    entries = JSON.parse(fs.readFileSync(matrixFile, "utf8"));
  } catch (error) {
    console.error(`INVALID ${matrixFile}\n  - unparseable JSON: ${error.message}`);
    process.exit(1);
  }

  const { ids, unreadable } = collectTestIds(srcDir);

  if (unreadable.length > 0) {
    console.error(`INVALID ${matrixFile}`);
    console.error(
      `  - source scan is incomplete: ${unreadable.length} path(s) unreadable, so a real testid could be reported missing.`,
    );
    for (const dir of unreadable.slice(0, 10)) console.error(`      ${dir}`);
    console.error(`  - fix permissions, or point --src at a readable subtree.`);
    process.exit(1);
  }

  const errors = validateMatrix(entries, {
    roles: readRoles(rolesFile),
    routes: readRoutes(routesFile),
    testids: ids,
  });

  if (errors.length > 0) {
    console.error(`INVALID ${matrixFile}`);
    for (const error of errors) console.error(`  - ${error}`);
    process.exit(1);
  }

  console.log(`VALID ${matrixFile} (${entries.length} rows)`);
}
