// tests/audit-gate-regression-ratchet.test.ts
// -----------------------------------------------------------------------------
// `audit:gate-regression-ratchet` 的**整脚本**回归面（[[INFRA-F138]] 的棘轮本体）。
//
// ⚠️ **本文件的存在本身是被测闸的判据之一**：棘轮盯的是「blocking 闸的接线零覆盖数」，
//    而棘轮自己就是一条 `audit-` 前缀 + 有 npm key + blocking 的闸 ⇒ 它进量具的闸清单。
//    没有本文件，棘轮落地当天就会把**自己**算成新增的零覆盖 blocking 闸并当场自红
//    （落地过程中实测：`current 57 / BASELINE 56 / delta +1`，offenders 里点名它自己）。
//    ⇒ 这不是「顺便补个测试」，是闸能不能上的前置。
//
// ⛔ **BASELINE 从活源抽，不手抄**（范式同 `audit-token-contract` 内联 HEADER、
//    `f129-rootradius` 内联 baseline 人口列）：闸里那个数改了，本文件自动跟着走 ——
//    因为本文件钉的是**判据行为**（== 绿 / > 红 / < 红），不是那个数字本身。
//    抽不到（常量被改名 / 换形态）⇒ 本文件当场抛错，这是刻意的 fail-closed。
//
// ⛔ **fixture 里所有假闸都用 `audit-fx-*` 假名**（[[INFRA-F138]] 逐字记的高报纪律）：
//    量具的 E 类判据是「exec 行往下 3 行窗口里出现该闸 basename」，而 fixture 的字面量
//    就紧挨着 `runGate(` —— 写真实闸名会把**那条闸**从「仅判据逻辑」误提到 `E-fx`，
//    是**高报**（把零覆盖伪装成已覆盖）。本文件出现的真实脚本只有两个：被测闸自己
//    （正确的 E-fx 归属）与它 spawn 的量具 `gate-regression-face-inventory.mjs`
//    —— 后者 npm key 是 `report:` 前缀、不在量具的闸清单扫描面（只认 audit|check|lint|smoke）
//    ⇒ 结构上不可能污染。
//
// 覆盖：绿档非空过（fixture 自己的三个读数 + 反向钉）· 增长红 · 该缩红（shrink-only）
//   · 四条 fail-closed（量具缺失 / 非零退出 / 输出畸形 / rows 空）· 口径四钉
//   （E 与 E-fx 都算已覆盖 · I / IL / M / NONE 都算未覆盖 · report-only 不进分子）
//   · 两个调用面（人读 + `--json`）各取一次终态。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterAll } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import {
  REPO_ROOT,
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
  writeFixtureFile,
} from './lib/gate-fixture-root'

const GATE = 'scripts/audit-gate-regression-ratchet.mjs'
/** 棘轮 spawn 的量具。⚠️ `report:` 前缀 ⇒ 不在量具自己的闸清单里，提它零污染。 */
const INVENTORY = 'scripts/gate-regression-face-inventory.mjs'
const CHAIN_LIB = 'scripts/lib/gate-chain-steps.mjs'

/** ⛔ 从活源抽，不手抄。抽不到就抛 —— 刻意 fail-closed。 */
function extractBaseline(): number {
  const src = readFileSync(resolve(REPO_ROOT, GATE), 'utf8')
  const m = /^const BASELINE = (\d+)$/m.exec(src)
  if (!m) {
    throw new Error(
      `无法从 ${GATE} 抽出 BASELINE 常量 —— 它被改名或换了形态？` +
      '先修本测试的抽取正则，⛔ 别在这里写死一个数。'
    )
  }
  return Number(m[1])
}
const BASELINE = extractBaseline()

type FixtureSpec = {
  /** 期望被棘轮算出的 current（blocking ∧ face ∉ {E, E-fx}）。必须 ≥ 2。 */
  current: number
  /** 不把量具拷进 fixture（造 spawn 失败）。 */
  omitInventory?: boolean
  /** 落地后覆盖 fixture 里的文件（造畸形输出 / 空清单）。 */
  overrides?: Record<string, string>
  /**
   * 写进 fixture `package.json` 的 `files[]` 白名单（= 随包发到 consumer 的那批）。
   * 省略 ⇒ 不写这个字段，用来测「随包面判不了」的降级分支。
   */
  files?: string[]
}

const BLOCKING = '// fx gate\nprocess.exit(1)\n'
const REPORT_ONLY = '// fx gate\nprocess.exit(0)\n'

