// One place may drive the docs-site theme toggle: tests/lib/docs-theme-toggle.mjs.
//
// ── Why this gate exists (2026-08-18, backlog INFRA-F86) ─────────────────────
// `57c86ba9` (2026-08-12) moved the theme button into the "More" popup, which broke
// every bare click on that selector in the repo at once. There were FOUR of them
// and they were found one at a time over six days:
//   • tests/visual/docs-pages.spec.ts        — noticed 08-13 (the whole visual
//     suite had silently stopped for a day)
//   • tests/a11y-non-contrast/docs-pages.spec.ts — noticed 08-18, and this one is
//     BLOCKING in scripts/release.mjs step 1d with no skip flag, so it sat red on
//     master and would have stopped the next `git tag`
//   • tests/a11y/docs-pages.spec.ts, scripts/a11y-survey.mjs,
//     scripts/a11y-contrast-inspector.mjs — same day, same cause
//
// The defect was never "the selector changed". It was that N copies of a fragile
// interaction each had to be found separately, while the one harness that would
// have shouted runs only at tag time. Collapsing them into one helper only helps
// if copy number five cannot be added — hence a gate rather than a comment.
//
// ── Two ways this gate could have been useless, and what stops each ──────────
// 1. False positives on prose. Every file involved in this fix DESCRIBES the old
//    bare-click pattern in a comment. A first draft flagged 5 such mentions
//    (including two of its own). ⇒ comments are stripped before matching, strings
//    are kept (the selector lives in a string), and the pattern is assembled from
//    parts so this file never itself contains the offending shape.
// 2. A regex that quietly stops matching real code — the failure direction that
//    makes a live defect look fixed. ⇒ the detector is exercised on both a
//    positive and a negative fixture below, and separately on the helper itself.
//
// ⚠️ This asserts SHAPE, not behaviour: it cannot tell whether the helper still
// works, only that nobody is driving the toggle behind its back. The behavioural
// check is `pnpm audit:a11y-non-contrast`, which needs chromium and a dev server
// and therefore cannot live here.

import { describe, it, expect } from 'vitest'
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { resolve, dirname, relative, extname } from 'node:path'
import { fileURLToPath } from 'node:url'

const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const HELPER = 'tests/lib/docs-theme-toggle.mjs'
const SCAN_ROOTS = ['tests', 'scripts']
const CODE_EXT = new Set(['.ts', '.tsx', '.mjs', '.js', '.cjs'])

/** Assembled from parts on purpose — see note 1 in the header. */
const SELECTOR = 'button.canonical-toggle'
const DRIVE_RE = new RegExp(
  `(?:click|locator|waitForSelector|\\$\\$?)\\(\\s*['"\`]${SELECTOR.replace(/\./g, '\\.')}`,
  'g',
)

/**
 * Remove comments, keep string/template literals intact, and keep byte offsets
 * stable (comment bytes become spaces) so reported line numbers stay true.
 *
 * A naive `split('//')` would be worse than nothing here: on a line where a URL
 * in a string precedes a real call, it would delete the call and report the file
 * as clean.
 */
function stripComments(src: string): string {
  const out = src.split('')
  const blank = (from: number, to: number) => {
    for (let k = from; k < to && k < out.length; k++) if (out[k] !== '\n') out[k] = ' '
  }
  let i = 0
  const n = src.length
  while (i < n) {
    const c = src[i]
    const d = src[i + 1]
    if (c === '/' && d === '/') {
      let j = i + 2
      while (j < n && src[j] !== '\n') j++
      blank(i, j)
      i = j
    } else if (c === '/' && d === '*') {
      let j = i + 2
      while (j < n && !(src[j] === '*' && src[j + 1] === '/')) j++
      blank(i, Math.min(j + 2, n))
      i = j + 2
    } else if (c === "'" || c === '"' || c === '`') {
      const quote = c
      let j = i + 1
      while (j < n && src[j] !== quote) {
        if (src[j] === '\\') j++
        j++
      }
      i = j + 1
    } else {
      i++
    }
  }
  return out.join('')
}

function walk(dir: string, out: string[] = []): string[] {
  for (const name of readdirSync(dir)) {
    if (name === 'node_modules' || name.startsWith('.')) continue
    const abs = resolve(dir, name)
    if (statSync(abs).isDirectory()) walk(abs, out)
    else if (CODE_EXT.has(extname(name))) out.push(abs)
  }
  return out
}

