/**
 * /tokens/colors — Figma Variables Pro export.
 *
 * Reads the live --a-* color token surface, walks var()/light-dark() chains
 * symbolically to keep cssVar identity in references, converts every literal
 * OKLCH value through OKLab → linear-sRGB → sRGB → HSL in JS floats (no
 * canvas quantization), and assembles a Variables Pro JSON document.
 *
 * Public surface:
 *   buildFigmaJson({ format })  → JSON object ready for JSON.stringify
 *   getExportStats()            → counts + reference-coverage stats
 *   downloadJson(filename, data) → trigger browser file save
 *   copyJson(data)              → copy stringified JSON to clipboard
 */

/* ─── CSS Object Model scan ──────────────────────────────────────────────── */
function buildCssVarMap() {
  const map = new Map();
  for (const sheet of document.styleSheets) {
    let rules;
    try { rules = sheet.cssRules; } catch { continue; }
    walkRules(rules, map);
  }
  return map;
}
// Only GLOBAL-scope token definitions are authoritative. Attribute-scoped
// selectors like `[color="info"]` conditionally override --a-fg etc. and
// must not leak into the default-state token map.
function isAuthoritativeSelector(sel) {
  return /(^|,)\s*(:root|theme-ui|\[data-theme\])\b/i.test(sel);
}
function walkRules(rules, map) {
  for (const rule of rules) {
    if (rule instanceof CSSStyleRule) {
      if (!isAuthoritativeSelector(rule.selectorText)) continue;
      const decl = rule.style;
      for (let i = 0; i < decl.length; i++) {
        const name = decl.item(i);
        if (!name.startsWith('--a-')) continue;
        const raw = decl.getPropertyValue(name).trim();
        if (raw) map.set(name, raw);
      }
    } else if (rule.cssRules) {
      walkRules(rule.cssRules, map);
    }
  }
}

