// tests/EslintPluginR20R21.test.ts
//
// R20 / R21 的 **ESLint 侧**规则（`require-hash-n-semantic` / `no-hardcoded-overlay`）。
// 2026-09-07 落 Q5 时只落了 CLI probe 那一半；这一半是 2026-09-08 补的。
// 口径与两侧读数的唯一真源 =
//   ai-ds-lab/docs/2026-09-08-q5-r20-r21-eslint-side-preregistration.md
//
// 🔴 **本轮新增的到底是什么牙 —— ⛔ 不是「补上了对称性」**：
//   CLI probe R20/R21 在 DS 仓**四个挂载面全零**（Q5 读数 C/D：不在 .husky/pre-commit、
//   不在 Gitea / GitHub CI、`audit:consumer-code` 没有挂载声明）。
//   而 ESLint 这一半**有挂载**：`.husky/pre-commit:355-363` 的 eslint dogfood 条件块
//   （staged 命中 `src/(canonical|components)/*.vue` 或 `eslint-plugin/` 或 `eslint.config.js`
//   ⇒ 跑 `pnpm run lint:ds`）+ `.github/workflows/ci.yml:64` 的 push 路径过滤含 `eslint-plugin/**`。
//   ⇒ 挂载面才是这一批的产出，⛔ 别把它讲成「判据变多了」。
//
// ⚠️ **如实声明本文件验到的上限**：
//   · 它验「两条 ESLint 规则的行为 + 与 CLI 判据逐条对齐 + 真扫描面上的分母地板」；
//   · ⛔ 不验「消费仓真的跑过这两条规则」—— 打包面只实测到**可解析**，零消费仓实跑读数；
//   · ⛔ DS 仓现取违例数 R20 **0** · R21 **0** ⇒ 今日两条在 DS 上是**零信息的绿**，
//     真射程是消费仓 + 未来新写的代码（前向 fail-closed）。「它真的会红」由本文件的
//     planted-drift 用例 + Q5 小节证据表里那两次真造故障（`lint:ds` EXIT=1 并点名）承担。
import fs from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { Linter } from 'eslint'
import vueParser from 'vue-eslint-parser'
// @ts-expect-error — plain ESM JS plugin without .d.ts
import tvuPlugin from '../eslint-plugin/index.js'
import {
  findR20Violations,
  findR21Violations,
  findR21ContextUnits,
  R20_SEMANTIC_VALUES,
} from '../scripts/audit-product-code.mjs'

const R20 = '@ux-team/tvu-design-system/require-hash-n-semantic'
const R21 = '@ux-team/tvu-design-system/no-hardcoded-overlay'

const linter = new Linter()

function lintVue(code: string, filename = 'T.vue') {
  return linter.verify(
    code,
    {
      files: ['**/*.vue'],
      languageOptions: { parser: vueParser as any, ecmaVersion: 'latest', sourceType: 'module' },
      plugins: { '@ux-team/tvu-design-system': tvuPlugin as any },
      rules: { [R20]: 'error', [R21]: 'error' },
    },
    { filename }
  )
}

function lintJs(code: string, filename = 'T.js') {
  return linter.verify(
    code,
    {
      files: ['**/*.js'],
      languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
      plugins: { '@ux-team/tvu-design-system': tvuPlugin as any },
      rules: { [R20]: 'error', [R21]: 'error' },
    },
    { filename }
  )
}

/** 造一个 `<style>` 块在最后的 SFC —— 选择器与声明**刻意隔开几行**（见 R21 头注释的滑窗缺陷） */
function sfcWithStyle(...styleLines: string[]) {
  return [
    '<template><div /></template>',
    '<script setup>const a = 1</script>',
    '<style>',
    ...styleLines,
    '</style>',
  ].join('\n')
}

function sfcWithScript(...scriptLines: string[]) {
  return ['<template><div /></template>', '<script setup>', ...scriptLines, '</script>'].join('\n')
}

