// Non-color-contrast a11y regression gate — the green counterpart to the red
// `test:a11y` bar. Landed 2026-08-03 alongside the fix in 008bd0ba, which took
// the non-contrast cluster from 86 nodes (43 per theme, 5 rules, 6 pages) to 0.
//
// ── Why this exists ──────────────────────────────────────────────────────────
// `tests/a11y/docs-pages.spec.ts` asserts EVERY axe rule with no allowlist and no
// severity threshold (Owner decision 2026-05-18). color-contrast is simultaneously
// Owner-accepted, so that suite is 33/33 red by construction and will stay red
// until the Owner rules on it. A permanently red suite protects nothing: put the
// 43 nodes back and its output goes from red to red. This gate is the part that
// can be green, so it is the part that can actually block a regression.
//
// This does NOT relax the other suite. Nothing is subtracted from it.
//
// ── Why one test and not 33 ──────────────────────────────────────────────────
// The first draft was one test per page plus a self-check test reading
// module-scope counters. It reported a denominator of 12/33 — because Playwright
// spawns a FRESH WORKER after a test failure, which re-imports the module and
// resets those counters. A denominator that collapses precisely when something
// else fails is worse than no denominator. Everything runs in one test so the
// tallies cannot be silently reset.
//
// ── Judgement criteria (every one fails closed) ──────────────────────────────
//   S1 body        — any violation of a rule other than color-contrast → FAIL
//   S2 pages       — must scan exactly `orderedPages.length` pages, and > 0
//   S3 themes      — the light scan only counts if data-theme actually flipped
//   S4 shrink-only — if color-contrast reaches 0 everywhere, the exclusion has
//                    outlived its reason → FAIL asking for this gate's deletion
//   S5 axe ran     — a scan that throws is a failure, never a skip
//
// ── One number will look wrong if you don't read this ────────────────────────
// This gate kills CSS transitions before scanning, so the color-contrast figure
// it reports (S4, ~446) is the STEADY-STATE one and is far below the ~2092
// `test:a11y` reports. Neither is a bug. `test:a11y` begins its light-theme scan
// the instant `data-theme` flips, while the colour transition is still running,
// so it samples intermediate colours: two runs of identical code disagreed on 8
// pages, by up to 9 nodes on one (measured 2026-08-03, backlog INFRA-F86 残余③).
// Roughly 79% of its hits are animation artifacts. The structural rules this gate
// asserts are unaffected either way; transitions are frozen so S4 is reproducible.

import type { Page } from '@playwright/test'
import { orderedPages, getPagePath } from '../../playground/docs/navigation'
// Plain .mjs on purpose: the same helper is imported by scripts/*.mjs, which run
// under bare node and cannot import a .ts. See its header.
import { flipDocsTheme } from '../lib/docs-theme-toggle.mjs'

/**
 * The single excluded rule. Deliberately a one-element list rather than a
 * configurable allowlist: everything else is asserted at zero, so a NEW rule
 * appearing in a future axe release fails instead of slipping through.
 * Enumerating the five rules that happen to be broken today would be the
 * "enumerate what's in use rather than what's structurally possible"
 * antipattern (meta-rules #6).
 */
const EXCLUDED_RULES = ['color-contrast'] as const

const AXE_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']

/** Steady-state rendering: no transitions, no animations. See header. */
const FREEZE_CSS = `*,*::before,*::after{transition:none !important;animation:none !important}`

/** Per-page budget for the theme flip. Generous: a slow page must not be read as a broken toggle. */
const THEME_FLIP_TIMEOUT_MS = 15_000