/* ─── CSS expression parsers ─────────────────────────────────────────────── */
function splitArgs(s) {
  const out = [];
  let depth = 0, start = 0;
  for (let i = 0; i < s.length; i++) {
    const c = s[i];
    if (c === '(') depth++;
    else if (c === ')') depth--;
    else if (c === ',' && depth === 0) { out.push(s.slice(start, i).trim()); start = i + 1; }
  }
  out.push(s.slice(start).trim());
  return out;
}
function matchSingleVar(expr) {
  const m = expr.match(/^var\(\s*(--[\w-]+)\s*(?:,\s*(.+))?\s*\)$/s);
  if (!m) return null;
  return { name: m[1], fallback: m[2]?.trim() ?? null };
}
function matchLightDark(expr) {
  if (!expr.startsWith('light-dark(')) return null;
  if (!expr.endsWith(')')) return null;
  const inner = expr.slice('light-dark('.length, -1);
  const args = splitArgs(inner);
  if (args.length !== 2) return null;
  return { light: args[0], dark: args[1] };
}
function isColorLiteral(expr) {
  return /^(oklch|oklab|rgb|rgba|hsl|hsla|#|color\()/i.test(expr);
}

/* ─── Symbolic chain walker ──────────────────────────────────────────────── */
function walkChain(startCssVar, mode, map) {
  const hops = [];
  const visited = new Set();
  let current = startCssVar;

  while (true) {
    if (visited.has(current)) return { terminal: 'cycle', hops };
    visited.add(current);
    hops.push(current);

    const rhs = map.get(current);
    if (!rhs) return { terminal: 'unresolved', hops };

    let expr = rhs;
    let jumped = false;
    for (let i = 0; i < 4; i++) {
      const ld = matchLightDark(expr);
      if (ld) { expr = (mode === 'light' ? ld.light : ld.dark).trim(); continue; }
      const v = matchSingleVar(expr);
      if (v) {
        if (map.has(v.name)) { current = v.name; jumped = true; break; }
        if (v.fallback) { expr = v.fallback; continue; }
        return { terminal: 'unresolved', hops };
      }
      break;
    }
    if (jumped) continue;

    if (isColorLiteral(expr)) {
      return { terminal: 'literal', leafCssVar: current, leafLiteral: expr, hops };
    }
    return { terminal: 'inline-literal', leafCssVar: current, leafLiteral: expr, hops };
  }
}

/* ─── Probe-based value resolver ─────────────────────────────────────────── */
function withProbes(work) {
  const root = document.createElement('div');
  root.style.cssText = 'position:fixed;left:-99999px;top:0;width:0;height:0;overflow:hidden;';
  document.body.appendChild(root);
  const make = scheme => {
    const h = document.createElement('div');
    h.style.colorScheme = `only ${scheme}`;
    root.appendChild(h);
    return h;
  };
  const lightHost = make('light');
  const darkHost  = make('dark');
  try {
    return work({ lightHost, darkHost });
  } finally {
    document.body.removeChild(root);
  }
}
function readComputed(host, cssVar) {
  const p = document.createElement('div');
  p.style.color = `var(${cssVar})`;
  host.appendChild(p);
  const v = getComputedStyle(p).color;
  host.removeChild(p);
  return v;
}

/* ─── Color-literal parser + math (OKLCH→OKLab→linear sRGB→sRGB→HSL) ───── */
function parseComputedColor(str) {
  str = (str || '').trim();
  let m;
  if ((m = str.match(/^oklch\(\s*([\d.+-]+%?)\s+([\d.+-]+%?)\s+([\d.+-]+)(deg|rad|turn|grad)?\s*(?:\/\s*([\d.+-]+%?))?\s*\)$/i))) {
    let L = parseFloat(m[1]); if (m[1].endsWith('%')) L /= 100;
    let C = parseFloat(m[2]); if (m[2].endsWith('%')) C = C / 100 * 0.4;
    let h = parseFloat(m[3]);
    switch (m[4]) { case 'rad': h = h * 180 / Math.PI; break;
                    case 'turn': h *= 360; break;
                    case 'grad': h *= 0.9; break; }
    let a = 1;
    if (m[5]) { a = parseFloat(m[5]); if (m[5].endsWith('%')) a /= 100; }
    return { space: 'oklch', L, C, h, alpha: a };
  }
  if ((m = str.match(/^oklab\(\s*([\d.+-]+%?)\s+([\d.+-]+%?)\s+([\d.+-]+%?)\s*(?:\/\s*([\d.+-]+%?))?\s*\)$/i))) {
    let L = parseFloat(m[1]); if (m[1].endsWith('%')) L /= 100;
    let a = parseFloat(m[2]); if (m[2].endsWith('%')) a = a / 100 * 0.4;
    let b = parseFloat(m[3]); if (m[3].endsWith('%')) b = b / 100 * 0.4;
    let A = 1;
    if (m[4]) { A = parseFloat(m[4]); if (m[4].endsWith('%')) A /= 100; }
    return { space: 'oklab', L, a, b, alpha: A };
  }
  if ((m = str.match(/^rgba?\(\s*([\d.+-]+)[,\s]+([\d.+-]+)[,\s]+([\d.+-]+)\s*(?:[,/]\s*([\d.+-]+%?))?\s*\)$/i))) {
    const a = m[4] ? (m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4])) : 1;
    return { space: 'srgb', r: parseFloat(m[1]) / 255, g: parseFloat(m[2]) / 255, b: parseFloat(m[3]) / 255, alpha: a };
  }
  if ((m = str.match(/^color\(\s*srgb\s+([\d.+-]+)\s+([\d.+-]+)\s+([\d.+-]+)\s*(?:\/\s*([\d.+-]+%?))?\s*\)$/i))) {
    const a = m[4] ? (m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4])) : 1;
    return { space: 'srgb', r: parseFloat(m[1]), g: parseFloat(m[2]), b: parseFloat(m[3]), alpha: a };
  }
  return null;
}
function oklchToLab({ L, C, h }) {
  const rad = h * Math.PI / 180;
  return { L, a: C * Math.cos(rad), b: C * Math.sin(rad) };
}
// Björn Ottosson's OKLab → linear-sRGB matrix.
function oklabToLinearSrgb({ L, a, b }) {
  const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
  const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
  const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
  const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
  return {
    r:  4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
    g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
    b: -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
  };
}
function linearToSrgb(x) {
  const sign = x < 0 ? -1 : 1;
  const ax = Math.abs(x);
  const v = ax <= 0.0031308 ? 12.92 * ax : 1.055 * Math.pow(ax, 1 / 2.4) - 0.055;
  return sign * v;
}
const clamp01 = x => Math.min(1, Math.max(0, x));
function colorToFloatSrgb(parsed) {
  if (!parsed) return null;
  if (parsed.space === 'srgb') {
    return { r: clamp01(parsed.r), g: clamp01(parsed.g), b: clamp01(parsed.b), alpha: parsed.alpha, gamut: 'in' };
  }
  const lab = parsed.space === 'oklab'
    ? { L: parsed.L, a: parsed.a, b: parsed.b }
    : oklchToLab(parsed);
  const lin = oklabToLinearSrgb(lab);
  const E = 1e-3;
  const gamutIn = lin.r >= -E && lin.r <= 1 + E && lin.g >= -E && lin.g <= 1 + E && lin.b >= -E && lin.b <= 1 + E;
  return {
    r: clamp01(linearToSrgb(lin.r)),
    g: clamp01(linearToSrgb(lin.g)),
    b: clamp01(linearToSrgb(lin.b)),
    alpha: parsed.alpha,
    gamut: gamutIn ? 'in' : 'clamped',
  };
}
function srgbToHsl({ r, g, b }) {
  const max = Math.max(r, g, b), min = Math.min(r, g, b);
  const L = (max + min) / 2;
  let H = 0, S = 0;
  const d = max - min;
  if (d > 1e-12) {
    S = L > 0.5 ? d / (2 - max - min) : d / (max + min);
    if      (max === r) H = ((g - b) / d) + (g < b ? 6 : 0);
    else if (max === g) H = ((b - r) / d) + 2;
    else                H = ((r - g) / d) + 4;
    H *= 60;
  }
  return { h: H, s: S * 100, l: L * 100 };
}

