// tests/audit-copied-token-drift.test.ts
// -----------------------------------------------------------------------------
// `audit:copied-token-drift` 的**整脚本**回归面。
//
// 为什么 spawn 整脚本而不是 import 判据函数：只 import 符号的测试**看不见接线** ——
// 摘掉 `main()` 后闸对真违例照印 PASS，而全量 vitest 全绿（[[INFRA-F138]] 实证）。
// 本文件 spawn 整个脚本，被测对象一行不改。
//
// ⚠️ **登记本面覆盖不到什么**（如实登记的边界，⛔ 不是待补 TODO）：
//   · ⛔ 覆盖不到真实的 `variables.css` / 分享页 —— fixture 用的是占位 token 名，
//     刻意不与真仓库内容重合，否则「闸读到的是 fixture 还是真仓库」分不开。
//   · ⛔ 覆盖不到视觉门（改 `.html` 要 `VISUAL_COMMIT_APPROVED=1`）—— 那是 hook 层的事。
//   · ⛔ 覆盖不到「整行被删」—— 那一格由 `audit:no-hardcoded-design-tokens` 的 varScope 档守，
//     本闸头注释 §覆盖边界 ① 已登记（含 2026-09-11 的实测读数）。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterAll } from 'vitest'
import {
  createGateFixture, writeFixtureFile, cleanupGateFixtures, runGate,
  expectGateRed, expectGateGreen,
} from './lib/gate-fixture-root'

const GATE = 'scripts/audit-copied-token-drift.mjs'
const SRC = 'src/tokens/variables.css'
const COPY = 'playground/public/design-process-review.html'

/** 真源：占位 token，⛔ 不用真仓库里的名字。默认拆成**两个** `:root` 块 —— 见 §多块回归。 */
function variablesCss(opts: { alphaVal?: string; betaVal?: string; oneBlock?: boolean; noRoot?: boolean; dupDiff?: boolean } = {}) {
  const alpha = opts.alphaVal ?? '4px'
  const beta = opts.betaVal ?? '#abcdef'
  if (opts.noRoot) return ['/* 没有 :root 的样式表 */', '.foo { color: red; }'].join('\n')
  if (opts.dupDiff) {
    return [':root {', `  --probe-alpha: ${alpha};`, '}', '', ':root {', '  --probe-alpha: 99px;', '}'].join('\n')
  }
  if (opts.oneBlock) {
    return [':root {', `  --probe-alpha: ${alpha};`, `  --probe-beta: ${beta};`, '}'].join('\n')
  }
  return [
    '/* ── 第一块：颜色 ── */',
    ':root {',
    `  --probe-beta: ${beta};   /* 行内注释 */`,
    '}',
    '',
    '/* ── 第二块：间距（真仓库就是这样分块的）── */',
    ':root {',
    `  --probe-alpha: ${alpha};`,
    '}',
    '',
    '[data-theme="light"] {',
    '  --probe-alpha: 999px;   /* ⛔ 非 :root 选择器，不该进判定面 */',
    '}',
  ].join('\n')
}

/** 副本页：`:root{` 紧贴无空格 —— 真分享页就是这个写法（正则缺 `m` 标志时会漏掉它）。 */
function sharePage(opts: { alphaVal?: string; betaVal?: string; ownVal?: string; noRoot?: boolean; rename?: boolean; commentTrap?: boolean } = {}) {
  const alpha = opts.alphaVal ?? '4px'
  const beta = opts.betaVal ?? '#abcdef'
  const own = opts.ownVal ?? '#141414'
  const selector = opts.noRoot ? '.not-root' : ':root'
  const names = opts.rename
    ? { a: '--page-alpha', b: '--page-beta' }
    : { a: '--probe-alpha', b: '--probe-beta' }
  const trap = opts.commentTrap ? `   /* ← 逐字复制 --probe-alpha: 99px; */` : ''
  return [
    '<!doctype html><html><head><style>',
    `  ${selector}{`,
    `  ${names.a}: ${alpha};${trap}`,
    `  ${names.b}: ${beta};`,
    `    --page-own:${own};`,
    '  }',
    '</style></head><body>独立分享页</body></html>',
  ].join('\n')
}