/**
 * 造一棵假 repo：`current` 条会被算进分子的 blocking 闸（拆成 1 条 I + 1 条 IL + 其余 NONE）
 * ＋ 三条**不该**被算进分子的对照（blocking+E · blocking+E-fx · report-only 无覆盖）。
 */
function buildFixture(spec: FixtureSpec): string {
  const { current } = spec
  expect(current).toBeGreaterThanOrEqual(2)

  const files: Record<string, string> = {}
  const scripts: Record<string, string> = {}

  // ── 分子：1 条 I（测试只 import 它）
  files['scripts/audit-fx-i.mjs'] = `export const fxProbe = 1\n${BLOCKING}`
  scripts['audit:fx-i'] = 'node scripts/audit-fx-i.mjs'
  files['tests/fx-i.test.ts'] = "import { fxProbe } from '../scripts/audit-fx-i.mjs'\nexport { fxProbe }\n"

  // ── 分子：1 条 IL（测试只 import 它抽出去的 lib —— owner 选候选② 的核心理由：
  //    这一条在候选①「零判据覆盖(NONE∪M)」里会被一行 import 稀释出去，在这里不会）
  files['scripts/lib/fx-shared.mjs'] = 'export const fxHelper = () => 1\n'
  files['scripts/audit-fx-il.mjs'] = `import { fxHelper } from './lib/fx-shared.mjs'\nfxHelper()\n${BLOCKING}`
  scripts['audit:fx-il'] = 'node scripts/audit-fx-il.mjs'
  files['tests/fx-il.test.ts'] = "import { fxHelper } from '../scripts/lib/fx-shared.mjs'\nexport { fxHelper }\n"

  // ── 分子：其余凑数的 NONE（全仓零测试引用）
  for (let i = 0; i < current - 2; i++) {
    files[`scripts/audit-fx-none${i}.mjs`] = BLOCKING
    scripts[`audit:fx-none${i}`] = `node scripts/audit-fx-none${i}.mjs`
  }

  // ── 对照 1：blocking + E（测试直接 spawn 它，非 fixture-root）⇒ 不进分子
  files['scripts/audit-fx-e.mjs'] = BLOCKING
  scripts['audit:fx-e'] = 'node scripts/audit-fx-e.mjs'
  files['tests/fx-e.test.ts'] =
    "import { spawnSync } from 'node:child_process'\n" +
    "spawnSync('node', ['scripts/audit-fx-e.mjs'])\n"

  // ── 对照 2：blocking + E-fx（spawn ＋ mkdtemp ⇒ 量具判 fixtureRoot）⇒ 不进分子
  files['scripts/audit-fx-efx.mjs'] = BLOCKING
  scripts['audit:fx-efx'] = 'node scripts/audit-fx-efx.mjs'
  files['tests/fx-efx.test.ts'] =
    "import { spawnSync } from 'node:child_process'\n" +
    "import { mkdtempSync } from 'node:fs'\n" +
    "const root = mkdtempSync('fx-')\n" +
    "spawnSync('node', ['scripts/audit-fx-efx.mjs'], { cwd: root })\n"

  // ── 对照 3：report-only 且零覆盖 ⇒ 不进分子（分子只数 blocking）
  files['scripts/audit-fx-ro.mjs'] = REPORT_ONLY
  scripts['audit:fx-ro'] = 'node scripts/audit-fx-ro.mjs'

  const pkg: Record<string, unknown> = { name: 'fx-root', scripts }
  if (spec.files) pkg.files = spec.files
  files['package.json'] = JSON.stringify(pkg, null, 2)

  const root = createGateFixture({
    gate: GATE,
    prefix: 'gate-ratchet-fx',
    // ⛔ 关掉默认的 `scripts/lib` 软链 —— fixture 自己要造 `scripts/lib/fx-shared.mjs`，
    //    软链回真仓库会让那条 IL 假闸的 import 落到真仓库的 lib 目录上。
    linkDirs: [],
    // 棘轮 spawn 量具；量具 import 链条常量 lib。两者都必须**拷**（软链会让 ESM realpath
    // 把量具的 `import.meta.url` 解回真仓库 ⇒ 它去数真仓库的 84 条闸，fixture 完全失效且照样绿）。
    copyFiles: spec.omitInventory ? [CHAIN_LIB] : [INVENTORY, CHAIN_LIB],
    files,
  })

  for (const [rel, content] of Object.entries(spec.overrides ?? {})) {
    writeFixtureFile(root, rel, content)
  }
  return root
}

