// Deterministic accessibility (a11y) baseline from a Screen-IR (Epic #750, Issue #812).
//
// Pure domain: a Screen-IR (#752) — per-screen kept-node trees plus the additive `textColor` /
// `backgroundColor` / `boundingBox` fields — is reduced, with NO model and NO IO, to per-screen a11y
// test items reusing #754's `StructuralTestItem` shape so they compose into the figma-snapshot QI run
// additively through `deriveScreenTestBaseline`'s `extraItems` seam (ALONGSIDE #811's nav items):
//   • accessible-name presence — an interactive node (button/input/link) with neither visible text
//     nor a descriptive name → a "needs an accessible name" item;
//   • colour-contrast — every TEXT node with a resolvable text colour and a nearest-ancestor
//     background colour → an exact WCAG relative-luminance contrast item (meets / below AA), with
//     the stricter AA-normal 4.5 threshold used when "large text" cannot be determined from the IR;
//     an uncomputable pairing (missing/malformed colour) → a coverage-notice item, never a crash;
//   • focus / reading order — ≥ 2 interactive nodes with bounding boxes → one reading-order item
//     (top-to-bottom, then left-to-right);
//   • minimum target size — an interactive node whose bounding box is smaller than 24×24 (WCAG 2.2
//     AA 2.5.8) → a target-size item;
//   • image-fill alt-text — a node carrying image fills → an alt-text expectation item.
//
// Generic by construction: every rule reads only structural shape (interactionHint, text, colour,
// box) — no screen name, layout, mask style, or copy is special-cased. Deterministic: items are
// emitted in a stable depth-first / stable-sorted order and carry no timestamp, so the same IR yields
// a byte-identical result. The baseline is model-free and stands alone; vision augmentation (#810) is
// layered separately and never replaces these items.
import { parseHexRgb } from "./color.js";
const MIN_TARGET_SIZE = 24;
const AA_NORMAL = 4.5;
const AA_LARGE = 3.0;
// Generous per-screen item cap so a pathologically dense real screen (thousands of TEXT nodes →
// thousands of contrast checks) cannot make the a11y baseline unbounded in memory/output. Small
// fixtures stay far below it, so it is invisible except on huge boards. Deterministic: the first
// `MAX_A11Y_ITEMS_PER_SCREEN` items (stable order) are kept and a single coverage notice records the
// remainder, so the same IR yields the same truncated set every run.
const MAX_A11Y_ITEMS_PER_SCREEN = 800;
const INTERACTIVE = new Set(["button", "input", "link"]);
// ─── WCAG relative luminance + contrast ratio (exact, model-free) ─────────────────
// sRGB → linear-light channel (WCAG 2.x): c/12.92 below the knee, else ((c+0.055)/1.055)^2.4.
const linearizeChannel = (channel255) => {
    const c = channel255 / 255;
    return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
/** WCAG relative luminance of an sRGB colour: 0 for black, 1 for white. */
export const relativeLuminance = (rgb) => 0.2126 * linearizeChannel(rgb.r) +
    0.7152 * linearizeChannel(rgb.g) +
    0.0722 * linearizeChannel(rgb.b);
/** WCAG contrast ratio (L_lighter + 0.05) / (L_darker + 0.05); symmetric, 1..21. */
export const contrastRatio = (a, b) => {
    const la = relativeLuminance(a);
    const lb = relativeLuminance(b);
    const lighter = Math.max(la, lb);
    const darker = Math.min(la, lb);
    return (lighter + 0.05) / (darker + 0.05);
};
/** Whether a ratio meets the WCAG AA threshold (3.0 for large text, else the stricter 4.5). */
export const meetsContrastAa = (ratio, isLargeText) => ratio >= (isLargeText ? AA_LARGE : AA_NORMAL);
function a11yItemId(prefix, key) {
    // Deterministic non-cryptographic id (FNV-1a) — stable across runs, no IO. Mirrors #754/#811.
    let hash = 0x811c9dc5;
    for (let i = 0; i < key.length; i += 1) {
        hash ^= key.charCodeAt(i);
        hash = Math.imul(hash, 0x01000193);
    }
    return `${prefix}-${(hash >>> 0).toString(16).padStart(8, "0")}`;
}
const a11yItem = (ctx, nodeId, kind, title) => ({
    id: a11yItemId("fa11y", `${ctx.screenId}|${kind}|${nodeId}`),
    category: "a11y",
    screenId: ctx.screenId,
    screenName: ctx.screenName,
    sourceNodeId: nodeId,
    title,
});
const noticeItem = (ctx, nodeId, kind, title) => ({
    id: a11yItemId("fa11ycov", `${ctx.screenId}|${kind}|${nodeId}`),
    category: "coverage-notice",
    screenId: ctx.screenId,
    screenName: ctx.screenName,
    sourceNodeId: nodeId,
    title,
});
// ─── Per-rule derivation ───────────────────────────────────────────────────────────
const nodeLabel = (node) => node.text !== undefined && node.text.length > 0 ? node.text : node.name;
// A node's name is "descriptive" only when it is not an opaque Figma node id (e.g. "123:45"). This is
// structural, not board-specific: it rejects the id syntax, never any particular screen's vocabulary.
const hasDescriptiveName = (node) => node.name.trim().length > 0 && !/^[0-9]+:[0-9]+$/u.test(node.name.trim());
const hasAccessibleName = (node) => (node.text !== undefined && node.text.trim().length > 0) || hasDescriptiveName(node);
const contrastItem = (ctx, node, background) => {
    const fg = node.textColor === undefined ? undefined : parseHexRgb(node.textColor);
    const bg = background === undefined ? undefined : parseHexRgb(background);
    if (fg === undefined || bg === undefined) {
        return noticeItem(ctx, node.id, "contrast", `Text "${nodeLabel(node)}" needs a verifiable colour contrast: text/background colour could not be resolved from the design tokens`);
    }
    const ratio = contrastRatio(fg, bg);
    const rounded = Math.round(ratio * 100) / 100;
    const verdict = meetsContrastAa(ratio, false)
        ? `meets WCAG AA (${String(rounded)}:1 ≥ ${String(AA_NORMAL)}:1)`
        : `is below WCAG AA (${String(rounded)}:1 < ${String(AA_NORMAL)}:1)`;
    return a11yItem(ctx, node.id, "contrast", `Text "${nodeLabel(node)}" colour contrast ${verdict}`);
};
const isTooSmall = (box) => box.width < MIN_TARGET_SIZE || box.height < MIN_TARGET_SIZE;
const byReadingOrder = (a, b) => a.box.y !== b.box.y ? a.box.y - b.box.y : a.box.x - b.box.x;
// Each rule returns its item (or undefined when the node is out of scope), keeping `visit` flat so
// its cyclomatic complexity stays within budget. Rules read only structural shape — never copy.
const nameRule = (ctx, node) => INTERACTIVE.has(node.interactionHint) && !hasAccessibleName(node)
    ? a11yItem(ctx, node.id, "name", `Interactive "${node.name}" exposes an accessible name`)
    : undefined;
const contrastRule = (ctx, node, background) => node.interactionHint === "text" && node.textColor !== undefined
    ? contrastItem(ctx, node, background)
    : undefined;
const targetSizeRule = (ctx, node) => INTERACTIVE.has(node.interactionHint) &&
    node.boundingBox !== undefined &&
    isTooSmall(node.boundingBox)
    ? a11yItem(ctx, node.id, "target", `Control "${nodeLabel(node)}" meets the 24×24 minimum target size`)
    : undefined;
const altTextRule = (ctx, node) => node.imageFills.length > 0
    ? a11yItem(ctx, node.id, "alt", `Image "${node.name}" exposes descriptive alt text`)
    : undefined;
function visit(node, ctx, inheritedBackground, acc) {
    const background = node.backgroundColor ?? inheritedBackground;
    for (const item of [
        nameRule(ctx, node),
        contrastRule(ctx, node, background),
        targetSizeRule(ctx, node),
        altTextRule(ctx, node),
    ]) {
        if (item !== undefined)
            acc.items.push(item);
    }
    if (INTERACTIVE.has(node.interactionHint) && node.boundingBox !== undefined) {
        acc.focusables.push({ nodeId: node.id, label: nodeLabel(node), box: node.boundingBox });
    }
    for (const child of node.children)
        visit(child, ctx, background, acc);
}
const focusOrderItem = (ctx, focusables) => {
    const ordered = [...focusables].sort(byReadingOrder);
    const sequence = ordered.map((f) => `"${f.label}"`).join(" → ");
    return a11yItem(ctx, ordered[0]?.nodeId ?? "", "focus-order", `Focus order follows the visual reading order: ${sequence}`);
};
/**
 * Derive the deterministic a11y test items per screen, keyed by the screen they are attributed to.
 * Reuses #754's StructuralTestItem shape so the items compose additively through
 * `deriveScreenTestBaseline(screen, extraItems)`, ALONGSIDE #811's navigation items. Model-free and
 * reproducible: the same IR yields a byte-identical map.
 */
export function deriveA11yTestItemsByScreen(screens) {
    const byScreen = new Map();
    for (const screen of screens) {
        const ctx = { screenId: screen.id, screenName: screen.name };
        const acc = { items: [], focusables: [] };
        visit(screen.root, ctx, undefined, acc);
        if (acc.focusables.length >= 2)
            acc.items.push(focusOrderItem(ctx, acc.focusables));
        byScreen.set(screen.id, capItems(ctx, acc.items));
    }
    return byScreen;
}
// Deterministically bound a screen's a11y items: keep the first MAX (stable order), and when more
// were derived, append ONE coverage notice recording how many were omitted (never a silent cut).
function capItems(ctx, items) {
    if (items.length <= MAX_A11Y_ITEMS_PER_SCREEN)
        return items;
    const omitted = items.length - MAX_A11Y_ITEMS_PER_SCREEN;
    const kept = items.slice(0, MAX_A11Y_ITEMS_PER_SCREEN);
    kept.push(noticeItem(ctx, ctx.screenId, "a11y-cap", `Screen "${ctx.screenName}" has more accessibility checks than the per-screen baseline shows: ${String(omitted)} additional checks were omitted to keep the baseline bounded`));
    return kept;
}