/**
 * 最小 L4 hook —— **S6 的判定面**。条件行形态逐字照真 `.husky/pre-commit` 那一行写
 * （以 `if git diff --cached` 起头 · 含 `audit-copied-token-drift` · `grep -qE '…'`），
 * ⛔ 否则测的就不是同一个抽取器。
 */
function precommitHook(opts: { pattern?: string; badGrep?: boolean; omitCond?: boolean } = {}) {
  if (opts.omitCond) return ['#!/usr/bin/env sh', 'echo "这份 hook 里没有本闸的条件块"', ''].join('\n')
  const pattern =
    opts.pattern ??
    '(src/tokens/variables\\.css|playground/public/design-process-review\\.html|\\.husky/pre-commit|scripts/audit-copied-token-drift\\.mjs)'
  // badGrep：条件行仍在（起头与点名都对），但用的是 `grep -q` ⇒ 抽不出 ERE pattern
  const cond = opts.badGrep
    ? `if git diff --cached --name-only --diff-filter=AMD | grep -q 'audit-copied-token-drift'; then`
    : `if git diff --cached --name-only --diff-filter=AMD | grep -qE '${pattern}'; then`
  return ['#!/usr/bin/env sh', cond, '  pnpm run audit:copied-token-drift', 'else', '  echo skip', 'fi', ''].join('\n')
}

function makeFixture(
  src: string,
  copy: string,
  opts: {
    omitCopy?: boolean
    hookPattern?: string
    omitHook?: boolean
    hookBadGrep?: boolean
    hookOmitCond?: boolean
  } = {},
) {
  const files: Record<string, string> = { [SRC]: src }
  if (!opts.omitCopy) files[COPY] = copy
  if (!opts.omitHook) {
    files['.husky/pre-commit'] = precommitHook({
      pattern: opts.hookPattern,
      badGrep: opts.hookBadGrep,
      omitCond: opts.hookOmitCond,
    })
  }
  return createGateFixture({ gate: GATE, prefix: 'copied-token-drift', files })
}

afterAll(() => cleanupGateFixtures())