// ---------------------------------------------------------------------------
// P2 —— R20 必须红
// ---------------------------------------------------------------------------
describe('R20 require-hash-n-semantic —— 必须红', () => {
  it('JS 里 `#${i + 1}` 无声明 ⇒ 1 条,点名行号', () => {
    const msgs = lintJs(['const i = 0', 'const label = `#${i + 1}`'].join('\n'))
    expect(msgs).toHaveLength(1)
    expect(msgs[0].ruleId).toBe(R20)
    expect(msgs[0].line).toBe(2)
    expect(msgs[0].message).toContain('#N semantic:')
  })

  it('SFC `<script setup>` 里同一形态 ⇒ 命中', () => {
    const msgs = lintVue(sfcWithScript('const i = 0', 'const label = `#${i + 1}`'))
    expect(msgs.filter((m) => m.ruleId === R20)).toHaveLength(1)
  })

  it("字符串拼接 `'#' + (idx + 1)` ⇒ 命中", () => {
    expect(lintJs("const idx = 0\nconst l = '#' + (idx + 1)").filter((m) => m.ruleId === R20)).toHaveLength(1)
  })

  // ⛔ 「有声明就放行」不算实现了 M40 —— 闸判存在性**和**闭集值域两条。
  it('声明值不在闭集（`auto`）⇒ 命中,并印出合法值', () => {
    const msgs = lintJs(['// #N semantic: auto', 'const i = 0', 'const l = `#${i + 1}`'].join('\n'))
    // 声明在第 1 行,触发在第 3 行 ⇒ 相邻窗口(2,3,4)看不到第 1 行 ⇒ 报的是「缺声明」
    expect(msgs).toHaveLength(1)
    const adjacent = lintJs(['const i = 0', '// #N semantic: auto', 'const l = `#${i + 1}`'].join('\n'))
    expect(adjacent).toHaveLength(1)
    expect(adjacent[0].message).toContain('not in the closed set')
    for (const legal of R20_SEMANTIC_VALUES) expect(adjacent[0].message).toContain(legal)
  })

  // 🔴 这一条证明扫描面含 `<template>` 原文 —— 同族 6 条规则里没有一条能做到,
  //    因为它们只喂 AST 节点文本。
  it('`<template>` 里的 Vue mustache `#{{ index + 1 }}` ⇒ 命中', () => {
    const code = ['<template>', '  <span>#{{ index + 1 }}</span>', '</template>'].join('\n')
    const msgs = lintVue(code).filter((m) => m.ruleId === R20)
    expect(msgs).toHaveLength(1)
    expect(msgs[0].line).toBe(2)
  })
})

// ---------------------------------------------------------------------------
// P3 —— R20 不许误报（阴性对照与 CLI 侧同源,两条逐字取自真实代码）
// ---------------------------------------------------------------------------
describe('R20 require-hash-n-semantic —— 不许误报', () => {
  it('URL hash `#${section.id}`（playground/docs/DocsShell.vue:1255 真实代码）⇒ 0 条', () => {
    expect(lintJs('const h = `#${section.id}`').filter((m) => m.ruleId === R20)).toHaveLength(0)
  })

  it('路由 hash `#${routeWithSection}`（DocsShell.vue:507 真实代码）⇒ 0 条', () => {
    expect(
      lintJs("const hash = routeWithSection === '/' ? '' : `#${routeWithSection}`").filter(
        (m) => m.ruleId === R20
      )
    ).toHaveLength(0)
  })

  it('hex 颜色 `#fff` / `#1a2b3c` ⇒ 0 条', () => {
    expect(lintJs("const c = 'color: #fff; background: #1a2b3c;'").filter((m) => m.ruleId === R20)).toHaveLength(0)
  })

  // 🔴 这三条是「必须扫全文」的直接理由：声明可以落在**相邻行**。
  //    若照同族形态只喂 TemplateLiteral 节点的文本,上/下行这两条会变**假阳** ——
  //    等于把规则自己的逃逸口打掉。⛔ 别改成节点作用域。
  it('同行声明 sequence ⇒ 0 条', () => {
    expect(lintJs('const i = 0\nconst l = `#${i + 1}` // #N semantic: sequence').filter((m) => m.ruleId === R20)).toHaveLength(0)
  })

  it('上一行声明 name ⇒ 0 条', () => {
    expect(
      lintJs(['const i = 0', '// #N semantic: name', 'const l = `#${i + 1}`'].join('\n')).filter((m) => m.ruleId === R20)
    ).toHaveLength(0)
  })

  it('下一行声明 ⇒ 0 条', () => {
    expect(
      lintJs(['const i = 0', 'const l = `#${i + 1}`', '// #N semantic: sequence'].join('\n')).filter((m) => m.ruleId === R20)
    ).toHaveLength(0)
  })

  it('AUDIT-IGNORE-R20 ⇒ 0 条', () => {
    expect(
      lintJs('const i = 0\nconst l = `#${i + 1}` // AUDIT-IGNORE-R20: legacy').filter((m) => m.ruleId === R20)
    ).toHaveLength(0)
  })

  it('AUDIT-IGNORE-R2 ⛔ 不得顺带豁免 R20（token 边界）', () => {
    expect(
      lintJs('const i = 0\nconst l = `#${i + 1}` // AUDIT-IGNORE-R2: color').filter((m) => m.ruleId === R20)
    ).toHaveLength(1)
  })
})

