import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";

import { collectTestIds, validateMatrix } from "./validate-matrix.mjs";

const ctx = {
  roles: new Set(["anonymous", "customer", "artist", "admin"]),
  routes: new Set(["/en/dashboard/releases", "/en/checkout"]),
  testids: new Set(["release-table", "release-title", "checkout-submit"]),
};

const validEntry = {
  route: "/en/dashboard/releases",
  roles: ["artist"],
  deniedRoles: { anonymous: "redirect", customer: "403" },
  elements: [
    { testid: "release-table", state: "populated" },
    { testid: "release-title", state: "required" },
  ],
};

test("accepts a complete matrix row", () => {
  assert.deepEqual(validateMatrix([validEntry], ctx), []);
});

test("rejects an empty matrix instead of passing vacuously", () => {
  assert.deepEqual(validateMatrix([], ctx), ["matrix is empty; an empty matrix asserts nothing"]);
});

test("rejects a testid that does not exist in source", () => {
  const entry = { ...validEntry, elements: [{ testid: "deal-submit", state: "enabled" }] };
  const errors = validateMatrix([entry], ctx);
  assert.ok(errors.some((e) => e.includes('testid "deal-submit" does not exist in source')));
  assert.ok(errors.some((e) => e.includes("silent no-op")));
});

test("rejects a route that does not exist", () => {
  const entry = { ...validEntry, route: "/vendor/deals/new" };
  const errors = validateMatrix([entry], ctx);
  assert.ok(errors.some((e) => e.includes("route is not a real route")));
});

test("rejects a role that does not exist in the project", () => {
  const entry = { ...validEntry, roles: ["vendor"] };
  const errors = validateMatrix([entry], ctx);
  assert.ok(errors.some((e) => e.includes('role "vendor" is not a real role')));
});

test("rejects 404 as a declared denial with the reason", () => {
  const entry = { ...validEntry, deniedRoles: { anonymous: "404" } };
  const errors = validateMatrix([entry], ctx);
  assert.ok(errors.some((e) => e.includes("404 is not proof of authorization")));
});

test("rejects an unknown denial mode", () => {
  const entry = { ...validEntry, deniedRoles: { anonymous: "maybe" } };
  const errors = validateMatrix([entry], ctx);
  assert.ok(errors.some((e) => e.includes('declares "maybe"')));
});

test("rejects a non-integer expectStatus", () => {
  const entry = { ...validEntry, expectStatus: "2xx" };
  const errors = validateMatrix([entry], ctx);
  assert.ok(errors.some((e) => e.includes("never a range")));
});

test("rejects an unknown state", () => {
  const entry = { ...validEntry, elements: [{ testid: "release-table", state: "probably-there" }] };
  const errors = validateMatrix([entry], ctx);
  assert.ok(errors.some((e) => e.includes('state "probably-there" is not one of')));
});

test("rejects a name with no explicit role", () => {
  const entry = { ...validEntry, elements: [{ name: "Submit", state: "enabled" }] };
  const errors = validateMatrix([entry], ctx);
  assert.ok(errors.some((e) => e.includes("name requires an explicit role")));
});

test("accepts role+name addressing a non-button element", () => {
  const entry = { ...validEntry, elements: [{ role: "textbox", name: "Title", state: "editable" }] };
  assert.deepEqual(validateMatrix([entry], ctx), []);
});

test("rejects an element with neither testid nor role+name", () => {
  const entry = { ...validEntry, elements: [{ state: "visible" }] };
  const errors = validateMatrix([entry], ctx);
  assert.ok(errors.some((e) => e.includes("needs a testid, or both role and name")));
});

test("rejects count on a non-countable state", () => {
  const entry = { ...validEntry, elements: [{ testid: "release-table", state: "visible", count: 2 }] };
  const errors = validateMatrix([entry], ctx);
  assert.ok(errors.some((e) => e.includes("count applies only to")));
});

test("accepts count on a countable state", () => {
  const entry = { ...validEntry, elements: [{ testid: "release-table", state: "present", count: 1 }] };
  assert.deepEqual(validateMatrix([entry], ctx), []);
});

test("rejects a duplicate route+role row", () => {
  const errors = validateMatrix([validEntry, validEntry], ctx);
  assert.ok(errors.some((e) => e.includes("duplicate row for route+role")));
});