/* ─── Output formatters ──────────────────────────────────────────────────── */
const dp = (n, d = 4) => Number.isFinite(n) ? Number(n.toFixed(d)) : 0;
function formatHsl(srgb) {
  const { h, s, l } = srgbToHsl(srgb);
  const a = srgb.alpha;
  return a < 0.999
    ? `hsla(${dp(h, 2)}, ${dp(s, 4)}%, ${dp(l, 4)}%, ${dp(a, 4)})`
    : `hsl(${dp(h, 2)}, ${dp(s, 4)}%, ${dp(l, 4)}%)`;
}
function formatHex(srgb) {
  const to = x => Math.round(x * 255).toString(16).padStart(2, '0');
  return srgb.alpha < 0.999
    ? `rgba(${Math.round(srgb.r * 255)}, ${Math.round(srgb.g * 255)}, ${Math.round(srgb.b * 255)}, ${dp(srgb.alpha, 4)})`
    : '#' + to(srgb.r) + to(srgb.g) + to(srgb.b);
}
// Float-RGB literal matches Figma's parseColor floatRgbRegex —
//   /^\{\s*r:\s*[\d\.]+,\s*g:\s*[\d\.]+,\s*b:\s*[\d\.]+(,\s*opacity:\s*[\d\.]+)?\s*\}$/
// Sub-byte precision (theoretically up to f64), but the canonical sample
// plugin's importer has a bug (it calls JSON.parse on unquoted keys);
// permissive parsers like Variables Pro may accept it.
function formatFloatRgb(srgb) {
  const f = x => dp(x, 6);
  return srgb.alpha < 0.999
    ? `{r: ${f(srgb.r)}, g: ${f(srgb.g)}, b: ${f(srgb.b)}, opacity: ${dp(srgb.alpha, 4)}}`
    : `{r: ${f(srgb.r)}, g: ${f(srgb.g)}, b: ${f(srgb.b)}}`;
}

