// tests/audit-product-code-r20-r21.test.ts
//
// R20 / R21 —— M40 / M41 的 code 端镜像（Q5 `build-with-gate`，owner 2026-09-07 拍定）。
// 口径与两侧读数的唯一真源 = ai-ds-lab/docs/2026-09-07-q5-r20-r21-code-mirror-preregistration.md
//
// 🔴 **为什么牙落在这份测试里，而不是把 `audit:consumer-code` 挂进 pre-commit / CI**
//   （2026-09-07 亲验，⛔ 不是推理）：单跑 `node scripts/audit-product-code.mjs` ⇒ **EXIT=1**，
//   `scanned 189 files`，其中 **R1 29 条全部**是 `src/icons/catalog/generated/*.ts` 与
//   `src/icons/raw.ts` —— **DS 就是 icon 的出处**，内联 SVG 在这里是产物不是违例。
//   ⇒ 整条 CLI 挂进 DS 必然恒红，且那些红里至少 29 条根本不是违例。
//   ⇒ 只能单独跑 R20 / R21 两个纯函数扫 `src/`，跑在 `pnpm test` 下
//     （`.husky/pre-commit` L8–9 **无条件** + Gitea `pr-checks.yml` "Unit tests (vitest)"）。
//   ⚠️ 这与 Q2 的 a11y 棘轮落法同源：牙放在测试层，⛔ 判据算式不抄第二份。
//
// ⚠️ **如实声明本文件验到的上限**：它验「判据本身 + DS `src/`」，
//   ⛔ 不验「消费仓真的跑过 audit:consumer-code」—— 那条 CLI 在 DS 仓仍无挂载。
import fs from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import {
  findR20Violations,
  findR21Violations,
  findR21ContextUnits,
  R20_SEMANTIC_VALUES,
} from '../scripts/audit-product-code.mjs'

const ROOT = process.cwd()
const SRC = path.join(ROOT, 'src')
const SRC_EXT = new Set(['.vue', '.ts', '.tsx', '.js', '.jsx', '.css', '.html'])
const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage'])

function collectSrcFiles(dir: string, out: string[] = []) {
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    if (entry.isDirectory()) {
      if (SKIP_DIRS.has(entry.name)) continue
      collectSrcFiles(path.join(dir, entry.name), out)
      continue
    }
    if (entry.isFile() && SRC_EXT.has(path.extname(entry.name))) out.push(path.join(dir, entry.name))
  }
  return out
}

// ---------------------------------------------------------------------------
// R20 — 必须命中（P1 fixture 半 / P3 值域）
// ---------------------------------------------------------------------------
describe('R20 findR20Violations — 必须命中', () => {
  it('模板插值 `#${i + 1}` 无声明 ⇒ 1 条，点名行号', () => {
    const src = ['const a = 1', 'const label = `#${i + 1}`', 'const b = 2'].join('\n')
    const v = findR20Violations(src)
    expect(v).toHaveLength(1)
    expect(v[0].line).toBe(2)
    expect(v[0].kind).toBe('missing-hash-n-semantic')
    expect(v[0].hint).toContain('#N semantic:')
  })
  it('Vue mustache `#{{ index + 1 }}` 无声明 ⇒ 命中', () => {
    expect(findR20Violations('<span>#{{ index + 1 }}</span>')).toHaveLength(1)
  })
  it("字符串拼接 `'#' + (idx + 1)` 无声明 ⇒ 命中", () => {
    expect(findR20Violations("const l = '#' + (idx + 1)")).toHaveLength(1)
  })
  it('驼峰 index 变量 `#${rowIndex}` 无声明 ⇒ 命中', () => {
    expect(findR20Violations('const l = `#${rowIndex}`')).toHaveLength(1)
  })
  // P3 —— 「有声明就放行」⛔ 不算实现了 M40：闸判的是存在性**和**闭集值域两条。
  it('声明值不在闭集（`auto`）⇒ 命中，并指出合法值', () => {
    const src = ['// #N semantic: auto', 'const label = `#${i + 1}`'].join('\n')
    const v = findR20Violations(src)
    expect(v).toHaveLength(1)
    expect(v[0].kind).toBe('bad-hash-n-semantic-value')
    for (const legal of R20_SEMANTIC_VALUES) expect(v[0].hint).toContain(legal)
  })
  it('闭集逐字取自 M40 语义类型表 —— 恰好两个值', () => {
    expect(R20_SEMANTIC_VALUES).toEqual(['sequence', 'name'])
  })
})

