// Deterministic, render-free check engine. Given parsed HTML (and optionally a
// rendered DOM, robots.txt, response headers, a CSS model, and computed facts
// from a Playwright pass), it returns objective per-check verdicts. The model
// never decides these; the code does. Each check maps to the audit sub-agent that
// owns the dimension so the orchestrator can hand an agent its ground truth.
//
// Pure Node standard library. No network, no execution of page content.

import { parse, queryAll, first, textContent, styleMap, resolveBackgroundStr, ancestors, walk } from "./html.mjs";
import { ratioOf, aaThreshold, parseColor, luminance } from "./contrast.mjs";
import { parseStylesheet, computeElementStyle, pageBackground, pickColor } from "./css.mjs";

// dimension/agent constants — kept in lockstep with agents/*.md
const A11Y    = { agent: "inspect-agent-a11y",     dimension: "Front-end Defects" };
const LAYOUT  = { agent: "inspect-agent-layout",   dimension: "Front-end Defects" };
const TECH    = { agent: "seo-agent-technical",    dimension: "Search Visibility" };
const CONTENT = { agent: "seo-agent-content",      dimension: "Search Visibility" };

const TEXTISH = new Set(["p","span","a","li","td","th","div","button","label",
  "strong","em","small","b","i","h1","h2","h3","h4","h5","h6","figcaption",
  "blockquote","cite","dd","dt","summary","caption"]);

function mk(o) {
  return {
    id: o.id, label: o.label, dimension: o.dimension, agent: o.agent,
    verdict: o.verdict, critical: !!o.critical, method: o.method,
    value: o.value === undefined ? null : o.value, detail: o.detail || "",
  };
}

function fontSizePx(st, tag) {
  const v = st["font-size"];
  if (v) {
    const m = v.match(/^([\d.]+)\s*(px|pt|rem|em)?$/);
    if (m) {
      const n = parseFloat(m[1]);
      switch (m[2]) {
        case "pt": return n * 4 / 3;
        case "rem": case "em": return n * 16;
        default: return n; // px or unit-less
      }
    }
  }
  if (tag === "h1") return 32;
  if (tag === "h2") return 24;
  return 16;
}

function isBold(st, el) {
  const w = (st["font-weight"] || "").toLowerCase();
  if (w === "bold" || w === "bolder") return true;
  const num = parseInt(w, 10);
  if (Number.isFinite(num) && num >= 700) return true;
  if (["b","strong","h1","h2","h3"].includes(el.tag)) return true;
  for (const a of ancestors(el)) if (a.tag === "b" || a.tag === "strong") return true;
  return false;
}

// ── individual checks ─────────────────────────────────────────────────────────

function checkViewport(doc) {
  const metas = queryAll(doc, "meta").filter(m => (m.attrs.name || "").toLowerCase() === "viewport");
  if (metas.length === 0) {
    return mk({ id: "viewport-meta", label: "Viewport meta", ...LAYOUT, verdict: "FAIL",
      method: "static", value: null, detail: "No <meta name=\"viewport\"> — page will not adapt to mobile widths." });
  }
  const content = (metas[0].attrs.content || "").toLowerCase();
  if (!/width\s*=\s*device-width/.test(content)) {
    return mk({ id: "viewport-meta", label: "Viewport meta", ...LAYOUT, verdict: "WARN",
      method: "static", value: content, detail: "Viewport present but missing width=device-width." });
  }
  if (/user-scalable\s*=\s*(no|0)/.test(content) || /maximum-scale\s*=\s*1(\.0)?\b/.test(content)) {
    return mk({ id: "viewport-meta", label: "Viewport meta", ...LAYOUT, verdict: "WARN",
      method: "static", value: content, detail: "Viewport blocks pinch-zoom (user-scalable=no / maximum-scale=1) — accessibility issue." });
  }
  return mk({ id: "viewport-meta", label: "Viewport meta", ...LAYOUT, verdict: "PASS",
    method: "static", value: content, detail: "Responsive viewport declared." });
}

function hasDims(el) {
  const a = el.attrs || {};
  if (a.width !== undefined && a.height !== undefined) return true;
  const st = styleMap(el);
  if (st.width && st.height) return true;
  if (st["aspect-ratio"]) return true;
  return false;
}

function checkImgDimensions(doc) {
  const imgs = queryAll(doc, "img");
  if (imgs.length === 0) {
    return mk({ id: "img-dimensions", label: "Image width/height", ...LAYOUT, verdict: "PASS",
      method: "static", value: { total: 0, missing: 0 }, detail: "No <img> elements." });
  }
  const missing = imgs.filter(im => !hasDims(im)).length;
  const value = { total: imgs.length, missing };
  if (missing === 0) {
    return mk({ id: "img-dimensions", label: "Image width/height", ...LAYOUT, verdict: "PASS",
      method: "static", value, detail: `All ${imgs.length} images declare width and height.` });
  }
  const verdict = missing >= 3 ? "FAIL" : "WARN";
  return mk({ id: "img-dimensions", label: "Image width/height", ...LAYOUT, verdict,
    method: "static", value,
    detail: `${missing}/${imgs.length} images lack explicit width/height (CLS risk).` });
}

function checkHtmlLang(doc) {
  const htmlEl = first(doc, "html");
  const lang = htmlEl && (htmlEl.attrs.lang || "").trim();
  if (lang) {
    return mk({ id: "html-lang", label: "HTML lang attribute", ...A11Y, verdict: "PASS",
      method: "static", value: lang, detail: `Document language declared (lang="${lang}").` });
  }
  return mk({ id: "html-lang", label: "HTML lang attribute", ...A11Y, verdict: "FAIL",
    method: "static", value: null, detail: "<html> has no lang attribute — screen readers cannot pick a voice." });
}

function checkTitle(doc) {
  const t = first(doc, "title");
  const text = t ? textContent(t).trim() : "";
  if (!text) {
    return mk({ id: "title-tag", label: "Title tag", ...CONTENT, verdict: "FAIL",
      method: "static", value: 0, detail: "Missing or empty <title>." });
  }
  const len = text.length;
  if (len > 60) {
    return mk({ id: "title-tag", label: "Title tag", ...CONTENT, verdict: "WARN",
      method: "static", value: len, detail: `Title is ${len} chars (over ~60 may truncate in SERP).` });
  }
  if (len < 10) {
    return mk({ id: "title-tag", label: "Title tag", ...CONTENT, verdict: "WARN",
      method: "static", value: len, detail: `Title is only ${len} chars (thin).` });
  }
  return mk({ id: "title-tag", label: "Title tag", ...CONTENT, verdict: "PASS",
    method: "static", value: len, detail: `Title present (${len} chars).` });
}