if (process.env.VITEST) {
  const { test } = await import('vitest')
  test.skip('non-contrast a11y gate runs via pnpm audit:a11y-non-contrast (Playwright)', () => {})
} else {
  const { test, expect } = await import('@playwright/test')
  // @ts-expect-error — @axe-core/playwright ships no .d.ts usable from this project's ESM context; runtime import works.
  const { default: AxeBuilder } = await import('@axe-core/playwright')

  test('non-contrast a11y is clean on every docs page, both themes', async ({ page }: { page: Page }) => {
    const findings: string[] = []
    let pagesScanned = 0
    let scansCompleted = 0
    let colorContrastNodes = 0

    for (const pageItem of orderedPages) {
      await page.goto(getPagePath(pageItem.id))
      await page.waitForLoadState('networkidle')

      for (const theme of ['dark', 'light'] as const) {
        if (theme === 'light') {
          // S3: prove the flip happened. Without this, a broken toggle silently
          // turns the light half into a second dark scan while the gate reports
          // full two-theme coverage. Measured 2026-08-03: this is not
          // hypothetical — a polluted Vite dep cache made exactly this page's
          // toggle a no-op for four consecutive runs.
          //
          // ⚠️ 2026-08-18: the flip itself moved into tests/lib/docs-theme-toggle.mjs.
          // The bare `page.click('button.canonical-toggle')` that used to be here
          // stopped working on 2026-08-12 (`57c86ba9` put the button inside the
          // "More" popup) and this gate — BLOCKING in release.mjs step 1d, run
          // only at tag time — sat red on master for six days. The helper's header
          // has the full story and the other three call sites it now covers.
          await flipDocsTheme(page, 'light', {
            label: `[S3] ${pageItem.id}`,
            timeout: THEME_FLIP_TIMEOUT_MS,
          })
        }
        // Re-inject after the toggle: addStyleTag is per-navigation, and the
        // toggle is what starts the transitions being frozen.
        await page.addStyleTag({ content: FREEZE_CSS })

        // S5: no try/catch around analyze(). An AxeBuilder throw propagates and
        // fails the run — "the scanner broke, therefore we passed" is the exact
        // false green this avoids.
        const scan = await new AxeBuilder({ page }).withTags(AXE_TAGS).analyze()
        scansCompleted += 1

        for (const v of scan.violations as any[]) {
          if ((EXCLUDED_RULES as readonly string[]).includes(v.id)) {
            colorContrastNodes += v.nodes.length
            continue
          }
          findings.push(
            `  ${pageItem.id} / ${theme} — [${v.impact}] ${v.id} (${v.nodes.length} node(s))\n` +
              v.nodes.map((n: any) => `      ${n.target.join(' ')}`).join('\n') +
              `\n      ${v.helpUrl}`,
          )
        }
      }

      pagesScanned += 1
    }

    // S2 · denominator. "Scanned nothing, therefore passed" is the classic false
    // green; a routing change that empties orderedPages must be red, not quiet.
    expect(
      pagesScanned,
      `[S2] Expected to scan all ${orderedPages.length} docs pages, actually scanned ` +
        `${pagesScanned}. A gate with a shrunken denominator is a gate that stopped looking.`,
    ).toBe(orderedPages.length)
    expect(pagesScanned, '[S2] Zero pages scanned — nothing was verified.').toBeGreaterThan(0)

    // S3 · both themes per page, counted rather than assumed.
    expect(
      scansCompleted,
      `[S3] Expected ${orderedPages.length * 2} scans (every page in dark and light), ` +
        `got ${scansCompleted}.`,
    ).toBe(orderedPages.length * 2)

    // S4 · shrink-only. The exclusion is not a permanent carve-out; it exists
    // solely because color-contrast is Owner-blocked (backlog INFRA-F86 残余③).
    // The day that clears, `test:a11y` covers everything this gate does and this
    // gate becomes a redundant 1.5 minutes. Let it announce that itself rather
    // than leaving a stale exclusion for someone to puzzle over years later.
    expect(
      colorContrastNodes,
      `[S4] color-contrast reported 0 nodes across all ${orderedPages.length} pages in both ` +
        `themes. The reason this gate excludes it no longer holds — delete EXCLUDED_RULES, ` +
        `this spec, playwright.a11y-noncontrast.config.ts, the audit:a11y-non-contrast ` +
        `script and its release.mjs step 1d call, and let pnpm test:a11y be the single ` +
        `a11y bar again.`,
    ).toBeGreaterThan(0)

    // Coverage, printed every run (INFRA-F87/F88 precedent: a gate states its own
    // reach so nobody over-claims from a green tick).
    process.stdout.write(
      `\n[a11y-non-contrast] ${pagesScanned}/${orderedPages.length} pages × 2 themes = ` +
        `${scansCompleted} scans, tags ${AXE_TAGS.join('+')}\n` +
        `[a11y-non-contrast] asserted at zero: every axe rule EXCEPT ${EXCLUDED_RULES.join(', ')}\n` +
        `[a11y-non-contrast] excluded rule still failing (steady-state): ${colorContrastNodes} ` +
        `nodes — not this gate's business, see backlog INFRA-F86 残余③\n` +
        `[a11y-non-contrast] NOT covered: keyboard operation, focus order, screen-reader\n` +
        `[a11y-non-contrast]              output, and anything axe cannot see statically.\n`,
    )

    // S1 · the body of the gate. Asserted last so a run that trips it has already
    // printed the coverage line above.
    expect(
      findings,
      `[S1] non-color-contrast a11y violations (this gate is green on master — if you are ` +
        `seeing this, the change under test introduced them):\n${findings.join('\n')}`,
    ).toEqual([])
  })
}