// ---------------------------------------------------------------------------
// R20 — 不可误报（P2；三条阴性对照里两条**逐字取自真实代码**，⛔ 不是编的）
// ---------------------------------------------------------------------------
describe('R20 findR20Violations — 不可误报', () => {
  it('URL hash `#${section.id}`（playground/docs/DocsShell.vue:1255 真实代码）⇒ 0 条', () => {
    expect(findR20Violations(':href="`#${section.id}`"')).toHaveLength(0)
  })
  it('路由 hash `#${routeWithSection}`（DocsShell.vue:507 真实代码）⇒ 0 条', () => {
    expect(
      findR20Violations("const hash = routeWithSection === '/' ? '' : `#${routeWithSection}`"),
    ).toHaveLength(0)
  })
  it('hex 颜色 `#fff` / `#1a2b3c` ⇒ 0 条', () => {
    expect(findR20Violations('color: #fff; background: #1a2b3c;')).toHaveLength(0)
  })
  it('同行已声明 sequence ⇒ 0 条', () => {
    expect(findR20Violations('const l = `#${i + 1}` // #N semantic: sequence')).toHaveLength(0)
  })
  it('上一行已声明 name ⇒ 0 条', () => {
    expect(findR20Violations(['// #N semantic: name', 'const l = `#${i + 1}`'].join('\n'))).toHaveLength(0)
  })
  it('下一行已声明 ⇒ 0 条', () => {
    expect(findR20Violations(['const l = `#${i + 1}`', '// #N semantic: sequence'].join('\n'))).toHaveLength(0)
  })
  it('AUDIT-IGNORE-R20 ⇒ 0 条', () => {
    expect(findR20Violations('const l = `#${i + 1}` // AUDIT-IGNORE-R20: legacy')).toHaveLength(0)
  })
  it('AUDIT-IGNORE-R2 ⛔ 不得顺带豁免 R20（token 边界）', () => {
    expect(findR20Violations('const l = `#${i + 1}` // AUDIT-IGNORE-R2: color')).toHaveLength(1)
  })
})

// ---------------------------------------------------------------------------
// R21 — 必须命中（P4 造故障半）
// ---------------------------------------------------------------------------
describe('R21 findR21Violations — 必须命中', () => {
  it('overlay 背景写裸 rgba ⇒ 命中，点名行号', () => {
    const src = ['.popup-overlay {', '  background: rgba(0, 0, 0, 0.6);', '}'].join('\n')
    const v = findR21Violations(src, 'src/components/PopupBox/PopupBox.vue')
    expect(v).toHaveLength(1)
    expect(v[0].line).toBe(2)
    expect(v[0].kind).toBe('raw-rgba-overlay')
    expect(v[0].hint).toContain('var(--mask-overlay)')
  })
  it('overlay 的 backdrop-filter 写裸 blur(8px) ⇒ 命中', () => {
    const src = ['.scrim {', '  backdrop-filter: blur(8px);', '}'].join('\n')
    const v = findR21Violations(src, 'src/x.css')
    expect(v).toHaveLength(1)
    expect(v[0].kind).toBe('raw-blur-overlay')
  })
  it('选择器在上方 3 行内也算 overlay 上下文', () => {
    const src = ['.modal-backdrop,', '.other {', '  /* x */', '  background: rgba(0,0,0,.4);', '}'].join('\n')
    expect(findR21Violations(src, 'src/x.css')).toHaveLength(1)
  })
})

// ---------------------------------------------------------------------------
// R21 — 不可误报（P5；两条阴性对照逐字取自真实代码）
// ---------------------------------------------------------------------------
describe('R21 findR21Violations — 不可误报', () => {
  it('走 token 的正例（PopupBox.vue:280-281 真实代码）⇒ 0 条', () => {
    const src = [
      '.popup-overlay {',
      '  background: var(--mask-overlay);',
      '  backdrop-filter: blur(var(--mask-overlay-blur));',
      '}',
    ].join('\n')
    expect(findR21Violations(src, 'src/components/PopupBox/PopupBox.vue')).toHaveLength(0)
  })
  // 🔴 **2026-09-08 修掉一个恒真断言 —— ⛔ 别改回去。**
  //   本用例原先的 fixture 是 `--mask-overlay: rgba(0,0,0,0.6)` + `--mask-overlay-blur: 8px`
  //   （**自定义属性**）。实测:那两行压根不匹配 `R21_RAW_RGBA_RE`(要求 `background(-color):`)
  //   也不匹配 `R21_MASK_DECL_RE` ⇒ **豁免侧与非豁免侧都是 0 / 0**
  //   ⇒ 把 `R21_TOKEN_DEF_FILE_RE` 整段豁免拆掉,本用例**照样绿** = 零信息。
  //   （同族:AGENTS 第 25 条「恒红 = 零信息」的反面 —— 恒绿的断言同样零信息。）
  //   ⇒ 改用**真会违例**的文本,并**两侧**断言:豁免侧 0、非豁免侧必须 1。
  it('token 唯一定义点 src/tokens/variables.css ⇒ 整份文件豁免（非豁免侧必须红）', () => {
    const src = ['.popup-overlay {', '  background: rgba(0, 0, 0, 0.6);', '}'].join('\n')
    expect(findR21Violations(src, 'src/tokens/variables.css'), '豁免侧应为 0').toHaveLength(0)
    expect(findR21ContextUnits(src, 'src/tokens/variables.css'), '豁免侧的分母也应为 0').toHaveLength(0)
    expect(
      findR21Violations(src, 'src/components/Other.vue'),
      '非豁免侧必须红 —— 否则上面那个 0 只是「本来就不命中」,不是「被豁免了」'
    ).toHaveLength(1)
  })
  it('注释里的 rgba(hue,0.18)（src/canonical/PillStatus.vue:17 真实代码）⇒ 0 条', () => {
    const src = '  - Active=True bg is the raw hue at 18% opacity (Figma rgba(hue,0.18)),'
    expect(findR21Violations(src, 'src/canonical/PillStatus.vue')).toHaveLength(0)
  })
  it('非 overlay 语义的 blur（缩略图）⇒ 0 条', () => {
    const src = ['.thumb-preview {', '  filter: blur(2px);', '}'].join('\n')
    expect(findR21Violations(src, 'src/x.css')).toHaveLength(0)
  })
  it('非 overlay 语义的 rgba（阴影色）⇒ 0 条', () => {
    const src = ['.card {', '  box-shadow: 0 1px 2px rgba(0,0,0,.2);', '}'].join('\n')
    expect(findR21Violations(src, 'src/x.css')).toHaveLength(0)
  })
  it('AUDIT-IGNORE-R21 ⇒ 0 条', () => {
    const src = ['.popup-overlay {', '  background: rgba(0,0,0,.6); // AUDIT-IGNORE-R21: 见 Q11', '}'].join('\n')
    expect(findR21Violations(src, 'src/x.css')).toHaveLength(0)
  })
})