// ---------------------------------------------------------------------------
// P2 —— R21 必须红,且**必须在 `<style>` 里红**
//   这一组是本文件存在的核心理由：R21 在 `lint:ds` 扫描面上的分母（2 个单元）
//   **全部**在 SFC 的 `<style>` 块里。只在 JS 上验绿 = 没验到牙。
// ---------------------------------------------------------------------------
describe('R21 no-hardcoded-overlay —— 必须红（含 <style> 块）', () => {
  it('🔴 `<style>` 里 overlay 背景写裸 rgba ⇒ 命中,点名行号（选择器隔了 3 行）', () => {
    const code = sfcWithStyle(
      '.popup-overlay {',
      '  position: fixed;',
      '  inset: 0;',
      '  display: flex;',
      '  background: rgba(0, 0, 0, 0.6);',
      '}'
    )
    const msgs = lintVue(code, 'PopupBox.vue').filter((m) => m.ruleId === R21)
    expect(msgs).toHaveLength(1)
    expect(msgs[0].line).toBe(8) // <style> 起于第 3 行 ⇒ 声明落第 8 行
    expect(msgs[0].message).toContain('var(--mask-overlay)')
  })

  it('`<style>` 里 overlay 的 backdrop-filter 写裸 blur(8px) ⇒ 命中', () => {
    const code = sfcWithStyle('.scrim {', '  backdrop-filter: blur(8px);', '}')
    const msgs = lintVue(code, 'X.vue').filter((m) => m.ruleId === R21)
    expect(msgs).toHaveLength(1)
    expect(msgs[0].message).toContain('blur(var(--mask-overlay-blur))')
  })

  it('JS 行内 style 对象 `const overlayStyle = { background: rgba(...) }` ⇒ 命中', () => {
    const code = ['const overlayStyle = {', "  background: 'rgba(0,0,0,.6)',", '}'].join('\n')
    expect(lintJs(code).filter((m) => m.ruleId === R21)).toHaveLength(1)
  })
})

// ---------------------------------------------------------------------------
// P3 —— R21 不许误报（阴性对照逐字取自真实代码）
// ---------------------------------------------------------------------------
describe('R21 no-hardcoded-overlay —— 不许误报', () => {
  it('走 token 的正例（src/components/PopupBox/PopupBox.vue:280-281 真实代码）⇒ 0 条', () => {
    const code = sfcWithStyle(
      '.popup-overlay {',
      '  background: var(--mask-overlay);',
      '  backdrop-filter: blur(var(--mask-overlay-blur));',
      '}'
    )
    expect(lintVue(code, 'PopupBox.vue').filter((m) => m.ruleId === R21)).toHaveLength(0)
  })

  // 🔴 **两侧对照,⛔ 不许只断言豁免侧为 0** —— 本轮撞出 CLI 侧那份测试的同名用例是
  //    **恒真断言**：它的 fixture 用 `--mask-overlay: rgba(...)`(自定义属性),
  //    压根不匹配 `background:` 判据 ⇒ 把豁免整段拆掉它照样绿 = 零信息。
  //    这里用**真会违例**的文本,并同时断言非豁免文件名下它**会**红。
  it('token 唯一定义点整份豁免 —— 非豁免文件名下同一份文本必须红', () => {
    const style = ['.popup-overlay {', '  position: fixed;', '  background: rgba(0, 0, 0, 0.6);', '}']
    const code = sfcWithStyle(...style)
    expect(
      lintVue(code, 'src/tokens/variables.css').filter((m) => m.ruleId === R21),
      '豁免侧应为 0'
    ).toHaveLength(0)
    expect(
      lintVue(code, 'src/components/Other.vue').filter((m) => m.ruleId === R21),
      '非豁免侧必须红 —— 否则上面那个 0 只是「本来就不命中」'
    ).toHaveLength(1)
  })

  it('注释里的 rgba(hue,0.18)（src/canonical/PillStatus.vue:17 真实代码）⇒ 0 条', () => {
    const code = sfcWithStyle(
      '/* Active=True bg is the raw hue at 18% opacity (Figma rgba(hue,0.18)), */',
      '.pill { color: red; }'
    )
    expect(lintVue(code, 'PillStatus.vue').filter((m) => m.ruleId === R21)).toHaveLength(0)
  })

  it('非 overlay 语义的 blur（缩略图）⇒ 0 条', () => {
    const code = sfcWithStyle('.thumb-preview {', '  filter: blur(2px);', '}')
    expect(lintVue(code, 'X.vue').filter((m) => m.ruleId === R21)).toHaveLength(0)
  })

  it('非 overlay 语义的 rgba（阴影色）⇒ 0 条', () => {
    const code = sfcWithStyle('.card {', '  box-shadow: 0 1px 2px rgba(0,0,0,.2);', '}')
    expect(lintVue(code, 'X.vue').filter((m) => m.ruleId === R21)).toHaveLength(0)
  })
})