/**
 * 🔴 **fail-closed 专用断言：红得干净，不是崩出来的。**
 *
 * 起因是本文件落地当天的**故障注入 ③**：把 `failClosed()` 末尾的 `process.exit(1)` 换成
 * `return`（= 摘掉 fail-closed 的出口）后，五条 fail-closed 用例**一条都没红** ——
 * 因为 `failClosed` 的 `console.error` 在 return 之前已经打完，而函数返回后 `report`
 * 是 undefined，脚本**崩溃**，崩溃同样是 `exit 1`。⇒ `status === 1` ＋ 点名判据两条断言
 * 全都满足，测试看不出闸已经不再是「判据在阻断」而是「碰巧崩在同一个退出码上」。
 *
 * ⇒ 判据必须取**终态事实**：闸自己的最后一句话是不是输出的最后一行。
 *   · 健康态：failClosed 打完四行立刻 exit ⇒ 末行 = 那句 ⛔ 提示
 *   · 注入 ③：末行 = `Node.js v24.x` 崩溃栈尾
 * ⛔ 别退化成「stderr 不含 node:internal/」—— 「量具不存在」那条用例会把量具自己的
 *    模块解析栈**如实带出来**（那是 detail 参数，是特性不是缺陷），那样写会误伤。
 */
function expectFailClosedCleanly(run: { status: number; stdout: string; stderr: string }, checks: string[]): void {
  const out = `${run.stderr}\n${run.stdout}`
  expect(run.status).toBe(1)
  for (const c of checks) expect(out).toContain(c)
  expect(out.trimEnd().endsWith('那正是本棘轮要防的假绿。')).toBe(true)
}

/** fixture 里的闸总数 = 分子 current 条 ＋ 三条对照。 */
const totalGatesOf = (current: number) => current + 3
/** fixture 里的 blocking 闸数 = current ＋ E ＋ E-fx（report-only 那条不算）。 */
const blockingGatesOf = (current: number) => current + 2

afterAll(() => cleanupGateFixtures())

describe('audit:gate-regression-ratchet — 绿档（current == BASELINE）', () => {
  it('持平 ⇒ PASS，且自印的是 fixture 自己的读数（非空过凭据）', () => {
    const root = buildFixture({ current: BASELINE })
    const run = runGate(root, GATE)

    expectGateGreen(run, {
      contains: [
        // 三个互不相关的 fixture 读数：闸总数 · blocking 数 · delta
        `闸清单 ${totalGatesOf(BASELINE)} 条`,
        `其中 blocking ${blockingGatesOf(BASELINE)} 条`,
        `BASELINE ${BASELINE} · 现取 ${BASELINE} · delta +0`,
        '✅ PASS',
      ],
    })

    // ⛔ 反向钉：真仓库的闸清单是 80+ 条，且 fixture 里根本没有 figma-sync/ 目录。
    // 跑错了树（量具退回真仓库读）时这两条会立刻转红。
    expect(run.stdout).not.toContain('figma-sync/')
    expect(run.stdout).not.toContain(`闸清单 ${totalGatesOf(BASELINE) + 1} 条`)
  })

  it('绿档也要说清「PASS ≠ 闸都被守住了」（免得读成健康度）', () => {
    const root = buildFixture({ current: BASELINE })
    const run = runGate(root, GATE)
    expect(run.stdout).toContain('别把 PASS 读成')
    expect(run.stdout).toContain(`这 ${BASELINE} 条 blocking 闸的接线仍是零覆盖`)
  })
})

describe('audit:gate-regression-ratchet — 红档', () => {
  it('增长一条 ⇒ 红，且点名「新增的 blocking 闸必须带整脚本回归面」', () => {
    const root = buildFixture({ current: BASELINE + 1 })
    const run = runGate(root, GATE)
    expectGateRed(run, {
      checks: [
        '增长了 1 条',
        '新增的 blocking 闸必须带整脚本回归面',
        'tests/lib/gate-fixture-root.ts',
        // 逃逸口要被逐字堵上：改名躲开扫描面是更大的洞
        '别为了过闸把新脚本改名成非 audit/check/smoke 前缀',
      ],
    })
    // 点名清单里必须真的列出 offender（⛔ 只报数不报名 = 不可行动）
    expect(`${run.stderr}${run.stdout}`).toContain('scripts/audit-fx-none0.mjs')
  })

  it('少一条 ⇒ 也红（shrink-only），且逐字给出该改成的实测值', () => {
    const root = buildFixture({ current: BASELINE - 1 })
    const run = runGate(root, GATE)
    expectGateRed(run, {
      checks: [
        '少了 1 条',
        `把 scripts/audit-gate-regression-ratchet.mjs 里的 BASELINE 改成 ${BASELINE - 1}`,
        '表只许缩，且由本闸宣布缩到哪了',
      ],
    })
  })
})