// ---------------------------------------------------------------------------
// 真 src/ 上的两侧断言 —— 这才是「牙」（P1 造故障半 / P4 零违例 + 分母地板）
// ---------------------------------------------------------------------------
describe('R20 / R21 —— 扫真 src/', () => {
  const files = collectSrcFiles(SRC)

  it('扫描面非空（空分母 ⇒ 这条测试的绿不可解读）', () => {
    expect(files.length).toBeGreaterThan(50)
  })

  it('R20 —— src/ 零违例', () => {
    const hits: string[] = []
    for (const f of files) {
      for (const v of findR20Violations(fs.readFileSync(f, 'utf8'))) {
        hits.push(`${path.relative(ROOT, f)}:${v.line} ${v.kind}`)
      }
    }
    expect(hits).toEqual([])
  })

  it('R21 —— src/ 零违例', () => {
    const hits: string[] = []
    for (const f of files) {
      const rel = path.relative(ROOT, f)
      for (const v of findR21Violations(fs.readFileSync(f, 'utf8'), rel)) {
        hits.push(`${rel}:${v.line} ${v.kind}`)
      }
    }
    expect(hits).toEqual([])
  })

  // 🔴 分母地板 —— 本仓两次点名过的病（分母静默变小 / Q8「总数守住、身份变了」）的直接防线。
  //   ⛔ 没有这一条，R21 会在某次重构后静默变成恒绿：探针看不见任何 overlay，零违例照样绿。
  it('R21 —— overlay 上下文单元数 ≥ 1（分母塌了就红）', () => {
    let units = 0
    const where: string[] = []
    for (const f of files) {
      const rel = path.relative(ROOT, f)
      const u = findR21ContextUnits(fs.readFileSync(f, 'utf8'), rel)
      if (u.length) where.push(`${rel}(${u.length})`)
      units += u.length
    }
    expect(units, `R21 探针在 src/ 上一个 overlay 上下文都没匹配到 ⇒ 分母塌了，闸已恒绿。命中文件: ${where.join(' · ')}`)
      .toBeGreaterThanOrEqual(1)
  })

  // ⚠️ **R20 刻意没有分母地板，且这里刻意没有第二条「命中数」测试** —— 如实说清为什么：
  //   R20 今日在 DS `src/` 的触发命中就是 **0**（预注册读数 H）。
  //   · 给它加地板（≥1）⇒ 立刻红，而红的原因是「DS 还没写这种代码」，是假红。
  //   · 写成 `expect(n).toBeGreaterThanOrEqual(0)` ⇒ **恒真断言 = 零信息**，正是本仓
  //     反复点名要禁的形态（AGENTS 第 25 条「恒红 = 零信息」的同族）。⛔ 别把它加回来。
  //   ⇒ R20 的「它真的会红」由上面 planted-drift 那组用例 + 一次真造故障（把 `#${i+1}`
  //     写进 src/ 下一个临时文件 ⇒ 上面「R20 —— src/ 零违例」当场红并点名）承担。
  //     那次造故障的读数留在 Q5 小节的证据表里，⛔ 不留残留文件在仓里。
})