// ---------------------------------------------------------------------------
// P1 —— 判据只有一份（⛔ parity 不靠「断言两份逐字相同」,靠 import 同一份）
// ---------------------------------------------------------------------------
describe('P1 判据只有一份 —— 与 CLI 纯函数逐条对齐', () => {
  it('R21：同一份 SFC 文本上,ESLint 报告与 findR21Violations 条数/行/列/报文全等', () => {
    const filename = 'src/components/Other.vue'
    const code = sfcWithStyle(
      '.popup-overlay {',
      '  inset: 0;',
      '  background: rgba(0, 0, 0, 0.6);',
      '  backdrop-filter: blur(8px);',
      '}'
    )
    const cli = findR21Violations(code, filename)
    const esl = lintVue(code, filename).filter((m) => m.ruleId === R21)
    expect(cli.length).toBeGreaterThan(0) // ⛔ 空集对齐是恒真的,先钉住非空
    expect(esl).toHaveLength(cli.length)
    for (let i = 0; i < cli.length; i++) {
      expect(esl[i].line).toBe(cli[i].line)
      expect(esl[i].column).toBe(cli[i].col) // ESLint 报的 column 是 1-based,与 CLI 的 col 同基
      expect(esl[i].message).toBe(cli[i].hint) // ⇒ 连措辞都没有第二份
    }
  })

  it('R20：同一份 JS 文本上,ESLint 报告与 findR20Violations 条数/行/列/报文全等', () => {
    const code = ['const i = 0', 'const a = `#${i + 1}`', 'const rowIndex = 2', 'const b = `#${rowIndex}`'].join('\n')
    const cli = findR20Violations(code)
    const esl = lintJs(code).filter((m) => m.ruleId === R20)
    expect(cli.length).toBeGreaterThan(0)
    expect(esl).toHaveLength(cli.length)
    for (let i = 0; i < cli.length; i++) {
      expect(esl[i].line).toBe(cli[i].line)
      expect(esl[i].column).toBe(cli[i].col)
      expect(esl[i].message).toBe(cli[i].hint)
    }
  })

  // 🔴 **造故障（P1 那一条）**：改 CLI 侧的闭集 ⇒ ESLint 侧行为必须**同步**改变。
  //    这证明两侧读的是**同一个对象**,⛔ 不是「两份恰好一致」。
  //    值域数组是活源导出的可变数组 ⇒ push 一个值再 finally 还原。
  it('往 CLI 的闭集 push 一个值 ⇒ ESLint 侧对该值的判定同步反转', () => {
    const code = ['const i = 0', '// #N semantic: auto', 'const l = `#${i + 1}`'].join('\n')
    expect(lintJs(code).filter((m) => m.ruleId === R20), '改之前 `auto` 必须红').toHaveLength(1)
    R20_SEMANTIC_VALUES.push('auto')
    try {
      expect(
        lintJs(code).filter((m) => m.ruleId === R20),
        '把 `auto` 加进 CLI 的闭集后,ESLint 侧必须跟着放行 —— 不跟着变 = 判据有第二份'
      ).toHaveLength(0)
    } finally {
      R20_SEMANTIC_VALUES.pop()
    }
    expect(lintJs(code).filter((m) => m.ruleId === R20), '还原后必须回到红').toHaveLength(1)
    expect(R20_SEMANTIC_VALUES).toEqual(['sequence', 'name'])
  })
})