function checkMetaDescription(doc) {
  const metas = queryAll(doc, "meta").filter(m => (m.attrs.name || "").toLowerCase() === "description");
  const content = metas.length ? (metas[0].attrs.content || "").trim() : "";
  if (!content) {
    return mk({ id: "meta-description", label: "Meta description", ...CONTENT, verdict: "WARN",
      method: "static", value: 0, detail: "No meta description — Google may synthesize a snippet." });
  }
  const len = content.length;
  if (len > 160) {
    return mk({ id: "meta-description", label: "Meta description", ...CONTENT, verdict: "WARN",
      method: "static", value: len, detail: `Meta description is ${len} chars (over ~160 truncates).` });
  }
  if (len < 50) {
    return mk({ id: "meta-description", label: "Meta description", ...CONTENT, verdict: "WARN",
      method: "static", value: len, detail: `Meta description is ${len} chars (short).` });
  }
  return mk({ id: "meta-description", label: "Meta description", ...CONTENT, verdict: "PASS",
    method: "static", value: len, detail: `Meta description present (${len} chars).` });
}

function checkHeadingOrder(doc) {
  // collect headings in document order
  const order = [];
  const rec = (n) => {
    for (const c of n.children) {
      if (c.type === "element") {
        if (/^h[1-6]$/.test(c.tag)) order.push(Number(c.tag[1]));
        rec(c);
      }
    }
  };
  rec(doc);
  if (order.length === 0) {
    return mk({ id: "heading-order", label: "Heading order", ...CONTENT, verdict: "WARN",
      method: "static", value: { count: 0 }, detail: "No headings found." });
  }
  const h1 = order.filter(l => l === 1).length;
  let skip = null;
  for (let i = 1; i < order.length; i++) {
    if (order[i] > order[i - 1] + 1) { skip = [order[i - 1], order[i]]; break; }
  }
  const value = { count: order.length, h1, sequence: order };
  if (skip) {
    return mk({ id: "heading-order", label: "Heading order", ...CONTENT, verdict: "FAIL",
      method: "static", value, detail: `Heading level skipped (h${skip[0]} jumps to h${skip[1]}).` });
  }
  if (h1 === 0) {
    return mk({ id: "heading-order", label: "Heading order", ...CONTENT, verdict: "WARN",
      method: "static", value, detail: "No <h1> on the page." });
  }
  if (h1 > 1) {
    return mk({ id: "heading-order", label: "Heading order", ...CONTENT, verdict: "WARN",
      method: "static", value, detail: `${h1} <h1> elements (expected one primary heading).` });
  }
  return mk({ id: "heading-order", label: "Heading order", ...CONTENT, verdict: "PASS",
    method: "static", value, detail: `One h1, ${order.length} headings, no skipped levels.` });
}