describe('audit:gate-regression-ratchet — 口径钉（哪些算进分子）', () => {
  it('E 与 E-fx 都算「接线有覆盖」⇒ 不进分子', () => {
    // 绿档已含各 1 条对照；这里再证「把它们变成零覆盖会让分子涨 2」
    const root = buildFixture({
      current: BASELINE,
      overrides: {
        // 抹掉两份 spawn 测试的内容 ⇒ 两条对照从 E / E-fx 掉回 NONE
        'tests/fx-e.test.ts': 'export const x = 1\n',
        'tests/fx-efx.test.ts': 'export const y = 1\n',
      },
    })
    const run = runGate(root, GATE)
    expectGateRed(run, { checks: ['增长了 2 条'] })
  })

  it('I 类（只 import 它导出的符号）算未覆盖 —— 这正是本闸存在的理由', () => {
    const root = buildFixture({ current: BASELINE })
    const run = runGate(root, `${GATE}`, ['--json'])
    const report = JSON.parse(run.stdout)
    expect(report.offenders.map((o: { gate: string }) => o.gate)).toContain('scripts/audit-fx-i.mjs')
    expect(report.offenders.find((o: { gate: string }) => o.gate === 'scripts/audit-fx-i.mjs').face)
      .toBe('I')
  })

  it('🔴 IL 类（只 import 它抽出去的 lib）算未覆盖 —— 候选① 的一行逃逸口在这里堵死', () => {
    const root = buildFixture({ current: BASELINE })
    const run = runGate(root, GATE, ['--json'])
    const report = JSON.parse(run.stdout)
    const il = report.offenders.find((o: { gate: string }) => o.gate === 'scripts/audit-fx-il.mjs')
    expect(il).toBeDefined()
    expect(il.face).toBe('IL')
  })

  it('report-only 闸不进分子（哪怕零覆盖）', () => {
    const root = buildFixture({ current: BASELINE })
    const run = runGate(root, GATE, ['--json'])
    const report = JSON.parse(run.stdout)
    expect(report.offenders.map((o: { gate: string }) => o.gate)).not.toContain('scripts/audit-fx-ro.mjs')
    expect(report.totalGates).toBe(totalGatesOf(BASELINE))
    expect(report.blockingGates).toBe(blockingGatesOf(BASELINE))
  })
})

describe('audit:gate-regression-ratchet — 随包面分组（report-only，⛔ 不参与判据）', () => {
  it('files[] 里的 offender 被单独数出来，且逐字说明它的代价更大', () => {
    const root = buildFixture({
      current: BASELINE,
      files: ['scripts/audit-fx-i.mjs', 'scripts/audit-fx-il.mjs'],
    })
    const run = runGate(root, GATE)
    expectGateGreen(run, {
      contains: [
        '└ 其中 2 条在 package.json files[] 里（report-only，不参与判据）',
        '随下一个 npm 版本分发到所有 consumer 的 CI',
      ],
    })
  })

  it('files[] 的 `*` 通配也认（本仓真的用了 scripts/audit-mockup-*.mjs 这种形态）', () => {
    const root = buildFixture({ current: BASELINE, files: ['scripts/audit-fx-none*.mjs'] })
    const run = runGate(root, GATE, ['--json'])
    const report = JSON.parse(run.stdout)
    // fixture 的分子里凑数的 NONE 共 current-2 条，全部匹配这个 glob
    expect(report.shippedOffenders).toHaveLength(BASELINE - 2)
    expect(report.shippedFaceUnavailable).toBeNull()
    // ⛔ 反向钉：那两条具名的（-i / -il）不该被 none* 匹配上
    const names = report.shippedOffenders.map((o: { gate: string }) => o.gate)
    expect(names).not.toContain('scripts/audit-fx-i.mjs')
    expect(names).not.toContain('scripts/audit-fx-il.mjs')
  })

  it('🔴 随包面判不了时**降级可见、且不阻断主判据**（fail-open 是刻意的）', () => {
    // 不写 files[] ⇒ 分组算不出。主判据（持平）必须照样绿。
    const root = buildFixture({ current: BASELINE })
    const run = runGate(root, GATE)
    expectGateGreen(run, {
      contains: ['⚠️ 随包面判不了（package.json 没有 files[] 白名单）', '不影响上面的判据', '✅ PASS'],
    })
    const j = runGate(root, GATE, ['--json'])
    const report = JSON.parse(j.stdout)
    expect(report.shippedOffenders).toBeNull()
    expect(report.shippedFaceUnavailable).toBe('package.json 没有 files[] 白名单')
    // ⛔ 关键：report-only 的降级**不许**改变退出码
    expect(j.status).toBe(0)
  })

  it('超标清单里随包的那条带 📦 标记（优先级要能一眼看出）', () => {
    const root = buildFixture({ current: BASELINE + 1, files: ['scripts/audit-fx-none0.mjs'] })
    const run = runGate(root, GATE)
    const out = `${run.stderr}${run.stdout}`
    expect(run.status).toBe(1)
    expect(out).toContain('📦')
    // 标记必须落在那一条上，不是随便印一个 emoji
    expect(out).toMatch(/📦\s+NONE\s+scripts\/audit-fx-none0\.mjs/)
  })
})

