// Guards the mechanism that narrows tests/a11y/docs-pages.spec.ts.
//
// That suite stopped asserting color-contrast at zero on 2026-08-31 (Owner
// decision, backlog INFRA-F86 残余③). Narrowing a gate is exactly the move that
// usually goes wrong, so the narrowing itself is gated here: this file runs under
// `pnpm test` — pre-commit AND CI — while the suite it protects runs only at tag
// time (scripts/release.mjs). If the table or the wiring is tampered with, that is
// caught on the commit, not three weeks later at release.
//
// What this does NOT do: verify that the Owner actually accepted a 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.

import { describe, it, expect } from 'vitest'
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import {
  loadExemptions,
  partitionViolations,
  unusedRows,
  pairKey,
  TABLE_PATH,
  RULE_ID,
} from './lib/a11y-contrast-exemptions.mjs'

const SPEC = 'tests/a11y/docs-pages.spec.ts'
const read = (p: string) => readFileSync(p, 'utf8')

/**
 * 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 not a
 *     table, it 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.
 */
const ACCEPTED_PAIR_COUNT = 69

describe('color-contrast exemption table (INFRA-F86)', () => {
  it('parses, and every row carries its provenance', () => {
    const table = loadExemptions(TABLE_PATH)
    expect(table.rows.length).toBeGreaterThan(0)
    for (const row of table.rows) {
      expect(row.acceptedOn, `${row.key} missing acceptedOn`).toMatch(/^\d{4}-\d{2}-\d{2}$/)
      expect(row.acceptedBy.trim(), `${row.key} missing acceptedBy`).not.toBe('')
      expect(row.fixDirection.trim(), `${row.key} missing fixDirection`).not.toBe('')
    }
  })

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

  // ── The [E3] fail-closed claim, proven rather than asserted ────────────────
  // A loader that silently tolerates a broken table would make every other
  // assertion here decorative. Each case below is a table that MUST be rejected.
  it('refuses a malformed table instead of degrading', () => {
    const dir = mkdtempSync(join(tmpdir(), 'f86-'))
    const good = {
      fg: '#7b7b7b',
      bg: '#1e1e1e',
      expectedRatio: 4.5,
      acceptedOn: '2026-08-31',
      acceptedBy: 'owner',
      fixDirection: 'raise --text-tips lightness',
      note: 'fixture',
    }
    const cases: Array<[string, unknown]> = [
      ['not an object', []],
      ['missing exemptions array', { rows: [] }],
      ['row missing acceptedOn', { exemptions: [{ ...good, acceptedOn: undefined }] }],
      ['row with empty fixDirection', { exemptions: [{ ...good, fixDirection: '  ' }] }],
      ['row with a bad date', { exemptions: [{ ...good, acceptedOn: '31/08/2026' }] }],
      ['row with a non-hex colour', { exemptions: [{ ...good, fg: 'rgb(123,123,123)' }] }],
      ['row with a non-numeric ratio', { exemptions: [{ ...good, expectedRatio: '4.5' }] }],
      ['duplicate rows', { exemptions: [good, { ...good }] }],
    ]
    for (const [label, payload] of cases) {
      const p = join(dir, 'table.json')
      writeFileSync(p, JSON.stringify(payload))
      expect(() => loadExemptions(p), `${label} must be rejected`).toThrow()
    }
    // ...and an unreadable path is a failure, never an empty table.
    expect(() => loadExemptions(join(dir, 'does-not-exist.json'))).toThrow()
    // Negative control: the well-formed fixture parses, so the cases above fail for
    // the reason claimed and not because everything throws.
    const okPath = join(dir, 'ok.json')
    writeFileSync(okPath, JSON.stringify({ exemptions: [good] }))
    expect(loadExemptions(okPath).rows).toHaveLength(1)
  })

  it('treats an unlisted colour pair as blocking, not as covered', () => {
    const dir = mkdtempSync(join(tmpdir(), 'f86-'))
    const p = join(dir, 'table.json')
    writeFileSync(
      p,
      JSON.stringify({
        exemptions: [
          {
            fg: '#7b7b7b',
            bg: '#1e1e1e',
            expectedRatio: 4.5,
            acceptedOn: '2026-08-31',
            acceptedBy: 'owner',
            fixDirection: 'x',
            note: 'x',
          },
        ],
      }),
    )
    const table = loadExemptions(p)
    const node = (fg: string, bg: string, expected: string) => ({
      target: ['.x'],
      any: [{ data: { fgColor: fg, bgColor: bg, expectedContrastRatio: expected, contrastRatio: 3.1 } }],
    })

    const listed = partitionViolations(
      [{ id: RULE_ID, impact: 'serious', nodes: [node('#7b7b7b', '#1e1e1e', '4.5:1')] }] as any,
      table,
      { page: 'p', theme: 'dark' },
    )
    expect(listed.blocking, 'a listed pair must not block').toEqual([])
    expect(listed.exemptNodes).toBe(1)

    // Same colours, different bar (large text vs body text) — a different decision.
    const otherBar = partitionViolations(
      [{ id: RULE_ID, impact: 'serious', nodes: [node('#7b7b7b', '#1e1e1e', '3:1')] }] as any,
      loadExemptions(p),
      { page: 'p', theme: 'dark' },
    )
    expect(otherBar.unknown, 'a different expectedRatio is a different pair').toHaveLength(1)

    // A new colour on an accepted background must not ride along.
    const newPair = partitionViolations(
      [{ id: RULE_ID, impact: 'serious', nodes: [node('#8c8c8c', '#1e1e1e', '4.5:1')] }] as any,
      loadExemptions(p),
      { page: 'p', theme: 'dark' },
    )
    expect(newPair.unknown, 'an unlisted fg on a listed bg must block').toHaveLength(1)

    // axe reporting no colour data is unknown, never exempt.
    const noData = partitionViolations(
      [{ id: RULE_ID, impact: 'serious', nodes: [{ target: ['.x'], any: [] }] }] as any,
      loadExemptions(p),
      { page: 'p', theme: 'dark' },
    )
    expect(noData.unknown).toHaveLength(1)

    // Any other rule is untouched by the table.
    const otherRule = partitionViolations(
      [{ id: 'aria-required-parent', impact: 'serious', nodes: [node('#000000', '#ffffff', '4.5:1')] }] as any,
      loadExemptions(p),
      { page: 'p', theme: 'dark' },
    )
    expect(otherRule.blocking).toHaveLength(1)
  })

  it('reports a row that matched nothing, so the table shrinks by itself', () => {
    const table = loadExemptions(TABLE_PATH)
    expect(unusedRows(table).map((r: any) => r.key), 'nothing has been counted yet').toHaveLength(
      table.rows.length,
    )
    table.rows[0].hits = 1
    expect(unusedRows(table)).toHaveLength(table.rows.length - 1)
  })

  it('keeps the suite wired to the shared table, with no second judgement inlined', () => {
    const spec = read(SPEC)
    // The narrowing must go through the reviewed helper. An inline filter in the
    // spec would be a second, unguarded copy of this decision.
    expect(spec).toContain("from '../lib/a11y-contrast-exemptions.mjs'")
    expect(spec).toContain('partitionViolations(')
    expect(spec).toContain('loadExemptions(TABLE_PATH)')

    // The other half of the 2026-05-18 Owner decision — no severity threshold —
    // survives: nothing may branch on impact. (`v.impact` inside a message template
    // is fine; a comparison is not.)
    expect(
      /impact\s*[=!]==/.test(spec),
      'no severity threshold: the suite must not branch on impact',
    ).toBe(false)

    // Every judgement code the suite claims to carry is present.
    for (const code of ['[E1]', '[E2]', '[E3]', '[E4]', '[E5]']) {
      expect(spec, `${code} assertion missing from ${SPEC}`).toContain(code)
    }

    // Freeze, or the light-theme numbers are not reproducible and mid-transition
    // colours show up as unknown pairs (INFRA-F86, 2026-08-03).
    expect(spec).toContain('transition:none !important')
    expect(spec).toContain('animation:none !important')

    // A scan that throws must fail, never be rescued into a pass.
    expect(spec.includes('try {') && spec.includes('analyze()')).toBe(false)
  })

  it('resolves the table in exactly one place', () => {
    // Two code paths to the table is how a gate ends up reading a file nobody
    // edits. The helper owns the location; the suite imports the constant.
    // (Prose mentions of the filename in headers are fine — this is about code.)
    const helper = read('tests/lib/a11y-contrast-exemptions.mjs')
    expect(helper).toContain(`export const TABLE_PATH = '${TABLE_PATH}'`)

    const spec = read(SPEC)
    expect(spec).toContain('TABLE_PATH')
    expect(
      /readFileSync|readFile\(|JSON\.parse/.test(spec),
      'the suite must not load the table itself — it imports loadExemptions',
    ).toBe(false)
  })

  it('keys pairs case-insensitively and includes the bar', () => {
    expect(pairKey('#AABBCC', '#DDEEFF', 4.5)).toBe('#aabbcc on #ddeeff @4.5')
    expect(pairKey('#aabbcc', '#ddeeff', 3)).not.toBe(pairKey('#aabbcc', '#ddeeff', 4.5))
  })
})
