// tests/a11y-non-text-contrast-ratchet.test.ts
// -----------------------------------------------------------------------------
// Shrink-only ratchet for WCAG 1.4.11 non-text contrast (UI element vs adjacent
// surface, 3:1). Owner decision 2026-09-07 — ai-ds-lab `docs/decision-queue.md` Q2,
// branch `ratchet`.
//
// ── What this replaces ───────────────────────────────────────────────────────
// `audit:token-contrast` reported "0/20 pass · 20 fail" and exited **0**. The CI
// step that runs it is labelled "(non-blocking)" and carries no `continue-on-error`
// — so it was non-blocking only because the script never failed. `.github/workflows/
// gates.yml` says it in as many words: *"it is a report, not a gate"*.
//
// The Owner's three options were: ratchet / keep-report-only / gate-now. `gate-now`
// would turn the whole repo red (all 20 pairs fail today) and the fix is a designer
// decision on the Figma library, which the Owner has ruled authoritative and out of
// scope for code-side edits. `ratchet` freezes today's 20 and forbids growth.
//
// ── Why the ratchet is BOTH a count and a named set ──────────────────────────
// A count-only ratchet reproduces the defect this repo has already named twice
// (queue Q8, and the `#M40` section-id incident): **the total holds while the
// identity changes.** One pair getting fixed and a different one breaking keeps the
// count at 20 and a count-only gate stays green. So the row set is asserted exactly,
// in both directions:
//   - a failing pair missing from the table  → red, and the pair is named;
//   - a table row that no longer fails       → red, and you are told to delete it
//     (that is the shrink direction being *forced*, not merely allowed).
//
// ── Why it lives here and not in the script ──────────────────────────────────
// `scripts/a11y-token-contrast.mjs` rewrites two git-tracked artifacts
// (`playground/docs/data/a11y-token-contrast.json` + the markdown report). Mounting
// *that* on pre-commit would dirty the working tree on every commit. This file
// imports the script's pure `computeNonTextFindings()` instead — same arithmetic, no
// writes — and runs under `pnpm test`, which is pre-commit AND CI.
// ⛔ Do not copy the contrast arithmetic in here. A duplicated judgement drifts.
//
// ── What this does NOT do ────────────────────────────────────────────────────
// It cannot verify that the Owner accepted any given pair; no gate can. What it can
// do is make every row carry who accepted it and when, make growth require a second
// deliberate edit, and make the shrink direction free — and forced.
// -----------------------------------------------------------------------------
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'

const REPO_ROOT = resolve(__dirname, '..')
const TABLE_PATH = 'tests/a11y/non-text-contrast-exemptions.json'
const WRITER = 'scripts/a11y-token-contrast.mjs'

/**
 * Shrink-only ratchet. Equality, not `<=`, on purpose and in both directions:
 *   - raising it is a second deliberate edit, in a file whose header says an Owner
 *     decision is required — an allowlist that can grow silently is a rubber stamp;
 *   - lowering it is forced when a row is deleted, so the constant cannot sit above
 *     reality and quietly re-open room for a future addition.
 *
 * ⛔ 20 is the measured steady state, NOT a budget. It has been 20 since 2026-06-10.
 */
const ACCEPTED_PAIR_COUNT = 20

type Row = {
  theme: string
  elementToken: string
  surfaceToken: string
  elementHex: string
  surfaceHex: string
  required: number
  measuredRatio: number
  status: string
  acceptedOn: string
  acceptedBy: string
  fixDirection: string
}

function loadTable(): { rows: Row[]; byKey: Map<string, Row> } {
  const raw = JSON.parse(readFileSync(resolve(REPO_ROOT, TABLE_PATH), 'utf8'))
  const rows: Row[] = raw.exemptions
  if (!Array.isArray(rows)) throw new Error(`${TABLE_PATH}: \`exemptions\` is not an array — fail closed`)
  return { rows, byKey: new Map(rows.map((r) => [key(r.theme, r.elementToken, r.surfaceToken), r])) }
}

const key = (theme: string, el: string, surf: string) => `${theme} | ${el} on ${surf}`

async function liveFindings() {
  const mod = await import('../scripts/a11y-token-contrast.mjs')
  // fail-closed: if the writer is refactored and stops exporting the pure function,
  // this gate must break loudly rather than silently judge nothing.
  if (typeof mod.computeNonTextFindings !== 'function') {
    throw new Error(`${WRITER} no longer exports computeNonTextFindings — fix this gate before shipping the refactor`)
  }
  return mod.computeNonTextFindings() as Array<{
    theme: string
    elementToken: string
    surfaceToken: string
    key: string
    ratio: number
    status: string
    elementHex: string
    surfaceHex: string
  }>
}