/* ─── Classifier + Figma key derivation ──────────────────────────────────── */
function isColorCssVar(name, rhs, map) {
  if (rhs.startsWith('#')) return true;
  if (/^(oklch|oklab|rgb|rgba|hsl|hsla|color)\(/.test(rhs)) return true;
  if (/^(var\(|light-dark\()/.test(rhs)) {
    const chain = walkChain(name, 'light', map);
    if (chain.terminal !== 'literal') return false;
    return isColorLiteral(chain.leafLiteral);
  }
  return false;
}

// Typography color tokens (--a-display-color, --a-title-color, etc.) get
// folded into a single 'text' group rather than scattered as 1-leaf groups.
const TEXT_FAMILIES = new Set([
  'display', 'title', 'heading', 'kicker', 'label', 'caption',
  'deck', 'section', 'subsection', 'metric', 'body', 'code',
]);

// Identify a cssVar as a primitive (tonal-ramp source) and return its
// ADAPTIVE figma key — polarity-collapsed (no -tint/-shade suffix).
// Both `--a-neutral-50-tint` and `--a-neutral-50-shade` (and the adaptive
// `--a-neutral-50` wrapper if present) map to `{neutral.50}`.
// Returns null if cssVar isn't a primitive shape.
function primitiveFigmaKey(cssVar) {
  const body = cssVar.replace(/^--a-/, '');
  let m;
  // Step+polarity+scrim (fine or coarse): --a-{fam}-{N}-{tint|shade}-scrim
  if ((m = body.match(/^([a-z]+)-(\d{1,2})-(?:tint|shade)-scrim$/))) return { group: m[1], leaf: `scrim-${m[2]}` };
  // Step+polarity (fine or coarse): --a-{fam}-{N}-{tint|shade}
  if ((m = body.match(/^([a-z]+)-(\d{1,2})-(?:tint|shade)$/)))       return { group: m[1], leaf: m[2] };
  // Adaptive scrim wrapper: --a-{fam}-{N}-scrim
  if ((m = body.match(/^([a-z]+)-(\d{1,2})-scrim$/)))                return { group: m[1], leaf: `scrim-${m[2]}` };
  // Adaptive step wrapper: --a-{fam}-{N}
  if ((m = body.match(/^([a-z]+)-(\d{1,2})$/)))                      return { group: m[1], leaf: m[2] };
  // Data slot: --a-data-{N}
  if ((m = body.match(/^data-(\d{1,2})$/)))                          return { group: 'data', leaf: m[1] };
  return null;
}

// For an adaptive primitive name, return the raw cssVars to probe in each
// mode. Light reads from the -tint primitive, Dark from the -shade primitive.
// Data slots are mode-aware via @property so the same cssVar is probed twice.
function primitiveProbeCssVars({ group, leaf }) {
  if (group === 'data') {
    const v = `--a-data-${leaf}`;
    return { light: v, dark: v };
  }
  if (leaf.startsWith('scrim-')) {
    const step = leaf.slice('scrim-'.length);
    return {
      light: `--a-${group}-${step}-tint-scrim`,
      dark:  `--a-${group}-${step}-shade-scrim`,
    };
  }
  return {
    light: `--a-${group}-${leaf}-tint`,
    dark:  `--a-${group}-${leaf}-shade`,
  };
}

// Figma key for any cssVar — primitives use the adaptive (polarity-collapsed)
// key; consumables get a camelCased fallback. Typography color overrides
// (display/title/heading/...) consolidate under a 'text' group.
function figmaKey(cssVar) {
  const prim = primitiveFigmaKey(cssVar);
  if (prim) return prim;

  const body = cssVar.replace(/^--a-/, '');
  const parts = body.split('-');

  // --a-{family}-color  (e.g. --a-display-color → text.display)
  if (parts.length === 2 && parts[1] === 'color' && TEXT_FAMILIES.has(parts[0])) {
    return { group: 'text', leaf: parts[0] };
  }

  const leaf = parts.slice(1).map((p, i) => i === 0 ? p : p[0].toUpperCase() + p.slice(1)).join('');
  return { group: parts[0], leaf: leaf || 'default' };
}

/* ─── Extraction pipeline ────────────────────────────────────────────────── */
function extract() {
  const map = buildCssVarMap();
  const colorEntries = [...map.entries()].filter(([k, v]) => isColorCssVar(k, v, map));

  // A cssVar is a real adaptive primitive iff its name shape matches a
  // tonal-primitive AND its chain terminates at a literal that maps to
  // the SAME adaptive key. Aliases like --a-canvas-0 → --a-neutral-0 have
  // a matching name shape but resolve to a different family, so they're
  // consumables, not primitives.
  const isAdaptivePrimitive = (cssVar) => {
    const startKey = primitiveFigmaKey(cssVar);
    if (!startKey) return false;
    for (const mode of ['light', 'dark']) {
      const chain = walkChain(cssVar, mode, map);
      if (chain.terminal !== 'literal') continue;
      const leafKey = primitiveFigmaKey(chain.leafCssVar);
      if (leafKey && leafKey.group === startKey.group && leafKey.leaf === startKey.leaf) return true;
    }
    return false;
  };
  const consumables = colorEntries
    .filter(([k]) => !isAdaptivePrimitive(k))
    .map(([k]) => k);

  return withProbes(({ lightHost, darkHost }) => {
    // Walk every consumable's chain in BOTH modes. The chain bottoms out on
    // a raw polarity-suffixed primitive (e.g. --a-neutral-50-tint in light,
    // --a-neutral-60-shade in dark). We map each to its adaptive primitive
    // key so both modes alias the same Figma variable name (e.g. {neutral.50}).
    const consumableResolutions = consumables.map(cssVar => {
      const lightChain = walkChain(cssVar, 'light', map);
      const darkChain  = walkChain(cssVar, 'dark',  map);
      const lightPrimKey = lightChain.leafCssVar ? primitiveFigmaKey(lightChain.leafCssVar) : null;
      const darkPrimKey  = darkChain.leafCssVar  ? primitiveFigmaKey(darkChain.leafCssVar)  : null;
      // Inline-resolved values (used when the chain doesn't reach a primitive
      // we'd emit — e.g. for tokens with composed/calc expressions).
      const lightSrgb = colorToFloatSrgb(parseComputedColor(readComputed(lightHost, cssVar)));
      const darkSrgb  = colorToFloatSrgb(parseComputedColor(readComputed(darkHost,  cssVar)));
      return { cssVar, lightChain, darkChain, lightPrimKey, darkPrimKey, lightSrgb, darkSrgb };
    });

    // Enumerate every real adaptive primitive (reusing the same classifier
    // that decided which cssVars are NOT consumables).
    const discovered = new Map(); // 'group.leaf' → {group, leaf}
    const recordPrim = (k) => { if (k) discovered.set(`${k.group}.${k.leaf}`, k); };
    for (const cssVar of map.keys()) {
      if (isAdaptivePrimitive(cssVar)) recordPrim(primitiveFigmaKey(cssVar));
    }
    // Defensive: include anything a consumable chain reached that we missed.
    for (const c of consumableResolutions) {
      recordPrim(c.lightPrimKey);
      recordPrim(c.darkPrimKey);
    }

    // For each adaptive primitive, probe the underlying -tint cssVar in
    // light mode and the -shade cssVar in dark mode. (Data slots probe the
    // same mode-aware cssVar in both modes.)
    const primitiveResolutions = [...discovered.values()]
      .sort((a, b) => `${a.group}.${a.leaf}`.localeCompare(`${b.group}.${b.leaf}`))
      .map(key => {
        const probes = primitiveProbeCssVars(key);
        return {
          key,
          lightSrgb: colorToFloatSrgb(parseComputedColor(readComputed(lightHost, probes.light))),
          darkSrgb:  colorToFloatSrgb(parseComputedColor(readComputed(darkHost,  probes.dark))),
        };
      });

    return { primitiveResolutions, consumableResolutions };
  });
}

function formatValue(srgb, format) {
  switch (format) {
    case 'hsl':       return formatHsl(srgb);
    case 'float-rgb': return formatFloatRgb(srgb);
    case 'hex':       return formatHex(srgb);
    case 'dtcg':     {
      const f = x => dp(x, 6);
      const to = x => Math.round(x * 255).toString(16).padStart(2, '0');
      return {
        colorSpace: 'srgb',
        components: [f(srgb.r), f(srgb.g), f(srgb.b)],
        alpha: dp(srgb.alpha, 4),
        hex: '#' + to(srgb.r) + to(srgb.g) + to(srgb.b),
      };
    }
    default:          return formatHex(srgb);
  }
}
function buildEntry({ refKey, srgb, format }) {
  // DTCG mode: minimal W3C shape, no Variables-Pro extensions.
  if (format === 'dtcg') {
    const base = { $type: 'color' };
    if (refKey) return { ...base, $value: `{${refKey.group}.${refKey.leaf}}` };
    return { ...base, $value: srgb ? formatValue(srgb, format) : { colorSpace: 'srgb', components: [0, 0, 0], alpha: 1, hex: '#000000' } };
  }
  // Variables-Pro legacy shape — string $value + Figma plugin extension fields.
  const base = { $scopes: ['ALL_SCOPES'], $hiddenFromPublishing: true, $type: 'color' };
  if (refKey) {
    return { ...base, $libraryName: '', $collectionName: 'Tokens', $value: `{${refKey.group}.${refKey.leaf}}` };
  }
  if (!srgb) return { ...base, $value: '#000000' };
  return { ...base, $value: formatValue(srgb, format) };
}
function buildMode(modeKey, format, { primitiveResolutions, consumableResolutions }) {
  const tree = {};
  // Primitives — emit by adaptive identity, value differs per mode
  for (const p of primitiveResolutions) {
    const { group, leaf } = p.key;
    const srgb = modeKey === 'light' ? p.lightSrgb : p.darkSrgb;
    tree[group] ??= {};
    tree[group][leaf] = buildEntry({ srgb, format });
  }
  // Consumables — alias the primitive the chain ends on. Light and Dark
  // may alias different primitives (e.g. canvas.strong → neutral.50 in
  // Light, neutral.60 in Dark — that's how the source CSS works).
  for (const c of consumableResolutions) {
    const { group, leaf } = figmaKey(c.cssVar);
    const chain   = modeKey === 'light' ? c.lightChain   : c.darkChain;
    const primKey = modeKey === 'light' ? c.lightPrimKey : c.darkPrimKey;
    const srgb    = modeKey === 'light' ? c.lightSrgb    : c.darkSrgb;
    const refKey = (chain.terminal === 'literal' && primKey)
      ? primKey
      : null;
    tree[group] ??= {};
    tree[group][leaf] = buildEntry({ refKey, srgb, format });
  }
  return tree;
}

/* ─── Public API ─────────────────────────────────────────────────────────── */
//   format: 'dtcg'     — Figma's NATIVE direct-import format. W3C Design
//                        Tokens 1.0 spec. $value is a structured object
//                        {colorSpace, components, alpha, hex}. Emits ONE
//                        FILE PER MODE — call buildDtcgFiles() for both.
//           'hex'      — Variables Pro / jake-figma legacy. $value is a
//                        '#hex' or 'rgba(int, int, int, float)' string.
//                        Modes wrapped under [{Tokens: {modes: {Light, Dark}}}].
//           'float-rgb' — Legacy float-RGB literal string (permissive parsers only).
//           'hsl'      — Legacy decimal-HSL (non-standard).
export function buildFigmaJson({ format = 'dtcg' } = {}) {
  const ex = extract();
  if (format === 'dtcg') {
    // For DTCG, default to a SINGLE FILE containing the Light mode (Figma's
    // canonical shape). Use buildDtcgFiles() to get both modes as separate
    // files.
    return buildMode('light', 'dtcg', ex);
  }
  // Variables Pro legacy: nested modes wrapper, string $value.
  return [{
    Tokens: {
      modes: {
        Light: buildMode('light', format, ex),
        Dark:  buildMode('dark',  format, ex),
      },
    },
  }];
}

// DTCG emits one file per mode. Returns a list of {filename, body} pairs
// suitable for sequential downloads. Filenames are simple mode names so
// that Figma's import (which derives the mode name from the filename
// basename minus `.tokens.json`) creates modes called "light" and "dark".
export function buildDtcgFiles() {
  const ex = extract();
  return [
    { filename: `light.tokens.json`, body: buildMode('light', 'dtcg', ex) },
    { filename: `dark.tokens.json`,  body: buildMode('dark',  'dtcg', ex) },
  ];
}

export function getExportStats() {
  const ex = extract();
  let refCovL = 0, refCovD = 0, clampedL = 0, clampedD = 0;
  for (const c of ex.consumableResolutions) {
    if (c.lightPrimKey) refCovL++;
    if (c.darkPrimKey)  refCovD++;
    if (c.lightSrgb?.gamut === 'clamped') clampedL++;
    if (c.darkSrgb?.gamut  === 'clamped') clampedD++;
  }
  for (const p of ex.primitiveResolutions) {
    if (p.lightSrgb?.gamut === 'clamped') clampedL++;
    if (p.darkSrgb?.gamut  === 'clamped') clampedD++;
  }
  return {
    primitives: ex.primitiveResolutions.length,
    consumables: ex.consumableResolutions.length,
    referenceCoverageLight: refCovL,
    referenceCoverageDark:  refCovD,
    gamutClampedLight: clampedL,
    gamutClampedDark:  clampedD,
  };
}

export function downloadJson(filename, data) {
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  // The site's SPA router listens for document-level click events on
  // anchors to trigger client-side navigation and would try to
  // history.pushState() the blob URL (which throws cross-origin).
  // Dispatch a non-bubbling synthetic click so the event never reaches
  // document — the browser's default download action still fires because
  // it's tied to the event target's anchor element, not propagation.
  a.dispatchEvent(new MouseEvent('click', { bubbles: false, cancelable: true, view: window }));
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}

export async function copyJson(data) {
  await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
}