describe('audit:gate-regression-ratchet — fail-closed（拿不到读数时拒绝出绿）', () => {
  it('量具不存在 ⇒ 红，而不是把 current 当 0', () => {
    const root = buildFixture({ current: BASELINE, omitInventory: true })
    const run = runGate(root, GATE)
    expectFailClosedCleanly(run, ['分母 fail-closed'])
    // ⛔ 关键：current=0 < BASELINE 会走「该缩」分支，那是**假绿方向的误报**
    expect(`${run.stderr}${run.stdout}`).not.toContain('BASELINE 改成 0')
  })

  it('量具非零退出（闸清单为空）⇒ 红，并把量具的 stderr 带出来', () => {
    const root = buildFixture({
      current: BASELINE,
      // package.json 里一个 gate key 都不剩 ⇒ 量具自己的分母 fail-closed 触发
      overrides: { 'package.json': JSON.stringify({ name: 'fx-root', scripts: {} }, null, 2) },
    })
    const run = runGate(root, GATE)
    expectFailClosedCleanly(run, ['量具以非零退出', '闸清单为空'])
  })

  it('量具输出不是 JSON ⇒ 红（解析失败）', () => {
    const root = buildFixture({
      current: BASELINE,
      overrides: { [INVENTORY]: "console.log('not json at all')\nprocess.exit(0)\n" },
    })
    const run = runGate(root, GATE)
    expectFailClosedCleanly(run, ['--json 输出解析失败'])
  })

  it('rows 为空数组 ⇒ 红（此时任何统计都恒为 0）', () => {
    const root = buildFixture({
      current: BASELINE,
      overrides: { [INVENTORY]: "console.log(JSON.stringify({ rows: [] }))\nprocess.exit(0)\n" },
    })
    const run = runGate(root, GATE)
    expectFailClosedCleanly(run, ['量具的闸清单为空'])
  })

  it('rows 里缺字段 ⇒ 红（输入形态错要 fail closed，⛔ 不静默按 falsy 处理）', () => {
    const root = buildFixture({
      current: BASELINE,
      overrides: {
        [INVENTORY]:
          "console.log(JSON.stringify({ rows: [{ gate: 'scripts/audit-fx-x.mjs', face: 'NONE' }] }))\n" +
          'process.exit(0)\n',
      },
    })
    const run = runGate(root, GATE)
    expectFailClosedCleanly(run, ['缺 blocking / face / gate 字段'])
  })
})

describe('audit:gate-regression-ratchet — 两个调用面各取一次终态', () => {
  it('--json 面：持平时 exit 0 且 delta 为 0', () => {
    const root = buildFixture({ current: BASELINE })
    const run = runGate(root, GATE, ['--json'])
    expect(run.status).toBe(0)
    const report = JSON.parse(run.stdout)
    expect(report.auditId).toBe('gate-regression-ratchet')
    expect(report.baseline).toBe(BASELINE)
    expect(report.current).toBe(BASELINE)
    expect(report.delta).toBe(0)
  })

  it('--json 面：增长时同样 exit 1（⛔ 别只在人读面阻断）', () => {
    const root = buildFixture({ current: BASELINE + 1 })
    const run = runGate(root, GATE, ['--json'])
    // 两侧都钉：退出码 + 载荷。只钉任一个都有整档测不出来。
    expect(run.status).toBe(1)
    const report = JSON.parse(run.stdout)
    expect(report.delta).toBe(1)
    expect(report.current).toBe(BASELINE + 1)
  })

  it('未知参数 ⇒ 红（⛔ 别把畸形输入当默认模式跑）', () => {
    const root = buildFixture({ current: BASELINE })
    const run = runGate(root, GATE, ['--totally-unknown'])
    expectGateRed(run, { checks: ['未知参数'] })
  })
})