function checkRobots(robotsTxt, url) {
  if (robotsTxt == null) {
    return mk({ id: "robots-disallow", label: "robots.txt crawlability", ...TECH, verdict: "NOT_MEASURED",
      method: "not-measured", value: null, detail: "robots.txt not provided to the analyzer." });
  }
  let path = "/";
  try { if (url) path = new URL(url).pathname || "/"; } catch { /* keep / */ }

  // Build the rule set that applies to a generic crawler: the '*' group plus
  // googlebot, which is what an SEO crawlability check cares about.
  const lines = robotsTxt.split(/\r?\n/).map(l => l.replace(/#.*$/, "").trim());
  let groups = [], cur = null;
  for (const line of lines) {
    const m = line.match(/^(user-agent|disallow|allow)\s*:\s*(.*)$/i);
    if (!m) continue;
    const field = m[1].toLowerCase(), val = m[2].trim();
    if (field === "user-agent") {
      if (cur && !cur._open) { cur = null; }
      if (!cur) { cur = { agents: [], rules: [], _open: true }; groups.push(cur); }
      cur.agents.push(val.toLowerCase());
    } else if (cur) {
      cur._open = false;
      cur.rules.push({ type: field, val });
    }
  }
  const relevant = groups.filter(g => g.agents.some(a => a === "*" || a === "googlebot"));
  const disallows = [];
  for (const g of relevant) for (const r of g.rules) if (r.type === "disallow" && r.val) disallows.push(r.val);

  if (disallows.some(d => d === "/")) {
    return mk({ id: "robots-disallow", label: "robots.txt crawlability", ...TECH, verdict: "FAIL", critical: true,
      method: "static", value: { disallows }, detail: "robots.txt has Disallow: / — the whole site is blocked from crawling." });
  }
  const blocking = disallows.find(d => path.startsWith(d));
  if (blocking) {
    return mk({ id: "robots-disallow", label: "robots.txt crawlability", ...TECH, verdict: "FAIL", critical: true,
      method: "static", value: { path, rule: blocking }, detail: `robots.txt blocks this page (Disallow: ${blocking}).` });
  }
  return mk({ id: "robots-disallow", label: "robots.txt crawlability", ...TECH, verdict: "PASS",
    method: "static", value: { disallows }, detail: disallows.length ? `Page allowed; ${disallows.length} Disallow rule(s) do not match ${path}.` : "robots.txt present, nothing disallowed." });
}

// ── contrast (token-aware static path) ─────────────────────────────────────────

function hasDirectText(el) {
  for (const c of el.children) if (c.type === "text" && c.value.replace(/\s+/g, "").length > 1) return true;
  return false;
}
function styleFor(node, model, memo) {
  let st = memo.get(node);
  if (!st) { st = computeElementStyle(node, model, styleMap(node)); memo.set(node, st); }
  return st;
}
// Inherited text color: first ancestor (or self) with a resolvable color.
function colorUp(el, model, memo) {
  for (const node of [el, ...ancestors(el)]) {
    if (node.type !== "element") continue;
    const st = styleFor(node, model, memo);
    const c = (st.color || "").trim().toLowerCase();
    if (c && c !== "inherit" && c !== "currentcolor") return { color: st.color.trim(), st };
  }
  return null;
}
function parseableColor(s) { return !!parseColor(s); }
// Resolve the nearest background. Returns { bg, unresolved }: unresolved=true when
// the nearest paint is a gradient, image or a value we cannot turn into a color,
// so the caller SKIPS the sample rather than assuming white (the main source of
// render-free false positives on hero sections).
function bgResolve(el, model, memo, pageBg) {
  for (const node of [el, ...ancestors(el)]) {
    if (node.type !== "element") continue;
    const st = styleFor(node, model, memo);
    const bc = (st["background-color"] || "").trim();
    if (bc) {
      const low = bc.toLowerCase();
      if (low !== "transparent" && low !== "inherit") {
        return parseableColor(bc) ? { bg: bc, unresolved: false, explicit: true } : { bg: null, unresolved: true };
      }
    }
    const bg = (st["background"] || "").trim();
    if (bg) {
      if (/gradient|url\(/i.test(bg)) return { bg: null, unresolved: true };
      const col = pickColor(bg);
      if (col && parseableColor(col)) {
        const low = col.toLowerCase();
        if (low !== "transparent" && low !== "inherit") return { bg: col, unresolved: false, explicit: true };
      } else {
        return { bg: null, unresolved: true };
      }
    }
    const bimg = (st["background-image"] || "").trim();
    if (bimg && /gradient|url\(/i.test(bimg)) return { bg: null, unresolved: true };
  }
  // Nothing painted in the chain. A real page background (body/:root) is trusted;
  // a bare white default is not (a section we could not resolve may sit under it).
  if (pageBg && parseableColor(pageBg)) return { bg: pageBg, unresolved: false, explicit: true };
  return { bg: "#ffffff", unresolved: false, explicit: false };
}

function checkContrast(doc, computed, cssModel) {
  if (computed && Array.isArray(computed.contrastSamples)) {
    const fails = computed.contrastSamples.filter(s => s.ratio < s.threshold);
    if (computed.contrastSamples.length === 0) {
      return mk({ id: "contrast-ratio", label: "Color contrast (AA)", ...A11Y, verdict: "NOT_MEASURED", critical: true,
        method: "computed", value: null, detail: "Render produced no text samples to measure." });
    }
    if (fails.length) {
      const worst = fails.reduce((a, b) => a.ratio < b.ratio ? a : b);
      return mk({ id: "contrast-ratio", label: "Color contrast (AA)", ...A11Y, verdict: "FAIL", critical: true,
        method: "computed", value: { failures: fails.length, worst: worst.ratio },
        detail: `${fails.length} text sample(s) below AA; worst ${worst.ratio}:1 (need ${worst.threshold}:1).` });
    }
    return mk({ id: "contrast-ratio", label: "Color contrast (AA)", ...A11Y, verdict: "PASS", critical: true,
      method: "computed", value: { samples: computed.contrastSamples.length }, detail: "All measured text meets AA contrast." });
  }

  // Static path. With a CSS model (inline <style> + linked stylesheets) resolve
  // token colors and the tag/class cascade; without one, fall back to inline
  // color attributes only (the render-free minimum).
  const samples = [];
  if (cssModel) {
    const memo = new Map();
    const pageBg = pageBackground(cssModel);
    const visit = (n) => {
      for (const c of n.children) {
        if (c.type !== "element") continue;
        if (TEXTISH.has(c.tag) && hasDirectText(c)) {
          const cu = colorUp(c, cssModel, memo);
          if (cu && parseableColor(cu.color)) {
            const { bg, unresolved, explicit } = bgResolve(c, cssModel, memo, pageBg);
            const fgc = parseColor(cu.color), bgc = bg ? parseColor(bg) : null;
            if (!unresolved && bg && fgc && bgc) {
              const lf = luminance(fgc), lb = luminance(bgc);
              // Skip the two cases a render-free cascade gets wrong: (1) light text on
              // an assumed-white background (a section we could not resolve), and
              // (2) dark text on a dark background — a contextual light override set
              // by a descendant selector we do not model, that we missed. Both are
              // cascade artifacts, not real failures; --render resolves them.
              const ambiguousLight = !explicit && lf > 0.4;
              const darkOnDark = lf < 0.25 && lb < 0.25;
              if (!ambiguousLight && !darkOnDark) {
                const r = ratioOf(cu.color, bg);
                if (r) samples.push({ tag: c.tag, ratio: r.ratio, threshold: aaThreshold({ fontSizePx: fontSizePx(cu.st, c.tag), bold: isBold(cu.st, c) }) });
              }
            }
          }
        }
        if (samples.length < 800) visit(c);
      }
    };
    visit(doc);
  } else {
    const visit = (n) => {
      for (const c of n.children) {
        if (c.type !== "element") continue;
        if (TEXTISH.has(c.tag)) {
          const st = styleMap(c);
          if (st.color) {
            const bgStr = resolveBackgroundStr(c);
            const r = ratioOf(st.color, bgStr);
            if (r) samples.push({ tag: c.tag, ratio: r.ratio, threshold: aaThreshold({ fontSizePx: fontSizePx(st, c.tag), bold: isBold(st, c) }) });
          }
        }
        visit(c);
      }
    };
    visit(doc);
  }

  if (samples.length === 0) {
    return mk({ id: "contrast-ratio", label: "Color contrast (AA)", ...A11Y, verdict: "NOT_MEASURED", critical: false,
      method: "not-measured", value: null,
      detail: "No resolvable text colors to measure statically — run with --render for computed-style contrast (ground truth)." });
  }
  // Static contrast is a render-free ESTIMATE (no gradients, images, media queries
  // or specificity), so it informs the floor but is never critical: only the
  // Playwright-computed path above caps the score. This prevents a heuristic from
  // forcing a page to the Critical band.
  const fails = samples.filter(s => s.ratio < s.threshold);
  if (fails.length) {
    const worst = fails.reduce((a, b) => a.ratio < b.ratio ? a : b);
    return mk({ id: "contrast-ratio", label: "Color contrast (AA)", ...A11Y, verdict: "FAIL", critical: false,
      method: "static", value: { samples: samples.length, failures: fails.length, worst: worst.ratio },
      detail: `${fails.length}/${samples.length} resolved text sample(s) below AA; worst ${worst.ratio}:1 (need ${worst.threshold}:1). Static estimate over samples with a known background; confirm with --render.` });
  }
  return mk({ id: "contrast-ratio", label: "Color contrast (AA)", ...A11Y, verdict: "PASS", critical: false,
    method: "static", value: { samples: samples.length }, detail: `All ${samples.length} resolved text sample(s) meet AA (static estimate over samples with a known background).` });
}

function checkOverflow375(doc, computed) {
  if (computed && typeof computed.horizontalOverflow375 === "boolean") {
    return computed.horizontalOverflow375
      ? mk({ id: "horizontal-overflow-375", label: "No horizontal scroll at 375px", ...LAYOUT, verdict: "FAIL",
          method: "computed", value: computed.scrollWidth375 || null, detail: `Page scrolls horizontally at 375px (scrollWidth ${computed.scrollWidth375}px > 375px).` })
      : mk({ id: "horizontal-overflow-375", label: "No horizontal scroll at 375px", ...LAYOUT, verdict: "PASS",
          method: "computed", value: null, detail: "No horizontal scroll at a 375px viewport." });
  }
  // Static heuristic: explicit width sources that commonly overflow a 375px screen.
  const hits = [];
  const visit = (n) => {
    for (const c of n.children) {
      if (c.type !== "element") continue;
      const st = styleMap(c);
      const w = st.width || "", mw = st["min-width"] || "";
      if (/\b100vw\b/.test(w) || /\b100vw\b/.test(mw)) hits.push(`${c.tag}{${/min-width/.test(mw)?"min-":""}width:100vw}`);
      const px = w.match(/^([\d.]+)px$/);
      if (px && parseFloat(px[1]) > 600) hits.push(`${c.tag}{width:${px[1]}px}`);
      const mpx = mw.match(/^([\d.]+)px$/);
      if (mpx && parseFloat(mpx[1]) > 420) hits.push(`${c.tag}{min-width:${mpx[1]}px}`);
      // Note: bare width/height ATTRIBUTES on img/table are deliberately NOT a
      // signal here — they are near-always constrained by responsive CSS
      // (max-width:100%). Real image overflow is caught by the rendered pass.
      visit(c);
    }
  };
  visit(doc);
  if (hits.length) {
    return mk({ id: "horizontal-overflow-375", label: "No horizontal scroll at 375px", ...LAYOUT, verdict: "WARN",
      method: "static", value: hits.slice(0, 8),
      detail: `Possible 375px overflow from fixed widths (static heuristic, not laid out): ${hits.slice(0, 4).join(", ")}.` });
  }
  return mk({ id: "horizontal-overflow-375", label: "No horizontal scroll at 375px", ...LAYOUT, verdict: "PASS",
    method: "static", value: [], detail: "No fixed-width overflow source found statically (run with --render to lay out)." });
}

// ── security headers and canonical / preview (deterministic from the response) ──

const PREVIEW_HOSTS = /\.(netlify\.app|vercel\.app|pages\.dev|github\.io|web\.app|onrender\.com|surge\.sh|fly\.dev)$/i;

function hostOf(u) { try { return new URL(u).host.toLowerCase(); } catch { return null; } }

function checkSecurityHeaders(headers, url) {
  if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
    return mk({ id: "security-headers", label: "Security headers", ...TECH, verdict: "NOT_MEASURED",
      method: "not-measured", value: null, detail: "No HTTP response headers available (local file, or headers not captured)." });
  }
  const h = lowerHeaders(headers);
  let isHttps = false; try { isHttps = new URL(url).protocol === "https:"; } catch { /* unknown */ }
  const csp = h["content-security-policy"] || "";
  const hstsRaw = h["strict-transport-security"] || "";
  const present = {
    hsts: !!hstsRaw,
    csp: !!csp,
    xFrameOptions: !!h["x-frame-options"],
    xContentTypeOptions: /nosniff/i.test(h["x-content-type-options"] || ""),
    referrerPolicy: !!h["referrer-policy"],
    permissionsPolicy: !!h["permissions-policy"],
    crossOriginIsolation: !!(h["cross-origin-opener-policy"] || h["cross-origin-embedder-policy"] || h["cross-origin-resource-policy"]),
  };
  const frameProtected = present.xFrameOptions || /frame-ancestors/i.test(csp);
  const missing = [];
  if (isHttps && !present.hsts) missing.push("Strict-Transport-Security");
  if (!present.xContentTypeOptions) missing.push("X-Content-Type-Options: nosniff");
  if (!frameProtected) missing.push("X-Frame-Options / CSP frame-ancestors");
  if (!present.referrerPolicy) missing.push("Referrer-Policy");
  // Quality of the headers that ARE present. A weak present header can drop a
  // fully-present set to WARN; a missing advisory header never does, so a solid
  // core still passes.
  const weak = [];
  if (present.hsts) {
    const ma = (hstsRaw.match(/max-age\s*=\s*(\d+)/i) || [])[1];
    if (ma !== undefined && Number(ma) < 15768000) weak.push(`HSTS max-age ${ma}s is under 6 months`);
  }
  if (present.csp) {
    if (/'unsafe-inline'/i.test(csp)) weak.push("CSP allows 'unsafe-inline'");
    if (/'unsafe-eval'/i.test(csp)) weak.push("CSP allows 'unsafe-eval'");
  }
  const advisory = [];
  if (present.hsts && !/includesubdomains/i.test(hstsRaw)) advisory.push("HSTS includeSubDomains");
  if (!present.permissionsPolicy) advisory.push("Permissions-Policy");
  if (!present.crossOriginIsolation) advisory.push("Cross-Origin-*-Policy (COOP/COEP/CORP)");
  const value = { present, missing, weak, advisory };
  if (missing.length === 0) {
    if (weak.length) {
      return mk({ id: "security-headers", label: "Security headers", ...TECH, verdict: "WARN",
        method: "static", value, detail: `Core headers present but weak: ${weak.join(", ")}.` });
    }
    return mk({ id: "security-headers", label: "Security headers", ...TECH, verdict: "PASS",
      method: "static", value, detail: `Core hardening headers present.${advisory.length ? " Optional next: " + advisory.join(", ") + "." : ""}` });
  }
  // Hardening signal, not an indexing blocker: never critical.
  const verdict = missing.length >= 3 ? "FAIL" : "WARN";
  return mk({ id: "security-headers", label: "Security headers", ...TECH, verdict,
    method: "static", value, detail: `Missing hardening header(s): ${missing.join(", ")}.` });
}

function checkCanonicalPreview(doc, url) {
  if (!url) {
    return mk({ id: "canonical-url", label: "Canonical / preview", ...TECH, verdict: "NOT_MEASURED",
      method: "not-measured", value: null, detail: "Canonical and preview-host checks apply to a fetched URL, not a local file." });
  }
  const links = queryAll(doc, "link").filter(l => (l.attrs.rel || "").toLowerCase().split(/\s+/).includes("canonical"));
  const canonical = links.length ? (links[0].attrs.href || "").trim() : "";
  const host = hostOf(url);
  const isPreview = host ? PREVIEW_HOSTS.test(host) : false;
  const robotsMeta = queryAll(doc, "meta").find(m => (m.attrs.name || "").toLowerCase() === "robots");
  const noindex = robotsMeta ? /noindex/i.test(robotsMeta.attrs.content || "") : false;

  if (!canonical) {
    if (isPreview) {
      return mk({ id: "canonical-url", label: "Canonical / preview", ...TECH, verdict: noindex ? "PASS" : "WARN",
        method: "static", value: { host, preview: true, canonical: null, noindex },
        detail: noindex ? "Preview host with noindex — correctly kept out of the index."
          : "Preview deploy host without a canonical or noindex — add <meta name=\"robots\" content=\"noindex\"> so the preview is not indexed alongside production." });
    }
    return mk({ id: "canonical-url", label: "Canonical / preview", ...TECH, verdict: "WARN",
      method: "static", value: { host, preview: false, canonical: null },
      detail: "No <link rel=\"canonical\"> — add one to consolidate ranking signals." });
  }
  const canonHost = hostOf(canonical) || host;
  const crossDomain = !!host && !!canonHost && canonHost !== host;
  if (isPreview && crossDomain) {
    // A preview host whose canonical points to production is the INTENDED setup.
    return mk({ id: "canonical-url", label: "Canonical / preview", ...TECH, verdict: "PASS",
      method: "static", value: { host, preview: true, canonical, canonHost, noindex },
      detail: `Preview host canonical points to production (${canonHost}); recommend also adding noindex on the preview. Not a canonical error.` });
  }
  if (crossDomain) {
    return mk({ id: "canonical-url", label: "Canonical / preview", ...TECH, verdict: "WARN",
      method: "static", value: { host, preview: false, canonical, canonHost },
      detail: `Canonical points to a different domain (${canonHost}) — intentional for syndication, otherwise it de-indexes this domain.` });
  }
  return mk({ id: "canonical-url", label: "Canonical / preview", ...TECH, verdict: "PASS",
    method: "static", value: { host, canonical }, detail: "Canonical URL present and same-origin." });
}

// ── DOM correctness, head hygiene and response hardening (harvested checks) ─────
// Agent bindings for checks that land in the code and performance dimensions.
const CODE = { agent: "inspect-agent-code",       dimension: "Front-end Defects" };
const PERF = { agent: "seo-agent-performance",    dimension: "Search Visibility" };

function lowerHeaders(headers) {
  const h = {};
  if (headers && typeof headers === "object") for (const k of Object.keys(headers)) h[k.toLowerCase()] = headers[k];
  return h;
}

// Valid WAI-ARIA 1.2 state and property names. An aria-* attribute outside this
// set is a typo or a non-standard name that assistive technology ignores.
const VALID_ARIA = new Set([
  "aria-activedescendant","aria-atomic","aria-autocomplete","aria-braillelabel",
  "aria-brailleroledescription","aria-busy","aria-checked","aria-colcount",
  "aria-colindex","aria-colindextext","aria-colspan","aria-controls","aria-current",
  "aria-describedby","aria-description","aria-details","aria-disabled","aria-dropeffect",
  "aria-errormessage","aria-expanded","aria-flowto","aria-grabbed","aria-haspopup",
  "aria-hidden","aria-invalid","aria-keyshortcuts","aria-label","aria-labelledby",
  "aria-level","aria-live","aria-modal","aria-multiline","aria-multiselectable",
  "aria-orientation","aria-owns","aria-placeholder","aria-posinset","aria-pressed",
  "aria-readonly","aria-relevant","aria-required","aria-roledescription","aria-rowcount",
  "aria-rowindex","aria-rowindextext","aria-rowspan","aria-selected","aria-setsize",
  "aria-sort","aria-valuemax","aria-valuemin","aria-valuenow","aria-valuetext",
]);

function editDistance(a, b) {
  const m = a.length, n = b.length;
  const dp = Array.from({ length: m + 1 }, (_, i) => { const row = new Array(n + 1).fill(0); row[0] = i; return row; });
  for (let j = 0; j <= n; j++) dp[0][j] = j;
  for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++)
    dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
  return dp[m][n];
}
function suggestAria(name) {
  let best = null, bestScore = 1e9;
  for (const v of VALID_ARIA) { const d = editDistance(name, v); if (d < bestScore) { bestScore = d; best = v; } }
  return bestScore > 0 && bestScore <= 3 ? best : null;
}

function checkInvalidAria(doc) {
  const bad = [];
  walk(doc, (el) => {
    for (const name of Object.keys(el.attrs || {})) {
      if (!name.startsWith("aria-") || VALID_ARIA.has(name)) continue;
      const hint = suggestAria(name);
      bad.push(hint ? `${name} (did you mean ${hint}?)` : name);
    }
  });
  const uniq = [...new Set(bad)];
  if (uniq.length === 0) {
    return mk({ id: "invalid-aria-attribute", label: "ARIA attribute names", ...A11Y, verdict: "PASS",
      method: "static", value: { invalid: 0 }, detail: "All aria-* attributes are valid WAI-ARIA names (or none are used)." });
  }
  return mk({ id: "invalid-aria-attribute", label: "ARIA attribute names", ...A11Y, verdict: "FAIL",
    method: "static", value: { invalid: uniq.length, names: uniq.slice(0, 12) },
    detail: `${uniq.length} invalid aria-* attribute(s) that assistive tech ignores: ${uniq.slice(0, 6).join(", ")}.` });
}

// Direct-parent requirements: a child tag is valid only inside these parents.
const NEST_PARENT = {
  li: new Set(["ul", "ol", "menu"]),
  option: new Set(["select", "optgroup", "datalist"]),
  optgroup: new Set(["select"]),
  td: new Set(["tr"]), th: new Set(["tr"]),
  tr: new Set(["thead", "tbody", "tfoot"]),
  thead: new Set(["table"]), tbody: new Set(["table"]), tfoot: new Set(["table"]),
  caption: new Set(["table"]), colgroup: new Set(["table"]), col: new Set(["colgroup"]),
  figcaption: new Set(["figure"]), summary: new Set(["details"]),
};
// Block-level tags that must not be a direct child of a paragraph.
const P_FORBIDS = new Set(["div", "p", "ul", "ol", "dl", "table", "section", "article",
  "header", "footer", "aside", "nav", "main", "figure", "form", "blockquote", "hr", "pre",
  "h1", "h2", "h3", "h4", "h5", "h6", "details", "fieldset", "address"]);
// Tags that may never contain another of the same, anywhere in their subtree.
const SELF_NEST = new Set(["a", "button", "form", "p"]);
const NEST_SEVERE = new Set(["tr", "td", "th", "li", "option", "optgroup"]);

function checkInvalidNesting(doc) {
  const severe = [], minor = [];
  walk(doc, (el) => {
    const tag = el.tag;
    const parent = el.parent && el.parent.type === "element" ? el.parent.tag : null;
    if (NEST_PARENT[tag] && !(parent && parent.includes("-"))) {
      if (!parent || !NEST_PARENT[tag].has(parent)) {
        const msg = parent === "table" && (tag === "tr" || tag === "td" || tag === "th")
          ? `<${tag}> directly in <table> (needs a <tbody>)`
          : `<${tag}> not inside <${[...NEST_PARENT[tag]].join("/")}>`;
        (NEST_SEVERE.has(tag) ? severe : minor).push(msg);
      }
    }
    if (tag === "dt" || tag === "dd") {
      let hasDl = false;
      for (const a of ancestors(el)) if (a.tag === "dl") { hasDl = true; break; }
      if (!hasDl) minor.push(`<${tag}> outside a <dl>`);
    }
    if (parent === "p" && P_FORBIDS.has(tag)) minor.push(`<${tag}> inside <p> (auto-closes the paragraph)`);
    if (SELF_NEST.has(tag)) {
      for (const a of ancestors(el)) if (a.tag === tag) {
        ((tag === "a" || tag === "button" || tag === "form") ? severe : minor).push(`<${tag}> nested inside another <${tag}>`);
        break;
      }
    }
  });
  const total = severe.length + minor.length;
  if (total === 0) {
    return mk({ id: "invalid-dom-nesting", label: "HTML nesting validity", ...CODE, verdict: "PASS",
      method: "static", value: { violations: 0 }, detail: "No invalid element nesting found." });
  }
  const sample = [...severe, ...minor].slice(0, 6);
  return mk({ id: "invalid-dom-nesting", label: "HTML nesting validity", ...CODE, verdict: severe.length ? "FAIL" : "WARN",
    method: "static", value: { violations: total, severe: severe.length, sample },
    detail: `${total} invalid nesting issue(s) that break parsing or semantics: ${sample.join("; ")}.` });
}

function checkCharsetEarly(rawHtml, headers) {
  const html = rawHtml || "";
  const ct = (headers && typeof headers === "object" && !Array.isArray(headers)) ? (lowerHeaders(headers)["content-type"] || "") : "";
  if (/charset\s*=/i.test(ct)) {
    return mk({ id: "charset-early", label: "Charset declared early", ...TECH, verdict: "PASS",
      method: "static", value: { source: "content-type" }, detail: "Charset declared in the Content-Type response header." });
  }
  if (!html.trim()) {
    return mk({ id: "charset-early", label: "Charset declared early", ...TECH, verdict: "NOT_MEASURED",
      method: "not-measured", value: null, detail: "No raw HTML to inspect for a charset declaration." });
  }
  const m = html.match(/<meta[^>]+charset\s*=\s*["']?[a-z0-9_-]+/i)
    || html.match(/<meta[^>]+http-equiv\s*=\s*["']?content-type["']?[^>]*charset\s*=/i);
  if (!m) {
    return mk({ id: "charset-early", label: "Charset declared early", ...TECH, verdict: "WARN",
      method: "static", value: null, detail: "No <meta charset>, so the browser has to guess the encoding." });
  }
  const bytes = Buffer.byteLength(html.slice(0, m.index || 0), "utf8");
  if (bytes > 1024) {
    return mk({ id: "charset-early", label: "Charset declared early", ...TECH, verdict: "WARN",
      method: "static", value: { byteOffset: bytes }, detail: `<meta charset> appears ${bytes} bytes in; the spec wants it within the first 1024.` });
  }
  return mk({ id: "charset-early", label: "Charset declared early", ...TECH, verdict: "PASS",
    method: "static", value: { byteOffset: bytes }, detail: `Charset declared within the first ${bytes} bytes.` });
}

function checkHeadMeta(doc) {
  const links = queryAll(doc, "link"), metas = queryAll(doc, "meta");
  const relSet = (l) => (l.attrs.rel || "").toLowerCase().split(/\s+/);
  const metaName = (n) => metas.some(m => (m.attrs.name || "").toLowerCase() === n);
  const present = {
    icon: links.some(l => relSet(l).includes("icon")),
    appleTouchIcon: links.some(l => relSet(l).includes("apple-touch-icon")),
    manifest: links.some(l => relSet(l).includes("manifest")),
    themeColor: metaName("theme-color"),
    colorScheme: metaName("color-scheme"),
  };
  const missing = Object.entries(present).filter(([, v]) => !v).map(([k]) => k);
  if (!present.icon && !present.manifest && !present.themeColor) {
    return mk({ id: "head-meta", label: "Head metadata", ...TECH, verdict: "WARN",
      method: "static", value: { present, missing }, detail: "No favicon, web app manifest or theme-color (basic head metadata is absent)." });
  }
  if (!present.icon) {
    return mk({ id: "head-meta", label: "Head metadata", ...TECH, verdict: "WARN",
      method: "static", value: { present, missing }, detail: "No favicon link (rel=\"icon\")." });
  }
  return mk({ id: "head-meta", label: "Head metadata", ...TECH, verdict: "PASS",
    method: "static", value: { present, missing },
    detail: missing.length ? `Favicon present; optional extras absent: ${missing.join(", ")}.` : "Favicon, apple-touch-icon, manifest, theme-color and color-scheme all present." });
}

function isCrossOrigin(href, pageHost) {
  if (!href) return false;
  if (href.startsWith("//")) return true;
  const m = href.match(/^https?:\/\/([^/]+)/i);
  if (!m) return false;
  if (!pageHost) return true;
  return m[1].toLowerCase() !== pageHost;
}
function checkSri(doc, url) {
  let pageHost = null; try { if (url) pageHost = new URL(url).host.toLowerCase(); } catch { /* unknown */ }
  const offenders = [];
  for (const s of queryAll(doc, "script"))
    if (s.attrs.src && isCrossOrigin(s.attrs.src, pageHost) && !s.attrs.integrity) offenders.push(`script ${s.attrs.src.slice(0, 60)}`);
  for (const l of queryAll(doc, "link")) {
    const rel = (l.attrs.rel || "").toLowerCase().split(/\s+/);
    if ((rel.includes("stylesheet") || rel.includes("modulepreload")) && l.attrs.href && isCrossOrigin(l.attrs.href, pageHost) && !l.attrs.integrity)
      offenders.push(`stylesheet ${l.attrs.href.slice(0, 60)}`);
  }
  if (offenders.length === 0) {
    return mk({ id: "subresource-integrity", label: "Subresource Integrity", ...TECH, verdict: "PASS",
      method: "static", value: { missing: 0 }, detail: "No cross-origin script or stylesheet lacks an integrity hash." });
  }
  return mk({ id: "subresource-integrity", label: "Subresource Integrity", ...TECH, verdict: "WARN",
    method: "static", value: { missing: offenders.length, sample: offenders.slice(0, 5) },
    detail: `${offenders.length} cross-origin resource(s) without integrity: ${offenders.slice(0, 3).join(", ")}.` });
}

const REDIRECT_PARAMS = new Set(["url", "redirect", "redirect_url", "redirect_uri", "redir", "rurl",
  "next", "dest", "destination", "go", "return", "returnto", "return_to", "return_path", "continue",
  "checkout_url", "image_url", "view", "target", "link", "out"]);
function offOrigin(val, pageHost) {
  if (!val) return false;
  if (val.startsWith("//")) return true;
  const m = val.match(/^https?:\/\/([^/]+)/i);
  if (!m) return false;
  if (!pageHost) return true;
  return m[1].toLowerCase() !== pageHost;
}
function checkOpenRedirect(doc, url) {
  let pageHost = null; try { if (url) pageHost = new URL(url).host.toLowerCase(); } catch { /* unknown */ }
  const hits = [];
  const scan = (raw) => {
    if (!raw) return;
    const qi = raw.indexOf("?");
    if (qi === -1) return;
    for (const pair of raw.slice(qi + 1).split("&")) {
      const eq = pair.indexOf("=");
      if (eq === -1) continue;
      let key = pair.slice(0, eq), value = pair.slice(eq + 1);
      try { key = decodeURIComponent(key); } catch { /* raw */ }
      try { value = decodeURIComponent(value); } catch { /* raw */ }
      if (REDIRECT_PARAMS.has(key.toLowerCase()) && offOrigin(value, pageHost)) hits.push(`${key.toLowerCase()}=${value.slice(0, 40)}`);
    }
  };
  for (const a of queryAll(doc, "a")) scan(a.attrs.href);
  for (const f of queryAll(doc, "form")) scan(f.attrs.action);
  if (hits.length === 0) {
    return mk({ id: "open-redirect-param", label: "Open redirect parameters", ...TECH, verdict: "PASS",
      method: "static", value: { hits: 0 }, detail: "No on-page link passes an off-origin URL through a redirect parameter." });
  }
  return mk({ id: "open-redirect-param", label: "Open redirect parameters", ...TECH, verdict: "WARN",
    method: "static", value: { hits: hits.length, sample: hits.slice(0, 5) },
    detail: `${hits.length} link(s) route an absolute off-origin URL through a redirect-style parameter (open-redirect smell): ${hits.slice(0, 3).join(", ")}. Advisory; confirm server-side validation.` });
}

function checkCorsWildcard(headers) {
  if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
    return mk({ id: "cors-credentialed-wildcard", label: "CORS credentialed wildcard", ...TECH, verdict: "NOT_MEASURED",
      method: "not-measured", value: null, detail: "No response headers available." });
  }
  const h = lowerHeaders(headers);
  const acao = (h["access-control-allow-origin"] || "").trim();
  const acac = /true/i.test(h["access-control-allow-credentials"] || "");
  if ((acao === "*" || acao.toLowerCase() === "null") && acac) {
    return mk({ id: "cors-credentialed-wildcard", label: "CORS credentialed wildcard", ...TECH, verdict: "FAIL",
      method: "static", value: { acao, credentials: true },
      detail: `Access-Control-Allow-Origin: ${acao} with Access-Control-Allow-Credentials: true is an insecure, invalid CORS combination.` });
  }
  return mk({ id: "cors-credentialed-wildcard", label: "CORS credentialed wildcard", ...TECH, verdict: "PASS",
    method: "static", value: { acao: acao || null }, detail: acao ? `CORS origin policy present (${acao}) without the credentialed-wildcard flaw.` : "No permissive credentialed CORS wildcard." });
}

function checkCompression(headers) {
  if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
    return mk({ id: "compression-enabled", label: "Response compression", ...PERF, verdict: "NOT_MEASURED",
      method: "not-measured", value: null, detail: "No response headers available." });
  }
  const enc = (lowerHeaders(headers)["content-encoding"] || "").toLowerCase();
  if (/\b(gzip|br|deflate|zstd)\b/.test(enc)) {
    return mk({ id: "compression-enabled", label: "Response compression", ...PERF, verdict: "PASS",
      method: "static", value: { encoding: enc }, detail: `Response compressed (${enc}).` });
  }
  return mk({ id: "compression-enabled", label: "Response compression", ...PERF, verdict: "WARN",
    method: "static", value: { encoding: enc || null }, detail: "No Content-Encoding, so the response appears served uncompressed (enable gzip or brotli)." });
}

function checkServerFingerprint(headers) {
  if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
    return mk({ id: "server-fingerprint", label: "Server fingerprint headers", ...TECH, verdict: "NOT_MEASURED",
      method: "not-measured", value: null, detail: "No response headers available." });
  }
  const h = lowerHeaders(headers);
  const leaks = [];
  if (h["x-powered-by"]) leaks.push(`X-Powered-By: ${h["x-powered-by"]}`);
  if (h["x-aspnet-version"]) leaks.push("X-AspNet-Version");
  if (h["x-aspnetmvc-version"]) leaks.push("X-AspNetMvc-Version");
  if (leaks.length === 0) {
    return mk({ id: "server-fingerprint", label: "Server fingerprint headers", ...TECH, verdict: "PASS",
      method: "static", value: { leaks: [] }, detail: "No version-revealing server headers." });
  }
  return mk({ id: "server-fingerprint", label: "Server fingerprint headers", ...TECH, verdict: "WARN",
    method: "static", value: { leaks }, detail: `Response reveals stack or version details: ${leaks.slice(0, 3).join(", ")}.` });
}

function checkCookieFlags(headers) {
  if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
    return mk({ id: "session-cookie-flags", label: "Cookie security flags", ...TECH, verdict: "NOT_MEASURED",
      method: "not-measured", value: null, detail: "No response headers available." });
  }
  const sc = lowerHeaders(headers)["set-cookie"];
  if (sc == null) {
    return mk({ id: "session-cookie-flags", label: "Cookie security flags", ...TECH, verdict: "PASS",
      method: "static", value: { cookies: 0 }, detail: "No Set-Cookie on this response." });
  }
  const cookies = Array.isArray(sc) ? sc : String(sc).split(/,(?=[^;=]+=)/);
  const weak = [];
  for (const c of cookies) {
    const name = (c.split("=")[0] || "").trim();
    const flags = c.toLowerCase(), miss = [];
    if (!/;\s*httponly/.test(flags)) miss.push("HttpOnly");
    if (!/;\s*secure/.test(flags)) miss.push("Secure");
    if (!/;\s*samesite/.test(flags)) miss.push("SameSite");
    if (miss.length) weak.push(`${name} (missing ${miss.join(", ")})`);
  }
  if (weak.length === 0) {
    return mk({ id: "session-cookie-flags", label: "Cookie security flags", ...TECH, verdict: "PASS",
      method: "static", value: { cookies: cookies.length, weak: 0 }, detail: `All ${cookies.length} cookie(s) set Secure, HttpOnly and SameSite.` });
  }
  return mk({ id: "session-cookie-flags", label: "Cookie security flags", ...TECH, verdict: "WARN",
    method: "static", value: { cookies: cookies.length, weak: weak.slice(0, 5) },
    detail: `${weak.length} cookie(s) missing a security flag: ${weak.slice(0, 3).join("; ")}.` });
}

function checkHttpsRedirect(probes) {
  const p = probes && probes.httpsRedirect;
  if (!p || !p.tested) {
    return mk({ id: "https-redirect", label: "HTTP to HTTPS redirect", ...TECH, verdict: "NOT_MEASURED",
      method: "not-measured", value: null, detail: "HTTP to HTTPS redirect not probed (URL crawl probes only)." });
  }
  if (p.redirectsToHttps) {
    return mk({ id: "https-redirect", label: "HTTP to HTTPS redirect", ...TECH, verdict: "PASS",
      method: "probe", value: { status: p.status || null, finalUrl: p.location || null }, detail: "Plain HTTP redirects to HTTPS." });
  }
  return mk({ id: "https-redirect", label: "HTTP to HTTPS redirect", ...TECH, verdict: p.reachable ? "FAIL" : "WARN",
    method: "probe", value: { status: p.status || null },
    detail: p.reachable ? "Plain HTTP does not redirect to HTTPS (content is reachable without transport security)." : "Could not confirm an HTTP to HTTPS redirect." });
}

function checkHostCanonical(probes) {
  const p = probes && probes.hostCanonical;
  if (!p || !p.tested) {
    return mk({ id: "host-canonicalization", label: "www / non-www canonical host", ...TECH, verdict: "NOT_MEASURED",
      method: "not-measured", value: null, detail: "Host canonicalization not probed." });
  }
  if (p.altRedirects || !p.altReachable) {
    return mk({ id: "host-canonicalization", label: "www / non-www canonical host", ...TECH, verdict: "PASS",
      method: "probe", value: { altHost: p.altHost, altRedirects: !!p.altRedirects },
      detail: p.altRedirects ? `The alternate host (${p.altHost}) redirects to the canonical host.` : `The alternate host (${p.altHost}) does not serve the site.` });
  }
  return mk({ id: "host-canonicalization", label: "www / non-www canonical host", ...TECH, verdict: "WARN",
    method: "probe", value: { altHost: p.altHost },
    detail: `Both the audited host and ${p.altHost} serve the page without a redirect (duplicate-content risk). Pick one and 301 the other.` });
}

function checkSecurityTxt(probes) {
  const p = probes && probes.securityTxt;
  if (!p || !p.tested) {
    return mk({ id: "security-txt", label: "security.txt", ...TECH, verdict: "NOT_MEASURED",
      method: "not-measured", value: null, detail: "/.well-known/security.txt not probed." });
  }
  if (p.found) {
    return mk({ id: "security-txt", label: "security.txt", ...TECH, verdict: "PASS",
      method: "probe", value: { found: true }, detail: "A security.txt is published (RFC 9116)." });
  }
  return mk({ id: "security-txt", label: "security.txt", ...TECH, verdict: "WARN",
    method: "probe", value: { found: false }, detail: "No /.well-known/security.txt (optional RFC 9116 disclosure contact)." });
}

// ── engine ────────────────────────────────────────────────────────────────────

export function runChecks({ rawHtml = "", renderedHtml = null, robotsTxt = null, url = null, computed = null, headers = null, css = "", probes = null } = {}) {
  const rawDoc = parse(rawHtml || "");
  const activeDoc = renderedHtml ? parse(renderedHtml) : rawDoc;
  // Build a CSS model from inline <style> blocks plus any linked stylesheets so
  // the static contrast pass can resolve token colors without a browser.
  const styleText = (queryAll(activeDoc, "style").map(s => textContent(s)).join("\n") + "\n" + (css || "")).trim();
  const cssModel = styleText ? parseStylesheet(styleText) : null;
  const checks = [
    checkViewport(activeDoc),
    checkImgDimensions(activeDoc),
    checkOverflow375(activeDoc, computed),
    checkContrast(activeDoc, computed, cssModel),
    checkHtmlLang(activeDoc),
    checkRobots(robotsTxt, url),
    checkTitle(activeDoc),
    checkMetaDescription(activeDoc),
    checkHeadingOrder(activeDoc),
    checkSecurityHeaders(headers, url),
    checkCanonicalPreview(activeDoc, url),
    checkInvalidNesting(rawDoc),
    checkInvalidAria(activeDoc),
    checkCharsetEarly(rawHtml, headers),
    checkHeadMeta(activeDoc),
    checkSri(activeDoc, url),
    checkOpenRedirect(activeDoc, url),
    checkCorsWildcard(headers),
    checkCompression(headers),
    checkServerFingerprint(headers),
    checkCookieFlags(headers),
    checkHttpsRedirect(probes),
    checkHostCanonical(probes),
    checkSecurityTxt(probes),
  ];
  return checks;
}

// Deterministic health floor computed from the objective checks only. This is a
// SUBSET of each agent's full checklist, so it is a floor, not the agent score.
export function scoreFromChecks(checks) {
  const measured = checks.filter(c => c.verdict !== "NOT_MEASURED");
  const fails = measured.filter(c => c.verdict === "FAIL");
  const warns = measured.filter(c => c.verdict === "WARN");
  const criticalFails = fails.filter(c => c.critical).map(c => c.id);
  let score = 100 - 15 * fails.length - 7 * warns.length;
  if (score < 0) score = 0;
  if (criticalFails.length) score = Math.min(score, 49);

  const byAgent = {};
  for (const c of measured) {
    const a = (byAgent[c.agent] ||= { fails: 0, warns: 0, score: 100, criticalFails: [] });
    if (c.verdict === "FAIL") { a.fails++; if (c.critical) a.criticalFails.push(c.id); }
    if (c.verdict === "WARN") a.warns++;
  }
  for (const a of Object.values(byAgent)) {
    a.score = Math.max(0, 100 - 15 * a.fails - 7 * a.warns);
    if (a.criticalFails.length) a.score = Math.min(a.score, 49);
  }
  return {
    score,
    fails: fails.length,
    warns: warns.length,
    notMeasured: checks.length - measured.length,
    criticalFails,
    byAgent,
  };
}
