import fs from 'node:fs';
import path from 'node:path';

import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs';
import { CSS_NAMED_COLORS, collectCssCustomProps, cssLengthToPx, parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';

// ---------------------------------------------------------------------------
// jsdom CSS-variable border override map
// ---------------------------------------------------------------------------
//
// jsdom's CSSOM silently drops any border shorthand that contains a var()
// reference — the computed style for the element then shows empty width,
// empty style, and a default black color. That's enough to hide the most
// common real-world side-tab pattern in AI-generated pages:
//
//   :root { --brand: #87a8ff; }
//   .card { border-left: 5px solid var(--brand); border-radius: 4px; }
//
// Real browsers (and therefore the browser detector path) resolve var()
// natively, so this only affects the Node jsdom path.
//
// This pre-pass walks the stylesheets, finds any rule whose per-side or
// all-sides border property contains var(), resolves the var() against
// :root-level custom properties (read from the documentElement's computed
// style, which jsdom DOES handle correctly), and attaches the resolved
// width+color to every element that matches the rule's selector. The
// Node-side `checkElementBorders` adapter consumes that map as a fallback
// whenever jsdom's computed style came back empty.
//
// Limitations (intentional, to keep the pass simple):
//   * Only :root-level custom properties are resolved. Scoped overrides on
//     descendants are not tracked — uncommon in practice and would require
//     a per-element cascade walk.
//   * @media / @supports wrapped rules are ignored (jsdom often mishandles
//     these anyway).
//   * The fallback only fills sides that jsdom left empty, so any rule
//     whose border parses normally still wins via the computed style.

const BORDER_SHORTHAND_RE = /^(\d+(?:\.\d+)?)px\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+(.+)$/i;

// isNeutralColor only understands rgba()/oklch()/lch()/lab()/hsl()/hwb().
// CSS variables typically hold hex or named colors, so normalize those to
// rgb() before handing the value off to the shared check. Anything we don't
// recognise is passed through unchanged — isNeutralColor then treats it as
// non-neutral, which is the safer default (matches the oklch-era bugfix).
const NAMED_COLORS = {
  white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128],
  grey: [128, 128, 128], silver: [192, 192, 192], red: [255, 0, 0],
  green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0],
};

function normalizeColorForCheck(value) {
  if (!value) return value;
  const v = value.trim();
  const hex6 = v.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
  if (hex6) {
    const [r, g, b] = [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)];
    return `rgb(${r}, ${g}, ${b})`;
  }
  const hex3 = v.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
  if (hex3) {
    const [r, g, b] = [
      parseInt(hex3[1] + hex3[1], 16),
      parseInt(hex3[2] + hex3[2], 16),
      parseInt(hex3[3] + hex3[3], 16),
    ];
    return `rgb(${r}, ${g}, ${b})`;
  }
  const named = NAMED_COLORS[v.toLowerCase()];
  if (named) return `rgb(${named[0]}, ${named[1]}, ${named[2]})`;
  return v;
}

function buildBorderOverrideMap(document, window) {
  const map = new Map();
  const rootStyle = window.getComputedStyle(document.documentElement);

  function resolveVar(value, depth = 0) {
    if (!value || depth > 10 || !value.includes('var(')) return value;
    return value.replace(
      /var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\s*\)/g,
      (_, name, fallback) => {
        const v = rootStyle.getPropertyValue(name).trim();
        if (v) return resolveVar(v, depth + 1);
        if (fallback) return resolveVar(fallback.trim(), depth + 1);
        return '';
      }
    );
  }

  function parseShorthand(text) {
    const m = text.trim().match(BORDER_SHORTHAND_RE);
    if (!m) return null;
    return { width: parseFloat(m[1]), color: normalizeColorForCheck(m[3]) };
  }

  // Read from the per-property accessors on rule.style. jsdom preserves
  // each border-* shorthand it parsed, even when the overall cssText has
  // been truncated (e.g. a `border: 1px solid var(...)` followed by a
  // `border-left: ...` loses the first declaration but keeps the second).
  const SIDE_PROPS = [
    ['borderLeft', 'Left'],
    ['borderRight', 'Right'],
    ['borderTop', 'Top'],
    ['borderBottom', 'Bottom'],
    ['borderInlineStart', 'Left'],
    ['borderInlineEnd', 'Right'],
  ];

  for (const sheet of document.styleSheets) {
    let rules;
    try { rules = sheet.cssRules || []; } catch { continue; }
    for (const rule of rules) {
      // CSSStyleRule only; skip @media / @keyframes / @supports wrappers.
      if (rule.type !== 1 || !rule.style || !rule.selectorText) continue;

      const perSide = {};

      for (const [prop, side] of SIDE_PROPS) {
        const val = rule.style[prop];
        if (!val || !val.includes('var(')) continue;
        const parsed = parseShorthand(resolveVar(val));
        if (parsed && parsed.color) perSide[side] = parsed;
      }

      // Uniform `border: <w> <style> var(...)` applies to every side the
      // per-side map didn't already claim.
      const borderAll = rule.style.border;
      if (borderAll && borderAll.includes('var(')) {
        const parsed = parseShorthand(resolveVar(borderAll));
        if (parsed && parsed.color) {
          for (const s of ['Top', 'Right', 'Bottom', 'Left']) {
            if (!perSide[s]) perSide[s] = parsed;
          }
        }
      }

      // Longhand `border-*-color: var(...)` with width/style in separate
      // declarations. Rare in AI-generated pages, but cheap to cover.
      for (const [prop, side] of [
        ['borderLeftColor', 'Left'],
        ['borderRightColor', 'Right'],
        ['borderTopColor', 'Top'],
        ['borderBottomColor', 'Bottom'],
      ]) {
        const val = rule.style[prop];
        if (!val || !val.includes('var(')) continue;
        const resolved = resolveVar(val).trim();
        if (!resolved) continue;
        // Width may or may not come from this rule — that's fine; the
        // adapter only substitutes the color when jsdom left it as a
        // literal var() string.
        if (!perSide[side]) perSide[side] = { width: 0, color: normalizeColorForCheck(resolved) };
      }

      if (Object.keys(perSide).length === 0) continue;

      let matched;
      try { matched = document.querySelectorAll(rule.selectorText); }
      catch { continue; }

      for (const el of matched) {
        const existing = map.get(el);
        if (existing) {
          // Later rules overwrite earlier ones — approximates source-order
          // cascade for equal-specificity rules and is good enough for the
          // uncontested var()-dropped sides we're trying to recover.
          Object.assign(existing, perSide);
        } else {
          map.set(el, { ...perSide });
        }
      }
    }
  }

  return map;
}