test("rejects a row that asserts nothing", () => {
  const entry = { route: "/en/checkout", roles: ["customer"], elements: [] };
  const errors = validateMatrix([entry], ctx);
  assert.ok(errors.some((e) => e.includes("asserts nothing")));
});

test("collectTestIds finds every quoting form and skips node_modules", () => {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-"));
  fs.writeFileSync(path.join(dir, "a.tsx"), '<button data-testid="double" />\n');
  fs.writeFileSync(path.join(dir, "b.astro"), "<div data-testid='single' />\n");
  fs.writeFileSync(path.join(dir, "c.jsx"), '<i data-testid={"braced"} />\n');
  fs.mkdirSync(path.join(dir, "node_modules"));
  fs.writeFileSync(path.join(dir, "node_modules", "d.tsx"), '<b data-testid="vendored" />\n');
  const { ids, unreadable } = collectTestIds(dir);
  assert.deepEqual([...ids].sort(), ["braced", "double", "single"]);
  assert.deepEqual(unreadable, []);
});

test("collectTestIds reports an unreadable dir instead of silently under-collecting", () => {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-perm-"));
  fs.writeFileSync(path.join(dir, "a.tsx"), '<button data-testid="visible-one" />\n');
  const locked = path.join(dir, "locked");
  fs.mkdirSync(locked);
  fs.writeFileSync(path.join(locked, "b.tsx"), '<button data-testid="hidden-one" />\n');
  fs.chmodSync(locked, 0o000);
  try {
    const { ids, unreadable } = collectTestIds(dir);
    assert.deepEqual([...ids], ["visible-one"]);
    assert.deepEqual(unreadable, [locked]);
  } finally {
    fs.chmodSync(locked, 0o755);
  }
});

test("CLI refuses to validate when the source scan is incomplete", () => {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-incomplete-"));
  fs.writeFileSync(path.join(dir, "routes.txt"), "/en/x\n");
  fs.writeFileSync(path.join(dir, "roles.txt"), "admin\n");
  const matrix = path.join(dir, "matrix.json");
  fs.writeFileSync(
    matrix,
    JSON.stringify([{ route: "/en/x", roles: ["admin"], elements: [{ testid: "hidden-one", state: "visible" }] }]),
  );
  const locked = path.join(dir, "locked");
  fs.mkdirSync(locked);
  fs.writeFileSync(path.join(locked, "b.tsx"), '<button data-testid="hidden-one" />\n');
  fs.chmodSync(locked, 0o000);
  try {
    const result = spawnSync(
      process.execPath,
      [
        path.join(import.meta.dirname, "validate-matrix.mjs"),
        matrix,
        "--routes",
        path.join(dir, "routes.txt"),
        "--roles",
        path.join(dir, "roles.txt"),
        "--src",
        dir,
      ],
      { encoding: "utf8" },
    );
    assert.equal(result.status, 1);
    assert.match(result.stderr, /source scan is incomplete/);
    // The real failure must not masquerade as "testid does not exist".
    assert.doesNotMatch(result.stderr, /does not exist in source/);
  } finally {
    fs.chmodSync(locked, 0o755);
  }
});

test("CLI prints VALID for a good matrix and fails closed on a missing fact source", () => {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-cli-"));
  fs.writeFileSync(path.join(dir, "src.tsx"), '<table data-testid="release-table" />\n');
  fs.writeFileSync(path.join(dir, "routes.txt"), "/en/dashboard/releases\n");
  fs.writeFileSync(path.join(dir, "roles.txt"), "anonymous\nartist\n");
  const matrix = path.join(dir, "matrix.json");
  fs.writeFileSync(
    matrix,
    JSON.stringify([
      {
        route: "/en/dashboard/releases",
        roles: ["artist"],
        deniedRoles: { anonymous: "redirect" },
        elements: [{ testid: "release-table", state: "populated" }],
      },
    ]),
  );
  const script = path.join(import.meta.dirname, "validate-matrix.mjs");
  const ok = spawnSync(
    process.execPath,
    [script, matrix, "--routes", path.join(dir, "routes.txt"), "--roles", path.join(dir, "roles.txt"), "--src", dir],
    { encoding: "utf8" },
  );
  assert.equal(ok.status, 0);
  assert.match(ok.stdout, /VALID .*matrix\.json \(1 rows\)/);

  const missing = spawnSync(process.execPath, [script, matrix, "--src", dir], { encoding: "utf8" });
  assert.equal(missing.status, 2);
  assert.match(missing.stderr, /Every flag is required/);
});