// ---------------------------------------------------------------------------
// P4 —— 逃逸口：两种**不等价**,这里断言的是**实测行为**,⛔ 不是「都能用」
// ---------------------------------------------------------------------------
describe('P4 逃逸口 —— 两种不等价（实测)', () => {
  const styleFault = (extra: string) =>
    sfcWithStyle('.popup-overlay {', '  inset: 0;', extra, '  background: rgba(0,0,0,.6);', '}')

  it('`<style>` 里 AUDIT-IGNORE-R21 ⇒ 生效（0 条）', () => {
    const code = sfcWithStyle(
      '.popup-overlay {',
      '  inset: 0;',
      '  background: rgba(0,0,0,.6); /* AUDIT-IGNORE-R21: 见 Q11 */',
      '}'
    )
    expect(lintVue(code, 'X.vue').filter((m) => m.ruleId === R21)).toHaveLength(0)
  })

  // ⚠️ 如实断言一个**不生效**：`<style>` 里 vue-eslint-parser 不产出注释 token,
  //    ESLint 的 disable 机制够不着。⇒ 在 `<style>` 里要豁免只能用 AUDIT-IGNORE-R21。
  //    ⛔ 这条断言的作用是防止有人把规则头注释改成「用 eslint-disable 即可」。
  it('`<style>` 里 eslint-disable-next-line ⛔ 不生效（仍 1 条）', () => {
    const code = styleFault('  /* eslint-disable-next-line @ux-team/tvu-design-system/no-hardcoded-overlay */')
    expect(lintVue(code, 'X.vue').filter((m) => m.ruleId === R21)).toHaveLength(1)
  })

  it('`<script>` / JS 区里 eslint-disable-next-line ⇒ 生效（0 条）', () => {
    const code = [
      'const i = 0',
      '// eslint-disable-next-line @ux-team/tvu-design-system/require-hash-n-semantic',
      'const l = `#${i + 1}`',
    ].join('\n')
    expect(lintJs(code).filter((m) => m.ruleId === R20)).toHaveLength(0)
  })
})

// ---------------------------------------------------------------------------
// P7 —— 分母地板（⛔ 没有它 R21 的 ESLint 侧会在某次重构后静默恒绿）
// ---------------------------------------------------------------------------
describe('R21 —— ESLint 挂载面上的分母地板', () => {
  // 与 package.json 的 `lint:ds` 逐字同一个扫描面：
  //   eslint 'src/canonical/**/*.vue' 'src/components/**/*.vue'
  const ROOT = process.cwd()
  function collectVue(dir: string, out: string[] = []) {
    if (!fs.existsSync(dir)) return out
    for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
      const p = path.join(dir, e.name)
      if (e.isDirectory()) collectVue(p, out)
      else if (e.isFile() && e.name.endsWith('.vue')) out.push(p)
    }
    return out
  }
  const files = [
    ...collectVue(path.join(ROOT, 'src/canonical')),
    ...collectVue(path.join(ROOT, 'src/components')),
  ]

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

  it('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 在 lint:ds 扫描面上一个 overlay 上下文都没匹配到 ⇒ 分母塌了,这条 ESLint 规则已恒绿。命中文件: ${where.join(' · ') || '(无)'}`
    ).toBeGreaterThanOrEqual(1)
  })

  it('R21 —— 该扫描面上现取零违例（⛔ 这个 0 是零信息的绿,见文件头）', () => {
    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([])
  })

  // ⚠️ **R20 刻意没有分母地板** —— 理由与 tests/audit-product-code-r20-r21.test.ts:216-223
  //   逐字相同：DS `src/` 的触发命中今日就是 0。给它加地板(≥1) ⇒ 假红；
  //   写 `≥ 0` ⇒ **恒真断言 = 零信息**（AGENTS 第 25 条同族）。⛔ 别加回来。
})