// Strip `@layer NAME { … }` wrappers from a CSS / HTML source, leaving
// the inner rules as flat CSS. jsdom doesn't implement CSS @layer, so
// any rule inside a layer block becomes invisible to getComputedStyle.
// Tailwind v4 makes this ubiquitous: every utility class lives in
// `@layer utilities`, and Preflight lives in `@layer base`. Without
// unwrapping, every Tailwind-styled element returns empty computed
// styles. We walk the source character-by-character, balancing braces
// so we correctly handle nested style rules inside the layer block.
function unwrapCssAtLayer(source) {
  if (!source || !source.includes('@layer')) return source;
  // Find `@layer <name>? {` openers. The match starts at the @, and
  // we then balance braces from the opening { onward.
  const re = /@layer\b[^{;]*\{/g;
  let out = '';
  let lastIdx = 0;
  let m;
  while ((m = re.exec(source)) !== null) {
    const openStart = m.index;
    const openEnd = m.index + m[0].length; // position right after `{`
    let depth = 1;
    let i = openEnd;
    while (i < source.length && depth > 0) {
      const c = source.charCodeAt(i);
      if (c === 0x7b /* { */) depth++;
      else if (c === 0x7d /* } */) depth--;
      i++;
    }
    if (depth !== 0) {
      // Unbalanced — bail and return source unchanged.
      return source;
    }
    // Emit everything before the @layer, then the inner contents
    // (between the opening { and the matched closing }), then advance.
    out += source.slice(lastIdx, openStart);
    out += source.slice(openEnd, i - 1); // i-1 = position of the closing }
    lastIdx = i;
    re.lastIndex = i;
  }
  out += source.slice(lastIdx);
  return out;
}

// ---------------------------------------------------------------------------
// Static HTML/CSS detection (default for local HTML files)
// ---------------------------------------------------------------------------

const STATIC_INHERITED_PROPS = new Set([
  'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
  'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
  'webkitHyphens',
  // visibility inherits in real CSS, and the invisible-at-rest contrast skip
  // relies on descendants of a hidden container computing as hidden. A child
  // that declares `visibility: visible` still overrides the inherited value.
  'visibility',
]);

const STATIC_DEFAULT_STYLE = {
  color: 'rgb(0, 0, 0)',
  backgroundColor: 'rgba(0, 0, 0, 0)',
  backgroundImage: 'none',
  borderTopWidth: '0px',
  borderRightWidth: '0px',
  borderBottomWidth: '0px',
  borderLeftWidth: '0px',
  borderTopColor: 'rgb(0, 0, 0)',
  borderRightColor: 'rgb(0, 0, 0)',
  borderBottomColor: 'rgb(0, 0, 0)',
  borderLeftColor: 'rgb(0, 0, 0)',
  borderRadius: '0px',
  outlineWidth: '0px',
  outlineColor: 'rgb(0, 0, 0)',
  outlineStyle: 'none',
  boxShadow: 'none',
  // NOT in STATIC_INHERITED_PROPS even though text-shadow inherits in real
  // CSS: the glow check only needs to fire once, on the element that
  // declares the shadow, not on every descendant.
  textShadow: 'none',
  fontFamily: '',
  fontSize: '16px',
  fontStyle: 'normal',
  fontVariant: 'normal',
  fontWeight: '400',
  lineHeight: 'normal',
  letterSpacing: 'normal',
  textTransform: 'none',
  textAlign: 'start',
  hyphens: 'manual',
  webkitHyphens: 'manual',
  transitionProperty: '',
  transitionTimingFunction: '',
  animationName: '',
  animationTimingFunction: '',
  webkitBackgroundClip: '',
  backgroundClip: '',
  width: '',
  height: '',
  paddingTop: '0px',
  paddingRight: '0px',
  paddingBottom: '0px',
  paddingLeft: '0px',
  marginTop: '0px',
  marginRight: '0px',
  marginBottom: '0px',
  marginLeft: '0px',
  position: 'static',
  visibility: 'visible',
  opacity: '1',
  top: 'auto',
  right: 'auto',
  bottom: 'auto',
  left: 'auto',
  inset: '',
  display: '',
  overflow: 'visible',
  overflowX: 'visible',
  overflowY: 'visible',
};

const STATIC_PROP_MAP = {
  'background-color': 'backgroundColor',
  'background-image': 'backgroundImage',
  'background-clip': 'backgroundClip',
  '-webkit-background-clip': 'webkitBackgroundClip',
  'border-radius': 'borderRadius',
  'border-top-width': 'borderTopWidth',
  'border-right-width': 'borderRightWidth',
  'border-bottom-width': 'borderBottomWidth',
  'border-left-width': 'borderLeftWidth',
  'border-top-color': 'borderTopColor',
  'border-right-color': 'borderRightColor',
  'border-bottom-color': 'borderBottomColor',
  'border-left-color': 'borderLeftColor',
  'outline-width': 'outlineWidth',
  'outline-color': 'outlineColor',
  'outline-style': 'outlineStyle',
  'box-shadow': 'boxShadow',
  'text-shadow': 'textShadow',
  'font-family': 'fontFamily',
  'font-size': 'fontSize',
  'font-style': 'fontStyle',
  'font-weight': 'fontWeight',
  'line-height': 'lineHeight',
  'letter-spacing': 'letterSpacing',
  'text-transform': 'textTransform',
  'text-align': 'textAlign',
  'hyphens': 'hyphens',
  '-webkit-hyphens': 'webkitHyphens',
  'transition-property': 'transitionProperty',
  'transition-timing-function': 'transitionTimingFunction',
  'animation-name': 'animationName',
  'animation-timing-function': 'animationTimingFunction',
  'width': 'width',
  'height': 'height',
  'padding-top': 'paddingTop',
  'padding-right': 'paddingRight',
  'padding-bottom': 'paddingBottom',
  'padding-left': 'paddingLeft',
  'margin-top': 'marginTop',
  'margin-right': 'marginRight',
  'margin-bottom': 'marginBottom',
  'margin-left': 'marginLeft',
  'position': 'position',
  'visibility': 'visibility',
  'opacity': 'opacity',
  'top': 'top',
  'right': 'right',
  'bottom': 'bottom',
  'left': 'left',
  'inset': 'inset',
  'display': 'display',
  'overflow': 'overflow',
  'overflow-x': 'overflowX',
  'overflow-y': 'overflowY',
};

// parseStaticColor tries parseAnyColor first, which already resolves every
// name in the shared CSS_NAMED_COLORS table. This fallback only carries the
// keywords parseAnyColor deliberately returns null for: the cascade needs
// `transparent` to read as an actual zero-alpha color.
const STATIC_NAMED_COLORS = {
  transparent: { r: 0, g: 0, b: 0, a: 0 },
};

// Named-color alternation for plucking a color token out of shorthand values
// (issue #359: a hardcoded 9-name list here silently dropped `purple`,
// `crimson`, `teal`, ... from border shorthands, so the side defaulted to
// neutral black and side-tab never fired on .html files). Derived from the
// same table parseAnyColor resolves against, so extraction and parsing can't
// drift apart. Longest-first so names containing other names as substrings
// (rebeccapurple) are matched whole.
const NAMED_COLOR_TOKENS = [...Object.keys(CSS_NAMED_COLORS), ...Object.keys(STATIC_NAMED_COLORS)]
  .sort((a, b) => b.length - a.length)
  .join('|');
const STATIC_COLOR_TOKEN_RE = new RegExp(
  `(?:rgba?\\([^)]+\\)|oklch\\([^)]+\\)|oklab\\([^)]+\\)|lch\\([^)]+\\)|lab\\([^)]+\\)|hsla?\\([^)]+\\)|hwb\\([^)]+\\)|#[0-9a-f]{3,8}\\b|\\b(?:${NAMED_COLOR_TOKENS})\\b)`,
  'i'
);

function splitCssList(value) {
  const parts = [];
  let depth = 0, quote = '', start = 0;
  for (let i = 0; i < value.length; i++) {
    const ch = value[i];
    if (quote) {
      if (ch === quote && value[i - 1] !== '\\') quote = '';
      continue;
    }
    if (ch === '"' || ch === "'") { quote = ch; continue; }
    if (ch === '(' || ch === '[') depth++;
    else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
    else if (ch === ',' && depth === 0) {
      parts.push(value.slice(start, i).trim());
      start = i + 1;
    }
  }
  const tail = value.slice(start).trim();
  if (tail) parts.push(tail);
  return parts;
}

function splitCssTokens(value) {
  const tokens = [];
  let depth = 0, quote = '', current = '';
  for (let i = 0; i < value.length; i++) {
    const ch = value[i];
    if (quote) {
      current += ch;
      if (ch === quote && value[i - 1] !== '\\') quote = '';
      continue;
    }
    if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
    if (ch === '(') { depth++; current += ch; continue; }
    if (ch === ')') { depth = Math.max(0, depth - 1); current += ch; continue; }
    if (/\s/.test(ch) && depth === 0) {
      if (current) { tokens.push(current); current = ''; }
      continue;
    }
    current += ch;
  }
  if (current) tokens.push(current);
  return tokens;
}

function cssPropToCamel(prop) {
  if (!prop) return prop;
  const mapped = STATIC_PROP_MAP[prop];
  if (mapped) return mapped;
  return prop.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());
}

function staticColorToCss(c) {
  if (!c) return '';
  if (c.a != null && c.a < 1) return `rgba(${c.r}, ${c.g}, ${c.b}, ${Number(c.a.toFixed(3))})`;
  return `rgb(${c.r}, ${c.g}, ${c.b})`;
}

function parseStaticColor(value) {
  const parsed = parseAnyColor(value);
  if (parsed) return parsed;
  const named = STATIC_NAMED_COLORS[String(value || '').trim().toLowerCase()];
  return named ? { ...named } : null;
}

function extractStaticColor(value) {
  if (!value) return '';
  const raw = String(value).trim();
  if (/^var\(/i.test(raw)) return raw;
  // color-mix(...) needs balanced-paren capture (its arguments regularly
  // contain nested var()/oklch() calls AND the keyword `transparent`, which
  // the flat regex below would otherwise pluck out of the middle of the
  // expression and report as the whole color).
  const mixStart = raw.search(/color-mix\(/i);
  if (mixStart !== -1) {
    let depth = 0;
    for (let i = raw.indexOf('(', mixStart); i < raw.length; i++) {
      if (raw[i] === '(') depth++;
      else if (raw[i] === ')') {
        depth--;
        if (depth === 0) return raw.slice(mixStart, i + 1);
      }
    }
    return '';
  }
  const colorLike = raw.match(STATIC_COLOR_TOKEN_RE);
  if (!colorLike) return '';
  return colorLike[0];
}

function normalizeStaticCssValue(prop, value, customProps, parentStyle, currentStyle = null) {
  let resolved = resolveVarRefs(String(value || '').trim(), customProps);
  if (resolved === 'inherit') return parentStyle?.[prop] || STATIC_DEFAULT_STYLE[prop] || '';
  const isModernBorderColor = /^border[A-Z][a-z]+Color$/.test(prop) && /^(?:oklch|oklab|lch|lab|hsl|hwb)\(/i.test(resolved);
  if (!isModernBorderColor && (/color$/i.test(prop) || prop === 'color' || prop === 'backgroundColor')) {
    const parsed = parseStaticColor(resolved);
    if (parsed) resolved = staticColorToCss(parsed);
  }
  if (prop === 'fontSize') {
    const base = parseFloat(parentStyle?.fontSize) || 16;
    const px = resolveLengthPx(resolved, base);
    if (px != null) resolved = `${px}px`;
  }
  if (prop === 'letterSpacing') {
    const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
    const px = resolveLengthPx(resolved, base);
    if (px != null) resolved = `${px}px`;
  }
  if (prop === 'lineHeight' && resolved !== 'normal') {
    const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
    const px = resolveLengthPx(resolved, base);
    if (px != null) resolved = `${px}px`;
  }
  return resolved;
}

function expandStaticBoxValues(tokens) {
  if (tokens.length === 0) return ['0px', '0px', '0px', '0px'];
  if (tokens.length === 1) return [tokens[0], tokens[0], tokens[0], tokens[0]];
  if (tokens.length === 2) return [tokens[0], tokens[1], tokens[0], tokens[1]];
  if (tokens.length === 3) return [tokens[0], tokens[1], tokens[2], tokens[1]];
  return [tokens[0], tokens[1], tokens[2], tokens[3]];
}

function parseStaticBorder(value) {
  const tokens = splitCssTokens(value);
  let width = '', color = '';
  for (const token of tokens) {
    if (!width && /^-?[\d.]+(?:px|rem|em|%)$/.test(token)) width = token;
    if (!color) color = extractStaticColor(token);
  }
  return { width, color };
}

function parseStaticFont(value) {
  const out = [];
  const slashParts = value.match(/(?:^|\s)([\d.]+(?:px|rem|em|%))(?:\/([^\s]+))?/);
  if (/\bitalic\b/i.test(value)) out.push(['fontStyle', 'italic']);
  const weight = value.match(/\b([1-9]00|bold|normal|lighter|bolder)\b/i);
  if (weight) out.push(['fontWeight', weight[1]]);
  if (slashParts) {
    out.push(['fontSize', slashParts[1]]);
    if (slashParts[2]) out.push(['lineHeight', slashParts[2]]);
    const familyStart = value.indexOf(slashParts[0]) + slashParts[0].length;
    const family = value.slice(familyStart).trim();
    if (family) out.push(['fontFamily', family]);
  }
  return out;
}

function parseStaticTransition(value) {
  const props = [];
  const timings = [];
  for (const item of splitCssList(value)) {
    const tokens = splitCssTokens(item);
    const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
    if (timing) timings.push(timing);
    const prop = tokens.find(token => /^[a-z-]+$/i.test(token) && !/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none)$/.test(token) && !/s$/.test(token));
    if (prop) props.push(prop);
  }
  return {
    property: props.join(', '),
    timing: timings.join(', '),
  };
}

function parseStaticAnimation(value) {
  const names = [];
  const timings = [];
  for (const item of splitCssList(value)) {
    const tokens = splitCssTokens(item);
    const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
    if (timing) timings.push(timing);
    const name = tokens.find(token =>
      /^[a-z_-][\w-]*$/i.test(token) &&
      !/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none|running|paused)$/.test(token)
    );
    if (name) names.push(name);
  }
  return {
    name: names.join(', '),
    timing: timings.join(', '),
  };
}

function expandStaticDeclaration(prop, value) {
  const p = prop.toLowerCase();
  const v = String(value || '').trim();
  if (!v) return [];
  if (p.startsWith('--')) return [[p, v]];
  if (p === 'background') {
    const out = [];
    const hasImage = /gradient|url\(/i.test(v);
    if (hasImage) out.push(['backgroundImage', v]);
    const beforeImage = hasImage ? v.split(/(?:repeating-)?(?:linear|radial|conic)-gradient\(|url\(/i)[0] : v;
    const color = extractStaticColor(hasImage ? beforeImage : v);
    if (color) out.push(['backgroundColor', color]);
    // The `background` shorthand resets every longhand it does not set.
    // Without this, `pre code { background: none }` leaves an earlier
    // `background: var(--surface)` color standing and the contrast checks
    // measure text against a surface the browser never paints. var() values
    // stay untouched: they may resolve to a color later in the pipeline.
    if (!color && !hasImage && !/var\(/i.test(v)) {
      out.push(['backgroundColor', 'rgba(0, 0, 0, 0)']);
      out.push(['backgroundImage', 'none']);
    }
    return out;
  }
  if (p === 'border') {
    const parsed = parseStaticBorder(v);
    const out = [];
    for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
      if (parsed.width) out.push([`border${side}Width`, parsed.width]);
      if (parsed.color) out.push([`border${side}Color`, parsed.color]);
    }
    return out;
  }
  if (p === 'outline') {
    // `outline` shorthand: width | style | color, in any order. Reuse the
    // border parser for width + color, then sniff a style keyword from the
    // tokens (solid|dashed|...). `outline: 0` (single-token zero) zeros
    // the width and effectively hides the outline.
    const tokens = splitCssTokens(v);
    const parsed = parseStaticBorder(v);
    const styleToken = tokens.find(t =>
      /^(none|hidden|solid|dashed|dotted|double|groove|ridge|inset|outset)$/i.test(t)
    );
    const out = [];
    if (parsed.width) out.push(['outlineWidth', parsed.width]);
    if (parsed.color) out.push(['outlineColor', parsed.color]);
    if (styleToken) out.push(['outlineStyle', styleToken.toLowerCase()]);
    // `outline: 0` with no other tokens: explicit zero width.
    if (!parsed.width && /^0(?:px|rem|em|%)?$/.test(v.trim())) {
      out.push(['outlineWidth', '0px']);
    }
    return out;
  }
  const sideMatch = p.match(/^border-(top|right|bottom|left)$/);
  if (sideMatch) {
    const parsed = parseStaticBorder(v);
    const side = sideMatch[1][0].toUpperCase() + sideMatch[1].slice(1);
    return [
      ...(parsed.width ? [[`border${side}Width`, parsed.width]] : []),
      ...(parsed.color ? [[`border${side}Color`, parsed.color]] : []),
    ];
  }
  if (p === 'border-width') {
    const vals = expandStaticBoxValues(splitCssTokens(v));
    return [
      ['borderTopWidth', vals[0]],
      ['borderRightWidth', vals[1]],
      ['borderBottomWidth', vals[2]],
      ['borderLeftWidth', vals[3]],
    ];
  }
  if (p === 'border-color') {
    const vals = expandStaticBoxValues(splitCssTokens(v));
    return [
      ['borderTopColor', vals[0]],
      ['borderRightColor', vals[1]],
      ['borderBottomColor', vals[2]],
      ['borderLeftColor', vals[3]],
    ];
  }
  if (p === 'padding') {
    const vals = expandStaticBoxValues(splitCssTokens(v));
    return [
      ['paddingTop', vals[0]],
      ['paddingRight', vals[1]],
      ['paddingBottom', vals[2]],
      ['paddingLeft', vals[3]],
    ];
  }
  if (p === 'margin') {
    const vals = expandStaticBoxValues(splitCssTokens(v));
    return [
      ['marginTop', vals[0]],
      ['marginRight', vals[1]],
      ['marginBottom', vals[2]],
      ['marginLeft', vals[3]],
    ];
  }
  if (p === 'font') return parseStaticFont(v);
  if (p === 'transition') {
    const parsed = parseStaticTransition(v);
    return [
      ...(parsed.property ? [['transitionProperty', parsed.property]] : []),
      ...(parsed.timing ? [['transitionTimingFunction', parsed.timing]] : []),
    ];
  }
  if (p === 'animation') {
    const parsed = parseStaticAnimation(v);
    return [
      ...(parsed.name ? [['animationName', parsed.name]] : []),
      ...(parsed.timing ? [['animationTimingFunction', parsed.timing]] : []),
    ];
  }
  const mapped = cssPropToCamel(p);
  if (STATIC_DEFAULT_STYLE[mapped] != null || STATIC_INHERITED_PROPS.has(mapped)) {
    return [[mapped, v]];
  }
  return [];
}

function compareStaticPriority(a, b) {
  if (!a) return true;
  if (!!b.important !== !!a.important) return !!b.important;
  if (!!b.inline !== !!a.inline) return !!b.inline;
  for (let i = 0; i < 3; i++) {
    if ((b.specificity[i] || 0) !== (a.specificity[i] || 0)) {
      return (b.specificity[i] || 0) > (a.specificity[i] || 0);
    }
  }
  return b.order >= a.order;
}

function staticSpecificity(selector) {
  const noWhere = selector.replace(/:where\([^)]*\)/g, '');
  const ids = (noWhere.match(/#[\w-]+/g) || []).length;
  const classes = (noWhere.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(?:\([^)]*\))?/g) || []).length;
  const stripped = noWhere
    .replace(/#[\w-]+/g, ' ')
    .replace(/\.[\w-]+|\[[^\]]+\]|:{1,2}[\w-]+(?:\([^)]*\))?/g, ' ')
    .replace(/[*>+~(),]/g, ' ');
  const types = (stripped.match(/\b[a-zA-Z][\w-]*\b/g) || []).length;
  return [ids, classes, types];
}

function applyStaticDeclaration(specified, node, prop, value, meta) {
  let map = specified.get(node);
  if (!map) { map = new Map(); specified.set(node, map); }
  for (const [expandedProp, expandedValue] of expandStaticDeclaration(prop, value)) {
    const existing = map.get(expandedProp);
    const next = { ...meta, prop: expandedProp, value: expandedValue };
    if (compareStaticPriority(existing, next)) map.set(expandedProp, next);
  }
}

function parseStaticStyleAttribute(styleText, orderBase = 0) {
  const decls = [];
  for (const part of String(styleText || '').split(';')) {
    const idx = part.indexOf(':');
    if (idx <= 0) continue;
    const prop = part.slice(0, idx).trim();
    let value = part.slice(idx + 1).trim();
    const important = /!important\s*$/i.test(value);
    value = value.replace(/\s*!important\s*$/i, '').trim();
    decls.push({ prop, value, important, order: orderBase + decls.length });
  }
  return decls;
}

function collectStaticCssRules(cssText, csstree) {
  const rules = [];
  let ast;
  try {
    ast = csstree.parse(cssText, { positions: false, parseValue: true, parseCustomProperty: false });
  } catch {
    return rules;
  }
  let order = 0;
  const walkList = (list, atRuleStack = []) => {
    list?.forEach?.(node => {
      if (node.type === 'Rule' && node.block) {
        if (atRuleStack.some(name => /keyframes$/i.test(name))) return;
        const selectorText = csstree.generate(node.prelude).trim();
        const declarations = [];
        node.block.children?.forEach?.(child => {
          if (child.type !== 'Declaration') return;
          declarations.push({
            prop: child.property,
            value: csstree.generate(child.value).trim(),
            important: !!child.important,
          });
        });
        for (const selector of splitCssList(selectorText)) {
          if (!selector) continue;
          // :hover rules can't be matched statically as-is (no interaction
          // state), but they carry real cascade weight while hovered. Tag
          // them and record a state-stripped selector so the hover pass can
          // find their targets; specificity stays computed from the ORIGINAL
          // selector (per CSS, :hover counts as a class).
          const isHover = /:hover\b/i.test(selector);
          let matchSelector = null;
          if (isHover) {
            matchSelector = selector.replace(/:hover\b/gi, '').trim();
            if (!matchSelector || /[>+~]\s*$/.test(matchSelector)) matchSelector = null;
            else matchSelector = matchSelector.replace(/(^|[\s>+~])(?=$|[\s>+~])/g, '$1*');
          }
          rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++, isHover, matchSelector });
        }
        return;
      }
      if (node.type === 'Atrule' && node.block) {
        const name = String(node.name || '').toLowerCase();
        if (name === 'media' || name === 'supports' || name === 'layer') {
          walkList(node.block.children, [...atRuleStack, name]);
        }
      }
    });
  };
  walkList(ast.children);
  return rules;
}

class StaticElement {
  constructor(node, doc) {
    this.node = node;
    this._doc = doc;
    this.nodeType = 1;
    this.tagName = String(node.name || '').toUpperCase();
    this.nodeName = this.tagName;
  }
  get parentElement() {
    let cur = this.node.parent;
    while (cur && cur.type !== 'tag') cur = cur.parent;
    return cur ? this._doc.wrap(cur) : null;
  }
  get previousElementSibling() {
    let cur = this.node.prev;
    while (cur && cur.type !== 'tag') cur = cur.prev;
    return cur ? this._doc.wrap(cur) : null;
  }
  get children() {
    return (this.node.children || []).filter(child => child.type === 'tag').map(child => this._doc.wrap(child));
  }
  get childNodes() {
    return (this.node.children || []).map(child => {
      if (child.type === 'text') return { nodeType: 3, textContent: child.data || '' };
      if (child.type === 'tag') return this._doc.wrap(child);
      return { nodeType: 8, textContent: child.data || '' };
    });
  }
  get textContent() {
    return this._doc.domutils.textContent(this.node);
  }
  get className() {
    return this.getAttribute('class') || '';
  }
  get id() {
    return this.getAttribute('id') || '';
  }
  getAttribute(name) {
    return this.node.attribs?.[name] ?? null;
  }
  querySelector(selector) {
    try {
      const found = this._doc.selectOne(selector, this.node.children || []);
      return found ? this._doc.wrap(found) : null;
    } catch {
      return null;
    }
  }
  querySelectorAll(selector) {
    try {
      return this._doc.selectAll(selector, this.node.children || []).map(node => this._doc.wrap(node));
    } catch {
      return [];
    }
  }
  closest(selector) {
    const matcher = this._doc.matcherFor(selector);
    let cur = this.node;
    while (cur && cur.type === 'tag') {
      try {
        if (matcher(cur)) return this._doc.wrap(cur);
      } catch {
        return null;
      }
      cur = cur.parent;
      while (cur && cur.type !== 'tag') cur = cur.parent;
    }
    return null;
  }
  contains(other) {
    let cur = other?.node || null;
    while (cur) {
      if (cur === this.node) return true;
      cur = cur.parent;
    }
    return false;
  }
}

class StaticDocument {
  constructor(root, modules) {
    this.root = root;
    this.selectAll = modules.selectAll;
    this.selectOne = modules.selectOne;
    this.compile = modules.compile;
    this.domutils = modules.domutils;
    this._wrappers = new WeakMap();
    this._compiledSelectors = new Map();
    this._styleMap = new WeakMap();
    this._hoverStyleMap = new WeakMap();
    this._accentDashPseudo = new WeakSet();
    // Elements whose ::before/::after paints a full-cover opaque surface
    // (position absolute/fixed + inset 0 + solid background). The pseudo is
    // the element's visible background for contrast purposes even though it
    // never joins the element cascade.
    this._pseudoSurface = new WeakMap();
  }
  wrap(node) {
    let wrapped = this._wrappers.get(node);
    if (!wrapped) {
      wrapped = new StaticElement(node, this);
      this._wrappers.set(node, wrapped);
    }
    return wrapped;
  }
  matcherFor(selector) {
    let matcher = this._compiledSelectors.get(selector);
    if (!matcher) {
      try {
        matcher = this.compile(selector);
      } catch (err) {
        // Cache the failure as a rethrower so a bad selector still reaches
        // closest()'s catch on every call, first and repeat alike.
        matcher = () => { throw err; };
      }
      this._compiledSelectors.set(selector, matcher);
    }
    return matcher;
  }
  querySelectorAll(selector) {
    try {
      return this.selectAll(selector, this.root.children || []).map(node => this.wrap(node));
    } catch {
      return [];
    }
  }
  querySelector(selector) {
    try {
      const found = this.selectOne(selector, this.root.children || []);
      return found ? this.wrap(found) : null;
    } catch {
      return null;
    }
  }
  get documentElement() {
    return this.querySelector('html');
  }
  get body() {
    return this.querySelector('body');
  }
  setStyle(node, style) {
    this._styleMap.set(node, style);
  }
  getStyle(el) {
    return this._styleMap.get(el.node) || makeStaticStyle();
  }
  setHoverStyle(node, style) {
    this._hoverStyleMap.set(node, style);
  }
  getHoverStyle(el) {
    return this._hoverStyleMap.get(el.node) || null;
  }
  setAccentDashPseudo(node) {
    this._accentDashPseudo.add(node);
  }
  hasAccentDashPseudo(el) {
    return this._accentDashPseudo.has(el.node);
  }
  setPseudoSurface(node, color) {
    this._pseudoSurface.set(node, color);
  }
  getPseudoSurface(el) {
    return this._pseudoSurface.get(el.node) || null;
  }
}

function makeStaticStyle(values = {}) {
  const style = { ...STATIC_DEFAULT_STYLE, ...values };
  style.getPropertyValue = (prop) => {
    const key = cssPropToCamel(prop);
    return style[key] || style[prop] || '';
  };
  return style;
}

function buildStaticWindow(staticDoc) {
  return {
    document: staticDoc,
    getComputedStyle: (el) => staticDoc.getStyle(el),
    getHoverStyle: (el) => staticDoc.getHoverStyle(el),
    hasAccentDashPseudo: (el) => staticDoc.hasAccentDashPseudo(el),
    getPseudoSurface: (el) => staticDoc.getPseudoSurface(el),
  };
}

function collectStaticCssText(root, fileDir, profile, filePath, modules) {
  const styleTexts = [];
  for (const styleEl of modules.selectAll('style', root.children || [])) {
    styleTexts.push(modules.domutils.textContent(styleEl));
  }
  const links = modules.selectAll('link', root.children || []);
  for (const link of links) {
    const rel = link.attribs?.rel || '';
    const href = link.attribs?.href || '';
    if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
    // Cache-busting hrefs (styles.css?v=3) resolve to the file, not to a
    // literal path with the query in it; a versioned link otherwise made the
    // whole stylesheet invisible to every element-level check.
    const cssPath = path.resolve(fileDir, href.split(/[?#]/)[0]);
    try {
      const css = profileStep(profile, {
        engine: 'static-html',
        phase: 'preprocess',
        ruleId: 'inline-linked-stylesheet',
        target: filePath,
        detail: href,
      }, () => fs.readFileSync(cssPath, 'utf-8'));
      styleTexts.push(css);
    } catch { /* skip unreadable */ }
  }
  return styleTexts.join('\n');
}

function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) {
  const specified = new Map();
  // Declarations from :hover rules, matched via their state-stripped
  // selectors. Merged per-property against the resting cascade in
  // computeNode — a hover declaration only takes effect if it would win
  // the cascade while the element is hovered (all resting rules still
  // apply in that state).
  const hoverSpecified = new Map();
  const rootCustomProps = collectCssCustomProps(cssText);
  const allNodes = modules.selectAll('*', root.children || []);
  const rules = profileStep(profile, {
    engine: 'static-html',
    phase: 'parse-css',
    ruleId: 'css-rules',
    target: filePath,
  }, () => collectStaticCssRules(cssText, modules.csstree));

  profileStep(profile, {
    engine: 'static-html',
    phase: 'selector-match',
    ruleId: 'css-selectors',
    target: filePath,
  }, () => {
    for (const rule of rules) {
      // ::before/::after rules can't join the element cascade (pseudo
      // elements aren't DOM nodes), but one shape matters to the eyebrow
      // check: the short chromatic "kicker dash" (content box 8-80px wide,
      // 1-6px tall, accent-colored fill). Mark the base-selector matches
      // so checkElementHeroEyebrow can see the dash.
      if (!rule.isHover) {
        const pm = rule.selector.match(/^(.+?)\s*::?(?:before|after)$/i);
        if (pm) {
          const decls = new Map();
          for (const d of rule.declarations) decls.set(d.prop.toLowerCase(), d.value);
          const w = cssLengthToPx(resolveVarRefs(decls.get('width') || decls.get('inline-size') || '', rootCustomProps));
          const h = cssLengthToPx(resolveVarRefs(decls.get('height') || decls.get('block-size') || '', rootCustomProps));
          if (w != null && h != null && w >= 8 && w <= 80 && h >= 1 && h <= 6) {
            const bgRaw = String(resolveVarRefs(decls.get('background-color') || decls.get('background') || '', rootCustomProps));
            const token = bgRaw.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b/i);
            const c = parseAnyColor(token ? token[0] : bgRaw);
            if (c && (c.a ?? 1) >= 0.1 && Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b) >= 30) {
              try {
                for (const node of modules.selectAll(pm[1], root.children || [])) {
                  staticDoc.setAccentDashPseudo(node);
                }
              } catch { /* unsupported base selector */ }
            }
          }
          // Full-cover surface pseudo: the CTA construction where the
          // element itself stays transparent and a ::before/::after with
          // position absolute/fixed + inset 0 (or all four sides 0, or
          // 100% width and height) plus an opaque background paints the
          // visible surface. Mark base-selector matches so the contrast
          // checks measure text against the surface the browser renders.
          const pseudoPos = String(decls.get('position') || '').toLowerCase();
          if (pseudoPos === 'absolute' || pseudoPos === 'fixed') {
            const zeroLen = v => v != null && /^0(?:px)?$/.test(String(v).trim());
            const insetRaw = String(decls.get('inset') || '').trim();
            const coversBox = (insetRaw !== '' && insetRaw.split(/\s+/).every(t => /^0(?:px)?$/.test(t)))
              || ['top', 'right', 'bottom', 'left'].every(side => zeroLen(decls.get(side)))
              || (String(decls.get('width') || '').trim() === '100%'
                && String(decls.get('height') || '').trim() === '100%');
            if (coversBox && decls.has('content')) {
              const surfRaw = String(resolveVarRefs(decls.get('background-color') || decls.get('background') || '', rootCustomProps));
              const surfToken = surfRaw.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b/i);
              const surf = parseAnyColor(surfToken ? surfToken[0] : surfRaw);
              if (surf && (surf.a ?? 1) >= 0.9 && !/gradient/i.test(surfRaw)) {
                try {
                  for (const node of modules.selectAll(pm[1], root.children || [])) {
                    staticDoc.setPseudoSurface(node, surf);
                  }
                } catch { /* unsupported base selector */ }
              }
            }
          }
          continue;
        }
      }
      const matchSelector = rule.isHover ? rule.matchSelector : rule.selector;
      if (!matchSelector) continue;
      let matched;
      try {
        matched = modules.selectAll(matchSelector, root.children || []);
      } catch {
        recordProfileEvent(profile, {
          engine: 'static-html',
          phase: 'selector-match',
          ruleId: 'unsupported-selector',
          target: filePath,
          ms: 0,
          findings: 0,
          detail: matchSelector,
        });
        continue;
      }
      for (const node of matched) {
        for (const decl of rule.declarations) {
          applyStaticDeclaration(rule.isHover ? hoverSpecified : specified, node, decl.prop, decl.value, {
            important: decl.important,
            specificity: rule.specificity,
            order: rule.order,
            inline: false,
          });
        }
      }
    }

    let inlineOrder = rules.length + 1;
    for (const node of allNodes) {
      const styleText = node.attribs?.style;
      if (!styleText) continue;
      for (const decl of parseStaticStyleAttribute(styleText, inlineOrder)) {
        applyStaticDeclaration(specified, node, decl.prop, decl.value, {
          important: decl.important,
          specificity: [1, 0, 0],
          order: decl.order,
          inline: true,
        });
      }
      inlineOrder += 1000;
    }
  });

  const computeNode = (node, parentStyle = null, parentCustom = new Map()) => {
    const specifiedMap = specified.get(node) || new Map();
    const customProps = new Map(parentCustom);
    for (const [prop, decl] of specifiedMap) {
      if (prop.startsWith('--')) customProps.set(prop, resolveVarRefs(decl.value, customProps));
    }
    const values = {};
    for (const prop of Object.keys(STATIC_DEFAULT_STYLE)) {
      if (STATIC_INHERITED_PROPS.has(prop) && parentStyle?.[prop] != null) values[prop] = parentStyle[prop];
      else values[prop] = STATIC_DEFAULT_STYLE[prop];
    }
    for (const [prop, decl] of specifiedMap) {
      if (prop.startsWith('--')) continue;
      values[prop] = normalizeStaticCssValue(prop, decl.value, customProps, parentStyle, values);
    }
    const style = makeStaticStyle(values);
    staticDoc.setStyle(node, style);

    // Hover pass: limited to the two properties the hover-contrast check
    // consumes. A hover declaration wins only if it beats the resting
    // winner for that property under normal cascade rules (specificity /
    // order / importance) — exactly what a browser computes while the
    // element is hovered.
    const hoverMap = hoverSpecified.get(node);
    if (hoverMap) {
      let hoverValues = null;
      for (const prop of ['color', 'backgroundColor']) {
        const hoverDecl = hoverMap.get(prop);
        if (!hoverDecl) continue;
        const restingDecl = specifiedMap.get(prop);
        if (!compareStaticPriority(restingDecl, hoverDecl)) continue;
        const next = normalizeStaticCssValue(prop, hoverDecl.value, customProps, parentStyle, values);
        if (next === values[prop]) continue;
        if (!hoverValues) hoverValues = { ...values };
        hoverValues[prop] = next;
      }
      if (hoverValues) staticDoc.setHoverStyle(node, makeStaticStyle(hoverValues));
    }

    for (const child of node.children || []) {
      if (child.type === 'tag') computeNode(child, style, customProps);
    }
  };

  profileStep(profile, {
    engine: 'static-html',
    phase: 'cascade',
    ruleId: 'compute-styles',
    target: filePath,
  }, () => {
    for (const child of root.children || []) {
      if (child.type === 'tag') computeNode(child);
    }
  });
}

export {
  BORDER_SHORTHAND_RE,
  NAMED_COLORS,
  normalizeColorForCheck,
  buildBorderOverrideMap,
  unwrapCssAtLayer,
  STATIC_INHERITED_PROPS,
  STATIC_DEFAULT_STYLE,
  STATIC_PROP_MAP,
  STATIC_NAMED_COLORS,
  splitCssList,
  splitCssTokens,
  cssPropToCamel,
  staticColorToCss,
  parseStaticColor,
  extractStaticColor,
  normalizeStaticCssValue,
  expandStaticBoxValues,
  parseStaticBorder,
  parseStaticFont,
  parseStaticTransition,
  parseStaticAnimation,
  expandStaticDeclaration,
  compareStaticPriority,
  staticSpecificity,
  applyStaticDeclaration,
  parseStaticStyleAttribute,
  collectStaticCssRules,
  StaticElement,
  StaticDocument,
  makeStaticStyle,
  buildStaticWindow,
  collectStaticCssText,
  buildStaticStyleMap,
};
