// tests/audit-product-code.test.ts
// -----------------------------------------------------------------------------
// `audit:consumer-code` / `audit:consumer`（`scripts/audit-product-code.mjs`）的**整脚本**回归面。
//
// 🔴 **为什么这一条排在存量最前面**（[[INFRA-F138]] 棘轮落地后量出来的优先级）：
// 它在 `package.json` 的 `files[]` 白名单里 ⇒ **随 npm 包发到每个 consumer**，
// 由 `templates/audit-workflow.yml` / `docs/CONSUMER_AUDIT_SETUP.md` 教他们挂到**自己的 CI** 上。
// ⇒ 它的假绿**会随下一个版本分发到所有下游产品**，而其余闸的假绿只影响本仓。
// 落地当天量到：`files[]` 内共 11 条闸，**11/11 全是 blocking 且接线零覆盖** ——
// 这个切面此前从没被单独量过。本文件是那 11 条里的第 1 条。
//
// 此前它的回归面是 `I` 类（只 import 它导出的检测器）——闸本体有 `IS_MAIN` guard，
// import 时**刻意不跑 CLI**，所以那些测试结构上碰不到 argv 解析、扫描面构建、
// 七条规则的聚合、以及**四个不同的进程出口**。摘掉任一出口的接线，那些测试全绿。
//
// ⛔ **闸本体一行没改。** fixture 里的组件全用 `fx-` 假名（本仓「探针名必须全仓无命中」纪律）。
//
// 覆盖：绿档非空过（fixture 自己的 scanned 数 + 反向钉）· R1/R2/R2S/R15/R16 各一条真违例
//   · 两类 escape（`LIBRARY GAP` 注释 / `AUDIT-IGNORE-*` 行标记）· R2 白名单
//   · 🔴 三个出口各取一次终态（默认 exit 1 · `--non-blocking` exit 0 · fatal exit 2）
//   · R9 advisory 永不阻断 · `--json` 面 · `--ext` / `--dir` 两个 argv 分支
// -----------------------------------------------------------------------------
import { describe, it, expect, afterEach } from 'vitest'
import { spawnSync } from 'node:child_process'
import { lstatSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'

const GATE = 'scripts/audit-product-code.mjs'
/** 闸 import 的同仓模块 —— 必须**拷**进 fixture（软链会让 ESM realpath 解回真仓库）。 */
const SPACING_MAP = 'eslint-plugin/spacing-token-map.js'

const extraRoots: string[] = []
afterEach(() => {
  cleanupGateFixtures()
  for (const r of extraRoots.splice(0)) rmSync(r, { recursive: true, force: true })
})

/** 一个干净的消费者组件：不触发任何一条 R 规则。 */
const CLEAN = `<template>
  <div class="fx-panel">
    <TvuButton fill="solid">Submit</TvuButton>
  </div>
</template>
<style scoped>
.fx-panel { padding: var(--sp-md); color: var(--text-primary); }
.fx-panel:hover { color: var(--text-strong); }
</style>
`

function build(files: Record<string, string>): string {
  return createGateFixture({
    gate: GATE,
    prefix: 'product-code-fx',
    // `scripts/lib` 走 harness 默认的**软链** —— 本闸 2026-08-26 起 import
    // `./lib/is-cli-entry.mjs`（[[INFRA-F140]] 抽出的共享 CLI entry guard）。
    // ⚠️ 软链安全的理由见 harness 判据 1：`isCliEntry` 不自己算 REPO_ROOT。
    // 🔴 此处曾写 `linkDirs: []` 并注释「闸不碰 scripts/lib」—— 抽共享 guard 当天
    //    那句话就失效了，本文件 19 条当场全红。
    copyFiles: [SPACING_MAP],
    files,
  })
}

describe('audit-product-code — 绿档（非空过）', () => {
  it('干净的 src/ ⇒ exit 0，且自印的 scanned 数是 fixture 自己的', () => {
    const root = build({ 'src/fx-clean.vue': CLEAN })
    const run = runGate(root, GATE)
    expectGateGreen(run, {
      contains: [
        '✅',
        // fixture 只有 1 个文件；真仓库跑这条闸时 scanned 是三位数 ⇒ 跑错树对不上
        'scanned 1 files under src',
        'R1  no-inline-svg',
      ],
    })
    // ⛔ 反向钉：fixture 里没有这些真仓库才有的目录名
    expect(run.stdout).not.toContain('playground/')
    expect(run.stdout).not.toContain('react-pilot/')
  })

  it('默认扫描面 = src/（存在时），⛔ 不是整个 cwd', () => {
    // 放一个**违例**在 src/ 外面：默认档不该扫到它 ⇒ 仍绿
    const root = build({
      'src/fx-clean.vue': CLEAN,
      'other/fx-outside.vue': '<template><svg><path d="M0 0"/></svg></template>\n',
    })
    const run = runGate(root, GATE)
    expectGateGreen(run, { contains: ['scanned 1 files under src'] })
  })
})

describe('audit-product-code — 七条 R 规则里的判据抽样', () => {
  // ⚠️ `SVG_PATH_RE` 要求 `d="…"` 的内容 **≥ 9 个字符** —— 这条边界是刻意的（`d="M0 0"`
  // 这种占位/装饰不该被当成"抄了一个图标"）。两条用例把它的**两侧**都钉住。
  const SVG_LONG = '<svg viewBox="0 0 16 16"><path d="M1 1L9 9L14 4Z"/></svg>'
  const SVG_SHORT = '<svg viewBox="0 0 16 16"><path d="M1 1L9 9"/></svg>' // d 只有 8 字符

  it('R1：内联 SVG（d ≥ 9 字符）⇒ 红并点名文件:行:列', () => {
    const root = build({ 'src/fx-icon.vue': `<template>\n  ${SVG_LONG}\n</template>\n` })
    const run = runGate(root, GATE)
    expectGateRed(run, {
      marker: '❌ audit-product-code FAIL',
      checks: ['R1 — no-inline-svg', 'src/fx-icon.vue:2', '@ux-team/tvu-design-system'],
    })
  })

  it('⛔ must-not-hit R1：`d` 短于 9 字符不算（占位/装饰不是"抄了个图标"）', () => {
    const root = build({ 'src/fx-icon.vue': `<template>\n  ${SVG_SHORT}\n</template>\n` })
    expectGateGreen(runGate(root, GATE), {
      contains: ['R1  no-inline-svg-or-arrow-icons        0 violations'],
    })
  })

  it('R1 escape：相邻 `LIBRARY GAP` 注释 ⇒ 绿（逃逸口本身也是判据的一部分）', () => {
    const root = build({
      'src/fx-icon.vue': `<template>\n  <!-- LIBRARY GAP: 该图形 DS 尚未提供 -->\n  ${SVG_LONG}\n</template>\n`,
    })
    expectGateGreen(runGate(root, GATE), { contains: ['R1  no-inline-svg'] })
  })

  it('R2：hex 字面色 ⇒ 红', () => {
    const root = build({
      'src/fx-color.vue': '<template><i /></template>\n<style>\n.fx-a { color: #3366ff; }\n</style>\n',
    })
    expectGateRed(runGate(root, GATE), { checks: ['R2 — no-hex-literal-colors', 'TVU color tokens'] })
  })

  it('⛔ must-not-hit R2：白名单里的 #fff / transparent 不算违例', () => {
    const root = build({
      'src/fx-color.vue':
        '<template><i /></template>\n<style>\n.fx-a { color: #fff; background: transparent; }\n</style>\n',
    })
    expectGateGreen(runGate(root, GATE), { contains: ['R2  no-hex-literal-colors               0 violations'] })
  })

  it('R2S：有 token 的间距 px ⇒ 红（走的是 copyFiles 拷进来的 spacing-token-map）', () => {
    const root = build({
      'src/fx-space.vue': '<template><i /></template>\n<style>\n.fx-a { padding: 12px; }\n</style>\n',
    })
    expectGateRed(runGate(root, GATE), { checks: ['R2S — no-hardcoded-spacing', 'var(--sp-*)'] })
  })

  it('R15：手搓 native <button> ⇒ 红', () => {
    const root = build({ 'src/fx-form.vue': '<template>\n  <button type="submit">Go</button>\n</template>\n' })
    expectGateRed(runGate(root, GATE), { checks: ['R15 — no-native-element', 'canonical Button'] })
  })

  it('R16：中英混排字面量 ⇒ 红，且 `AUDIT-IGNORE-R16` **同一行**标记能豁免', () => {
    const bad = { 'src/fx-i18n.ts': 'export const label = "Submit 提交"\n' }
    expectGateRed(runGate(build(bad), GATE), { checks: ['R16 — no-mixed-cn-en-string'] })

    // ⚠️ `lineHasIgnore` 只看**当前行**（与 R1 的 `libraryGap` 不同 —— 后者还看上一行）。
    // 写在上一行不生效，这是两种逃逸口的真实语义差别，别照着 R1 的形态想当然。
    const escaped = { 'src/fx-i18n.ts': 'export const label = "Submit 提交" // AUDIT-IGNORE-R16: 过渡期文案\n' }
    const ok = runGate(build(escaped), GATE)
    expect(ok.status).toBe(0)
    // ⛔ 别按对齐空格数写死 —— 那是排版不是判据（同族纪律：断言要钉判据，不钉格式）
    expect(ok.stdout).toMatch(/R16 no-mixed-cn-en-string\s+0 violations/)
  })

  it('⛔ must-not-hit：`AUDIT-IGNORE-R1` 不该顺带豁免 R15 / R16（词边界钉）', () => {
    // 闸里 `lineHasIgnore` 逐字要求规则号后面是非字母数字边界，正是为了这个。
    const root = build({ 'src/fx-mix.ts': 'export const t = "Submit 提交" // AUDIT-IGNORE-R1: 只豁免 R1\n' })
    expectGateRed(runGate(root, GATE), { checks: ['R16 — no-mixed-cn-en-string'] })
  })
})

describe('audit-product-code — 🔴 三个进程出口各取一次终态', () => {
  it('出口 A：有违例 + 默认档 ⇒ exit 1', () => {
    const root = build({ 'src/fx-bad.vue': '<template><button>x</button></template>\n' })
    const run = runGate(root, GATE)
    expect(run.status).toBe(1)
    expect(run.stdout).toContain('❌ audit-product-code FAIL')
  })

  it('出口 B：同样的违例 + `--non-blocking` ⇒ **exit 0**（场景 2 report-only）', () => {
    // ⛔ 这是一个**独立的接线点**：摘掉它，consumer 侧「只报不拦」的那条通路会静默变成拦。
    // 只测出口 A 的测试对它零敏感 —— 那正是 [[INFRA-F138]] 说的接线零覆盖。
    const root = build({ 'src/fx-bad.vue': '<template><button>x</button></template>\n' })
    const run = runGate(root, GATE, ['--non-blocking'])
    expect(run.status).toBe(0)
    // 两侧都钉：退出码 + stdout 点名（只钉任一个都有整档测不出来）
    expect(run.stdout).toContain('findings reported without blocking')
    expect(run.stdout).toContain('R15 — no-native-element')
  })

  it('🔴 出口 C：`--dir` 指到**不存在**的目录 ⇒ **exit 2 空分母 fail-closed**（⛔ 这条曾经钉的是相反的行为）', () => {
    // 🔴 **本用例 2026-08-26 翻过面，是设计意图不是返工**：初版钉的是假绿现行行为
    //    （`✅ pass · scanned 0 files` + exit 0），并逐字标注「不是背书」+「谁改了它
    //    这里会当场红」。owner 当日拍定修（[[INFRA-F140]]）⇒ 它当场红 ⇒ 按约翻面。
    //
    // 代价面值得留着：它在 `files[]` 里、随包发到每个 consumer，由他们自己的 CI 用
    //    `--dir` 指向各自的源码目录 ⇒ **目录改名 / 结构调整 / working-directory 不对
    //    之后，那条 CI 会一直绿着，而它其实一个文件都没扫。**
    //
    // 本仓对这个形态有明文纪律：`report:gate-output-harvest` 逐字把 `checkedUnits: 0`
    //    判成 `empty-denominator` / 「这条读数不可解读（假绿）」。本闸此前是那条纪律的例外。
    const root = build({ 'src/fx-clean.vue': CLEAN })
    const run = runGate(root, GATE, ['--dir', 'no-such-dir-fx'])
    // 🔑 三样一起钉：退出码 + 点名判据 + 扫描面回显（只钉退出码会被「碰巧崩在 2」骗过）
    expectGateRed(run, {
      status: 2,
      marker: '空分母（scanned 0 files）',
      checks: ['no-such-dir-fx', '不可解读', '--allow-empty'],
    })
    expect(run.stdout).not.toContain('✅ audit-product-code pass')
  })

  it('`--allow-empty` 放行空扫描面，但**必须把分母为 0 印出来**（逃逸口不许静默）', () => {
    const root = build({ 'src/fx-clean.vue': CLEAN })
    const run = runGate(root, GATE, ['--dir', 'no-such-dir-fx', '--allow-empty'])
    expect(run.status).toBe(0)
    expect(run.stdout).toContain('空分母（scanned 0 files），已由 --allow-empty 显式放行')
    expect(run.stdout).toContain('别把这次的 exit 0 读成')
  })
})

describe('audit-product-code — argv 分支与 advisory', () => {
  it('`--dir` 改扫描面（那正是 consumer CI 模板里传的形态）', () => {
    const root = build({
      'src/fx-clean.vue': CLEAN,
      'packages/app/src/fx-bad.vue': '<template><button>x</button></template>\n',
    })
    // 默认档扫 src/ ⇒ 绿；指到 packages/... ⇒ 红。同一棵树，只有 argv 不同。
    expectGateGreen(runGate(root, GATE), { contains: ['scanned 1 files under src'] })
    expectGateRed(runGate(root, GATE, ['--dir', 'packages/app/src']), {
      checks: ['R15 — no-native-element'],
    })
  })

  it('`--ext` 收窄后缀集 ⇒ 同一个违例文件不再被扫到（⚠️ 会落进空分母，要显式放行）', () => {
    const root = build({ 'src/fx-bad.vue': '<template><button>x</button></template>\n' })
    expectGateRed(runGate(root, GATE), { checks: ['R15 — no-native-element'] })
    // 🔴 2026-08-26 订正：此前这条断言 `--ext tsx` ⇒ 绿 + `scanned 0 files`，而
    //    [[INFRA-F140]] 的空分母 fail-closed 落地后那正是要拦的形态 ⇒ 必须带
    //    `--allow-empty`。**这条订正本身就是那道闸的价值证明**：它逼「我故意把扫描面
    //    滤空了」显式说出来，而不是和「扫过且干净」共用同一个绿。
    const run = runGate(root, GATE, ['--ext', 'tsx', '--allow-empty'])
    expect(run.status).toBe(0)
    expect(run.stdout).toContain('空分母（scanned 0 files）')
    // ⛔ 反向钉：滤空之后不该再报那条违例
    expect(run.stdout).not.toContain('R15 — no-native-element')
  })

  it('`--json` 面：结构化输出且 exit 码与人读面一致', () => {
    const root = build({ 'src/fx-bad.vue': '<template><button>x</button></template>\n' })
    const run = runGate(root, GATE, ['--json'])
    expect(run.status).toBe(1)
    const report = JSON.parse(run.stdout)
    expect(report.dir).toBe('src')
    expect(report.probes.R15).toBeDefined()
  })

  it('🔴 `IS_MAIN` guard 的回归钉：经 pnpm 形状的 symlink 调用，闸**必须真的跑**', () => {
    // 🔴 **这条 2026-08-26（第四十四轮）补：给上一轮那个修复一个「跨平台」的钉。**
    // 上一轮（`cddb16dd`）修掉的是「`argv[1]` 与 `import.meta.url` 解析 symlink 的程度
    // 不同 ⇒ 布局里有一层 symlink 就 guard 恒假 ⇒ 整条 CLI 静默不跑、exit 0、零输出」。
    //
    // ⚠️ **本文件其余 18 条对那个修复的钉是「平台偶然」的，不是设计出来的**（实测）：
    //    撤掉两侧 realpath 后，在 **macOS** 上 19/19 全红 —— 因为 `os.tmpdir()` 返回
    //    `/var/folders/…` 而 **`/var` 自己就是 symlink**，fixture 无意中就带了一层。
    //    而在 **Linux CI**（`os.tmpdir()` = `/tmp`，通常不是 symlink）上，那 18 条会
    //    **全绿** ⇒ 在真正跑 CI 的那个平台上，那个修复此前是**无钉**的。
    // ⇒ 本条不依赖平台：symlink 由它自己显式造，并 fail-closed 自证 realpath ≠ 调用路径。
    //
    // ⚠️ 必须自己 `spawnSync`：假绿态是 exit 0 且证据在「stderr 也是空的」，而共享
    //    harness 底下的 `execFileSync` 在成功分支不返回 stderr。
    //
    // ⚠️ **违例文件必须放在 consumer 侧，不是包侧** —— 本条初版放错了，于是它撞上的是
    //    `--dir` 那条空分母假绿（`consumer/src` 不存在 ⇒ `scanned 0 files` ⇒ exit 0），
    //    与 `IS_MAIN` 无关，**恰好也是红的**。⇒ 「测试红」不等于「测到了想测的东西」。
    //    闸的扫描面相对 **cwd**（consumer），而它 import 的 spacing-token-map 相对
    //    `import.meta.url`（包侧）—— 两个根，真实 consumer 就是这个形态。
    const root = realpathSync(build({}))

    const consumer = mkdtempSync(join(tmpdir(), 'product-code-consumer-'))
    extraRoots.push(consumer)
    const scopeDir = join(consumer, 'node_modules', '@ux-team')
    mkdirSync(scopeDir, { recursive: true })
    const linkPath = join(scopeDir, 'tvu-design-system')
    symlinkSync(root, linkPath, 'dir')
    // consumer 自己的源码（= 真实布局里被扫的那一侧）
    mkdirSync(join(consumer, 'src'), { recursive: true })
    writeFileSync(join(consumer, 'src', 'fx-bad.vue'), '<template><button>x</button></template>\n')

    // fail-closed 自证：这一层必须真是 symlink 且 realpath ≠ 调用路径，否则本条空过
    expect(lstatSync(linkPath).isSymbolicLink()).toBe(true)
    expect(realpathSync(linkPath)).not.toBe(linkPath)

    // 照 `templates/audit-workflow.yml` 教的形态跑
    const r = spawnSync(
      process.execPath,
      [`node_modules/@ux-team/tvu-design-system/${GATE}`, '--dir', 'src'],
      { cwd: consumer, encoding: 'utf8' },
    )
    // 🔑 三样一起钉：**不是** exit 0、输出**不是**空的、且点名了真违例。
    //    只钉退出码会被「碰巧崩在同一个码上」骗过（本仓「判据必须取终态事实」纪律）。
    expect(r.status).toBe(1)
    expect(`${r.stdout}${r.stderr}`).not.toBe('')
    expect(r.stdout).toContain('R15 — no-native-element')
    expect(r.stdout).toContain('scanned 1 files under src')
  })

  it('🔴 R9 是 advisory：命中也**永不**把退出码变成 1', () => {
    // 闸头注释逐字：「HIGH FP risk → ADVISORY (report-only; never sets exit 1)」。
    // 这条钉住那个承诺 —— 它是本仓「闸印的自我陈述必须与判据一致」纪律的应用面。
    const root = build({
      'src/fx-hoverless.vue':
        '<template><div class="fx-btn">x</div></template>\n<style>\n.fx-btn { padding: var(--sp-md); }\n</style>\n',
    })
    const run = runGate(root, GATE)
    expect(run.status).toBe(0)
    expect(run.stdout).toContain('advisory (report-only)')
  })
})