describe('WCAG 1.4.11 non-text contrast ratchet (queue Q2, Owner 2026-09-07)', () => {
  it('parses, and every row carries its provenance', () => {
    const { rows } = loadTable()
    expect(rows.length).toBeGreaterThan(0)
    for (const r of rows) {
      const k = key(r.theme, r.elementToken, r.surfaceToken)
      expect(r.acceptedOn, `${k} missing acceptedOn`).toMatch(/^\d{4}-\d{2}-\d{2}$/)
      expect(r.acceptedBy?.trim(), `${k} missing acceptedBy`).not.toBe('')
      expect(r.fixDirection?.trim(), `${k} missing fixDirection`).not.toBe('')
      // The fix direction must name the two tokens it is about — a row whose
      // instruction does not say what to change is a row nobody can act on.
      expect(r.fixDirection, `${k} fixDirection must name both tokens`).toContain(r.elementToken)
      expect(r.fixDirection, `${k} fixDirection must name both tokens`).toContain(r.surfaceToken)
      expect(['fail', 'same'], `${k} has status ${r.status} — only failing pairs belong in this table`).toContain(r.status)
    }
  })

  it('holds exactly the ratcheted number of pairs', () => {
    const { rows } = loadTable()
    expect(
      rows.length,
      `The table holds ${rows.length} pair(s) but the ratchet says ${ACCEPTED_PAIR_COUNT}. ` +
        `Adding a pair needs an Owner decision (queue Q2 froze today's set); removing one means a ` +
        `contrast defect got fixed — lower the constant and take the win.`,
    ).toBe(ACCEPTED_PAIR_COUNT)
  })

  it('has no duplicate rows', () => {
    const { rows, byKey } = loadTable()
    expect(byKey.size, 'duplicate (theme, elementToken, surfaceToken) rows — a duplicate hides a second defect behind one blessing').toBe(rows.length)
  })

  it('⛔ every failing pair is already in the table (a NEW failing pair is named and blocks)', async () => {
    const { byKey } = loadTable()
    const failing = (await liveFindings()).filter((f) => f.status !== 'pass')
    const unknown = failing.filter((f) => !byKey.has(f.key))
    expect(
      unknown.map((f) => `${f.key} — ${f.ratio}:1 [${f.status}] (${f.elementHex} on ${f.surfaceHex})`),
      `New non-text contrast failure(s) not covered by ${TABLE_PATH}. This is the growth the ratchet exists to stop. ` +
        `Either raise the contrast, or get an Owner decision and add the row (+ bump ACCEPTED_PAIR_COUNT).`,
    ).toEqual([])
  })

  it('⛔ forces the shrink direction: a row that no longer fails must be deleted', async () => {
    const { byKey } = loadTable()
    const live = await liveFindings()
    const stillFailing = new Set(live.filter((f) => f.status !== 'pass').map((f) => f.key))
    const stale = [...byKey.keys()].filter((k) => !stillFailing.has(k))
    expect(
      stale,
      `These row(s) no longer describe a failing pair — the defect was fixed. Delete them from ${TABLE_PATH} ` +
        `and lower ACCEPTED_PAIR_COUNT. ⛔ Leaving a stale row keeps room reserved for a future regression.`,
    ).toEqual([])
  })

  it('⛔ measured ratio may improve, never regress', async () => {
    const { byKey } = loadTable()
    const live = await liveFindings()
    const regressed: string[] = []
    for (const f of live) {
      const row = byKey.get(f.key)
      if (!row) continue
      // Recorded value is the floor. Improving is free; the row's number is then
      // stale-but-safe and can be refreshed in the same edit that takes the win.
      if (f.ratio < row.measuredRatio) {
        regressed.push(`${f.key}: ${row.measuredRatio}:1 → ${f.ratio}:1 (${f.elementHex} on ${f.surfaceHex})`)
      }
    }
    expect(
      regressed,
      'Contrast got WORSE on an already-accepted pair. The blessing covers the defect as measured, ⛔ not any deeper version of it.',
    ).toEqual([])
  })

  it('⛔ status may not degrade fail → same (identical values is strictly worse)', async () => {
    const { byKey } = loadTable()
    const live = await liveFindings()
    const degraded = live
      .filter((f) => byKey.get(f.key)?.status === 'fail' && f.status === 'same')
      .map((f) => `${f.key}: element and surface are now the SAME value (${f.elementHex}) — the element is invisible, not merely low-contrast`)
    expect(degraded, 'A low-contrast pair became an identical-value pair.').toEqual([])
  })

  it('the report writer stays a writer: it must not be turned into the blocking gate', () => {
    const src = readFileSync(resolve(REPO_ROOT, WRITER), 'utf8')
    // ⛔ Pinned on purpose: that script rewrites two git-tracked artifacts, so making
    //    it exit nonzero would dirty the tree on every commit. The teeth belong here.
    expect(src, `${WRITER} must not exit nonzero — see its footer comment`).not.toMatch(/process\.exit\([1-9]/)
    expect(src, `${WRITER} must keep exporting the pure computation this gate judges`).toContain('export function computeNonTextFindings')
    // The writes must stay behind the main guard, or importing this module from a
    // test would dirty the working tree.
    expect(src, `${WRITER} must keep its main guard`).toContain('import.meta.url === pathToFileURL(process.argv[1]).href')
  })
})