const files = SCAN_ROOTS.flatMap((r) => {
  const abs = resolve(REPO, r)
  return existsSync(abs) ? walk(abs) : []
})

const offenders: string[] = []
let helperDrivesIt = false

for (const abs of files) {
  const rel = relative(REPO, abs)
  const code = stripComments(readFileSync(abs, 'utf8'))
  const hits = [...code.matchAll(DRIVE_RE)]
  if (!hits.length) continue
  if (rel === HELPER) {
    helperDrivesIt = true
    continue
  }
  for (const h of hits) offenders.push(`${rel}:${code.slice(0, h.index).split('\n').length}`)
}

// `from '...docs-theme-toggle.mjs'`, not a bare substring: this very file holds the
// helper's path in a string, and counting itself would inflate the number it prints.
const IMPORT_RE = /from\s+['"][^'"]*docs-theme-toggle\.mjs['"]/
const importers = files
  .filter((abs) => relative(REPO, abs) !== HELPER)
  .filter((abs) => IMPORT_RE.test(readFileSync(abs, 'utf8')))
  .map((abs) => relative(REPO, abs))

describe('docs theme toggle — the detector itself', () => {
  it('fires on a drive call in code (positive fixture)', () => {
    const code = stripComments(`await page.click('${SELECTOR}')\n`)
    expect([...code.matchAll(DRIVE_RE)].length).toBe(1)
  })

  it('ignores the same text inside comments (negative fixture)', () => {
    const src =
      `// the old shape was page.click('${SELECTOR}')\n` +
      `/* also locator("${SELECTOR}") in a block */\n` +
      `const ok = 1\n`
    expect([...stripComments(src).matchAll(DRIVE_RE)].length).toBe(0)
  })

  it('does not lose a real call that shares a line with a URL string', () => {
    const src = `const u = 'https://x/y'; await page.locator('${SELECTOR}').click()\n`
    expect([...stripComments(src).matchAll(DRIVE_RE)].length).toBe(1)
  })
})

describe('docs theme toggle — single source', () => {
  // Denominator first. A scan that reached nothing passes trivially, and that is
  // the exact false green this repo keeps paying for.
  it('scanned a non-empty set of files under tests/ and scripts/', () => {
    expect(files.length, 'scan found no code files at all — the roots are wrong').toBeGreaterThan(0)
  })

  // Positive control on real code, not a fixture: the detector must fire on the
  // one file that legitimately drives the toggle. If this fails, "0 offenders"
  // below means nothing.
  it('fires on the helper itself', () => {
    expect(existsSync(resolve(REPO, HELPER)), `${HELPER} is missing`).toBe(true)
    expect(
      helperDrivesIt,
      `the detector matched nothing in ${HELPER}. Either the helper stopped driving the ` +
        `toggle, or the pattern no longer matches real code — in which case the assertion ` +
        `below is vacuous. Fix the pattern, do not relax it.`,
    ).toBe(true)
  })

  it('nothing else drives the toggle selector directly', () => {
    expect(
      offenders,
      `these call sites drive the theme toggle behind the helper's back:\n` +
        offenders.map((o) => `  ${o}`).join('\n') +
        `\n\nUse \`flipDocsTheme(page, 'light' | 'dark')\` from ${HELPER} instead. The button ` +
        `is not present on a freshly loaded page at any viewport — it lives inside the "More" ` +
        `popup and the mobile drawer — so a bare click is a timeout, not a flip.`,
    ).toEqual([])
  })

  it('the helper is actually used (not dead code)', () => {
    expect(
      importers.length,
      `no file imports ${HELPER}. Either the theme flip vanished from the harnesses or ` +
        `someone inlined it again.`,
    ).toBeGreaterThan(0)
  })

  it('states its own reach', () => {
    // INFRA-F87 precedent: a gate prints what green covers, so nobody over-claims
    // from a tick. Printed values are the ones that actually move.
    console.log(
      `[theme-toggle-single-source] scanned ${files.length} files under ${SCAN_ROOTS.join('+')}; ` +
        `${importers.length} importer(s): ${importers.join(', ')}\n` +
        `[theme-toggle-single-source] NOT covered: whether the helper still works ` +
        `(needs chromium — see pnpm audit:a11y-non-contrast), and any harness outside ` +
        `${SCAN_ROOTS.join('/')}.`,
    )
    expect(true).toBe(true)
  })
})