describe('audit:copied-token-drift — 整脚本回归面', () => {
  it('对照臂：两侧一致 ⇒ 绿（证明它不是恒红）', () => {
    const root = makeFixture(variablesCss(), sharePage())
    const run = runGate(root, GATE)
    // 钉一个「只有真跑过才有」的读数 —— ⛔ exit 0 + 空输出不算绿
    expectGateGreen(run, { contains: ['同名 2 个 · 漂移 0 个', 'PASS'] })
  })

  it('🔴 多个 :root 块：token 分散在两块里，两个都必须进判定面', () => {
    // 回归钉子：落地时的第一版只取**第一个** `:root` 块（且正则缺 `m` 标志）
    // ⇒ 真源 225 个 token 只解析出 3 个。这条测试在那一版上是红的。
    const root = makeFixture(variablesCss(), sharePage())
    const run = runGate(root, GATE)
    expectGateGreen(run, { contains: ['2 个 :root 块', '同名 2 个'] })
  })

  it('S4：副本页的值漂了 ⇒ 红并点名是哪个 token 与两边各是什么', () => {
    const root = makeFixture(variablesCss(), sharePage({ alphaVal: '5px' }))
    const run = runGate(root, GATE)
    expectGateRed(run, { contains: ['S4', '--probe-alpha', '5px', '4px'] })
  })

  it('S4：真源改了而副本没跟 ⇒ 同样红（方向对称）', () => {
    const root = makeFixture(variablesCss({ alphaVal: '6px' }), sharePage())
    const run = runGate(root, GATE)
    expectGateRed(run, { contains: ['S4', '--probe-alpha'] })
  })

  it('S3：副本页没有 :root 块 ⇒ 红，⛔ 不静默给绿', () => {
    const root = makeFixture(variablesCss(), sharePage({ noRoot: true }))
    const run = runGate(root, GATE)
    expectGateRed(run, { contains: ['S3', '找不到 `:root {` 块'] })
  })

  it('S3：副本页文件不存在 ⇒ 红并提示同时清 COPY_TARGETS', () => {
    const root = makeFixture(variablesCss(), '', { omitCopy: true })
    const run = runGate(root, GATE)
    expectGateRed(run, { contains: ['S3', 'COPY_TARGETS'] })
  })

  it('S1：真源没有 :root 块 ⇒ 红（fail closed，⛔ 不当成「没东西可比」）', () => {
    const root = makeFixture(variablesCss({ noRoot: true }), sharePage())
    const run = runGate(root, GATE)
    expectGateRed(run, { contains: ['S1'] })
  })

  it('S2：真源同一个 token 有两个不同的值 ⇒ 红，⛔ 不猜哪个算数', () => {
    const root = makeFixture(variablesCss({ dupDiff: true }), sharePage())
    const run = runGate(root, GATE)
    expectGateRed(run, { contains: ['S2', '拒绝猜'] })
  })

  it('🔴 S5：同名交集 = 0 ⇒ 必须红 —— 「0 处漂移」与「0 处可比」⛔ 不是一回事', () => {
    const root = makeFixture(variablesCss(), sharePage({ rename: true }))
    const run = runGate(root, GATE)
    expectGateRed(run, { contains: ['S5', '判定面塌了'] })
  })

  it('must-not-hit：注释里的假声明⛔不算声明 ⇒ 绿', () => {
    const root = makeFixture(variablesCss(), sharePage({ commentTrap: true }))
    const run = runGate(root, GATE)
    expectGateGreen(run, { contains: ['漂移 0 个'] })
  })

  it('must-not-hit：副本页自有的变量与真源无关 ⇒ 改它不红', () => {
    const root = makeFixture(variablesCss(), sharePage({ ownVal: '#999999' }))
    const run = runGate(root, GATE)
    expectGateGreen(run, { contains: ['漂移 0 个'] })
  })

  it('must-not-hit：`[data-theme="light"]` 里的同名 token ⛔ 不进判定面', () => {
    // 真源的 light 档把 --probe-alpha 写成 999px，若被收进来对照臂就会假红
    const root = makeFixture(variablesCss(), sharePage())
    const run = runGate(root, GATE)
    expectGateGreen(run, { contains: ['漂移 0 个'] })
    expect(run.stdout).not.toContain('999px')
  })

  // ───────────────── S6 L4 触发面 ⊇ 判定面 ─────────────────
  // 缺陷来源（⛔ 不是假想）：2026-09-11 ai-ds-lab 造故障把 COPY_TARGETS 从 1 项加到 2 项、
  // 触发 pattern 一字不动 ⇒ staged 新副本页时 pattern miss ⇒ pre-commit 静默 skip 本闸，
  // 而同一个页**在判定面里**（直接跑闸 EXIT=1 点名它漂移）⇒ 触发面与判定面脱节。

  it('S6 对照臂：pattern 覆盖判定面全部路径 ⇒ 绿并印出覆盖数（证明它不是恒红）', () => {
    const root = makeFixture(variablesCss(), sharePage())
    const run = runGate(root, GATE)
    expectGateGreen(run, { contains: ['L4 触发面', '覆盖判定面 3/3 个路径'] })
  })

  it('🔴 S6：pattern 漏了副本页 ⇒ 红、点名该路径，并印出「延迟到 CI」这个严重度限定', () => {
    const root = makeFixture(variablesCss(), sharePage(), {
      hookPattern: '(src/tokens/variables\\.css|\\.husky/pre-commit|scripts/audit-copied-token-drift\\.mjs)',
    })
    const run = runGate(root, GATE)
    expectGateRed(run, { contains: ['S6', '静默 skip', COPY, '同 commit 改', '延迟到 CI'] })
  })

  it('S6 fail closed：hook 文件不存在 ⇒ 红，⛔ 不当成「本闸没有 L4」', () => {
    const root = makeFixture(variablesCss(), sharePage(), { omitHook: true })
    const run = runGate(root, GATE)
    expectGateRed(run, { contains: ['S6', '不存在', '不可判'] })
  })

  it('S6 fail closed：条件行在但用的是 `grep -q` ⇒ 抽不出 pattern ⇒ 红，⛔ 不退化成空 pattern', () => {
    // ⛔ 空 pattern 会让「覆盖判定」恒真 ⇒ 这一条钉的正是那个假绿
    const root = makeFixture(variablesCss(), sharePage(), { hookBadGrep: true })
    const run = runGate(root, GATE)
    expectGateRed(run, { contains: ['S6', "抽不出 `grep -qE '…'`"] })
  })

  it('S6 fail closed：hook 里整个条件块没了 ⇒ 红（= 有人把 L4 摘掉了）', () => {
    const root = makeFixture(variablesCss(), sharePage(), { hookOmitCond: true })
    const run = runGate(root, GATE)
    expectGateRed(run, { contains: ['S6', '找不到', '触发条件块'] })
  })

  it('🔴 must-not-hit：闸脚本自己不在 pattern 里、但判定面全覆盖 ⇒ 绿（定位锚⛔不许钉这个字面）', () => {
    // 这一条是**回归钉子**：第一版 `extractHookPattern` 用「条件行里点名本闸」当定位锚 ⇒ 本臂
    // 报「找不到条件行」这个**错的修法方向**。把闸自己从触发面去掉是合法选择，⛔ 不该让锚丢。
    const root = makeFixture(variablesCss(), sharePage(), {
      hookPattern: '(src/tokens/variables\\.css|playground/public/design-process-review\\.html|\\.husky/pre-commit)',
    })
    const run = runGate(root, GATE)
    expectGateGreen(run, { contains: ['覆盖判定面 3/3 个路径'] })
  })

  // 🔴 本次修复的回归钉子（2026-09-11 第二批）——「S6 曾是单向的」那条缺陷。
  // 缺陷来源（⛔ 不是假想，ai-ds-lab 用真 `grep -E` 实测）：落地 S6 让本闸开始**读**
  // `.husky/pre-commit` ⇒ 它进了判定面，而触发 pattern 没跟着收它 ⇒ 只 stage 那个 hook 时，
  // 53 个条件块里只有 1 个命中（`audit:gate-ci-parity`）⇒ 本闸不跑
  // ⇒ 守得住「改闸忘了改 hook」、守不住「改 hook 忘了改闸」。
  it('🔴 S6：pattern 漏了 `.husky/pre-commit` 自己 ⇒ 红（钉住「S6 不得退回单向」）', () => {
    const root = makeFixture(variablesCss(), sharePage(), {
      hookPattern:
        '(src/tokens/variables\\.css|playground/public/design-process-review\\.html|scripts/audit-copied-token-drift\\.mjs)',
    })
    const run = runGate(root, GATE)
    expectGateRed(run, { contains: ['S6', '静默 skip', '.husky/pre-commit', '同 commit 改'] })
  })

  it('must-not-hit：pattern 多覆盖与判定面无关的路径 ⇒ ⛔ 不红（多触发是安全方向）', () => {
    const root = makeFixture(variablesCss(), sharePage(), {
      hookPattern:
        '(src/tokens/variables\\.css|playground/public/design-process-review\\.html|\\.husky/pre-commit|totally/unrelated\\.txt)',
    })
    const run = runGate(root, GATE)
    expectGateGreen(run, { contains: ['覆盖判定面 3/3 个路径'] })
  })
})
