// tests/audit-mockup-conformance-cli.test.ts
// -----------------------------------------------------------------------------
// `scripts/audit-mockup-conformance.mjs`（引擎）的**整脚本回归面**，射程限定在它那
// **两处「这次的绿不可全信」的免责声明**：变量端点 403 ⇒ 依赖变量名的判据会 under-report。
//
// 🔴 **WHY 这两处比同族别的 WARN 重：它们在【唯一会自动跑】的那条路上。**
//   同族那批 `audit-mockup-*` 单闸都**无自有挂载**（各自头注释的挂载真源块逐字写明），
//   它们的 403 WARN 只有手动 CLI 单跑才会走到。而本引擎的挂载真源逐字写着
//   「**L4 = .claude/hooks/post-figma-write.sh**（PostToolUse hook：每次 use_figma **写**
//   操作后自动在后台跑一次本引擎）…**这是本族闸历史上第一个自动执行点**」。
//   ⇒ 真实发生的 403 降级，是**在这里**被静默的。
//
// 🔴 **这两处形态不同，而同族处方原来的造故障判据对它们【结构上无效】。**
//   那份处方的判据是「给闸注入 `globalThis.console.warn = () => {}`，看全量红几条」——
//   而本引擎的 `console.warn` 计数是 **0**：
//     · 承载物 ①：`process.stderr.write(...)` —— 不走 console
//     · 承载物 ②：规则模块返回的**结构化 `unverified` 字段**（`{count, reasons}`）—— 根本不是发射
//   ⇒ 2026-09-15 换判据后实测（两处各自注入 + 全量 vitest）：**3526 passed / 0 failed**
//     ⇒ **此前两处都零人守**。本文件补的就是这两处。
//   ⚠️ 那个 `0 failed` 有**阳性对照**撑着，⛔ 不是「装置坏了」的 0 ——
//     同批在 fixture 里跑引擎三臂，读数互不相同且**正交**：
//       | 臂 | 403-WARN 在 stderr | under-report 在 stdout |
//       |---|---|---|
//       | 原样 | 1 | 6 |
//       | 摘掉 `stderr.write` 那行 | **0** | 6（不变）|
//       | `unverified` 恒设 null | 1（不变）| **3** |
//     ⇒ 两处注入各自真的改变了引擎行为，且互不干涉。
//
// ⚠️ **装置：`NODE_OPTIONS=--import <stub>` 覆写 `globalThis.fetch`，被测对象一行不改。**
//   引擎的上游 API 基址是写死常量（见它的 `const API = …`）⇒ 不可参数化，
//   而 [[INFRA-F138]] 逐字封死了「为了让 harness 能用去改活闸」这条路。
//   🔴 **⛔ 别在本文件里写那个 host 字面量、也别写请求头名** ——
//   `audit:figma-env-single-source` 的 S5/S6 标记就是这两个，且逐字声明「出现在**注释**里
//   也算命中…这是刻意的 fail-closed 方向」。本文件不解析凭据（那个 token 是喂给 fixture 的假值）。
//
// 🔴 **「stub 没生效」必须能被抓到**：stub 若没加载，引擎会拿假 token 去打真上游 API，
//   而真 API 对无效 token 同样返回 **403** ⇒ 那句 WARN 照样打出来。
//   ⇒ 反制是 **D3 的 200 臂**：它断言 `resolvedVariableNames` 是 stub 喂进去的**确定条数**，
//   且 `unverified` 变成 `null` —— 真 API 不可能给出这个组合。
//
// ⛔ 污染纪律：本文件**不出现任何别的闸的 `.mjs` basename、也不出现任何别的 `audit:` npm key**。
//   引擎要跑起来得把整批规则模块与它们 import 的同级闸都拷进 fixture ——
//   ⇒ **用 `readdirSync` 动态列，⛔ 不写死文件名**。副作用是好的：规则增删时本面自动跟上。
//
// ⚠️ **本面登记的覆盖边界（⛔ 是边界，不是 TODO）**：
//   1. 只覆盖 **403** 这一个错误码；其它 HTTP 错误走 `throw err`，未钉。
//   2. 只钉 `colors` 这一条规则的 `unverified` 字段。**别的规则模块也可能有自己的
//      `unverified` 契约位，本面⛔ 没有逐条钉**（同批实测：摘掉 colors 那条后 stdout 里
//      仍有 3 处 under-report 字样来自别处 ⇒ 那几处此面看不见）。
//   3. **⛔ 不断言引擎的 exit code** —— 它随 fixture 文档里恰好触发哪几条规则而漂，
//      钉它等于钉一个会变的数。本面断言的是那两处免责声明的**在与不在**。
//   4. `--import` 需 **Node ≥ 20.6**。本仓 `engines` 是 `>=20`，CI 矩阵 `'20'`/`'22'`
//      （setup-node 解析到该主版本最新 ⇒ 满足）。⚠️ lab **只在本机 Node v24.15.0 上实测过**。
//   5. **环境名**：macOS · Node v24.15.0 · vitest。⛔ 别把读数搬到别的环境。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterAll } from 'vitest'
import { readdirSync } from 'node:fs'
import { join, resolve, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import {
  createGateFixture,
  runGate,
  writeFixtureFile,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'

const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
const GATE = 'scripts/audit-mockup-conformance.mjs'

/** 规则模块 + 它们 import 的同级闸 —— 动态列，⛔ 不写死 basename（见污染纪律）。 */
const RULE_MODULES = readdirSync(join(REPO, 'scripts/mockup-rules'))
  .filter((f) => f.endsWith('.mjs'))
  .map((f) => `scripts/mockup-rules/${f}`)
const PEER_RULE_SOURCES = readdirSync(join(REPO, 'scripts'))
  .filter((f) => /^audit-mockup-.+\.mjs$/.test(f) && `scripts/${f}` !== GATE)
  .map((f) => `scripts/${f}`)

/** 哨兵：真 API 不可能返回这些值。 */
const SENTINEL_NODE = '9191:8181'
const GREY_VAR_ID = 'VariableID:4141:3131'
const GREY_VAR_NAME = 'UX/Grey/Grey 4'

/** 引擎那行 403 WARN 的原文（逐字取自它的 403 分支）。 */
const ENGINE_WARN =
  'Warning: /variables/local 403（非 Enterprise plan）—— 依赖变量名的判据会 under-report'

/** colors 规则 `unverified.reasons[0]` 里的特征串（逐字取自规则模块）。 */
const UNVERIFIED_REASON = '它们的绿不可解读为「验过了」'

function engineStub(variablesStatus: 403 | 200): string {
  const doc = JSON.stringify({
    name: 'Probe File',
    lastModified: '2026-09-15T00:00:00Z',
    document: {
      id: '0:0',
      name: 'Document',
      type: 'DOCUMENT',
      children: [
        {
          id: '0:1',
          name: 'Page 1',
          type: 'CANVAS',
          children: [
            {
              id: SENTINEL_NODE,
              name: 'Sentinel Card',
              type: 'RECTANGLE',
              fills: [
                { type: 'SOLID', visible: true, boundVariables: { color: { id: GREY_VAR_ID } } },
              ],
            },
          ],
        },
      ],
    },
    components: {},
    componentSets: {},
    styles: {},
  })
  const vars = JSON.stringify({ meta: { variables: { [GREY_VAR_ID]: { name: GREY_VAR_NAME } } } })
  return `
const DOC = ${doc}
const VARS = ${vars}
globalThis.fetch = async (url) => {
  const u = String(url)
  if (u.includes('/variables/local')) {
    ${
      variablesStatus === 403
        ? `return { ok: false, status: 403, statusText: 'Forbidden',
             text: async () => '{"status":403}', json: async () => ({}) }`
        : `return { ok: true, status: 200, statusText: 'OK',
             text: async () => '', json: async () => VARS }`
    }
  }
  if (u.includes('/components')) {
    return { ok: true, status: 200, statusText: 'OK', text: async () => '',
             json: async () => ({ meta: { components: [] } }) }
  }
  return { ok: true, status: 200, statusText: 'OK', text: async () => '', json: async () => DOC }
}
`
}

function runEngine(variablesStatus: 403 | 200) {
  const root = createGateFixture({
    gate: GATE,
    prefix: 'mockup-conformance-cli',
    copyFiles: [...RULE_MODULES, ...PEER_RULE_SOURCES],
  })
  const stubRel = 'stub/fetch.mjs'
  writeFixtureFile(root, stubRel, engineStub(variablesStatus))
  return runGate(root, GATE, ['--file', 'SENTINELFILEKEY'], {
    FIGMA_PERSONAL_ACCESS_TOKEN: 'fixture-token-not-a-real-credential',
    NODE_OPTIONS: `--import ${pathToFileURL(join(root, stubRel)).href}`,
  })
}

/** 引擎无条件按输出契约在 stdout 尾部发一块 JSON。 */
function contractOf(stdout: string) {
  const m = stdout.match(/\{[\s\S]*\}\s*$/)
  if (!m) throw new Error('引擎没有发出契约 JSON —— 判定面是空的，⛔ 不许当成「字段不在」')
  return JSON.parse(m[0])
}

const colorsRuleOf = (stdout: string) => {
  const rules = contractOf(stdout).rules
  const row = rules.find((r: { ruleId: string }) => r.ruleId === 'colors')
  if (!row) throw new Error('契约里没有 colors 这条规则 —— 判定面是空的')
  return row
}

afterAll(() => cleanupGateFixtures())

describe('audit-mockup-conformance 引擎 —— 403 降级的两处免责声明', () => {
  it('D1 承载物①：403 时 stderr 打出引擎那行 under-report WARN', () => {
    const run = runEngine(403)
    // ⛔ 点名原文，不写「stderr 非空」—— fixture 非 git 仓时 git 的报错会透传进 stderr
    expect(run.stderr).toContain(ENGINE_WARN)
  })

  it('D2 承载物②：403 时 colors 规则在契约里发出结构化 unverified（count + reason 原文）', () => {
    const run = runEngine(403)
    const row = colorsRuleOf(run.stdout)

    expect(row.unverified).not.toBeNull()
    expect(row.unverified.count).toBe(2) // C2 + C3 两条被降级
    expect(row.unverified.reasons[0]).toContain(UNVERIFIED_REASON)
    // 降级的机制本身：一个变量名都没解析出来
    expect(row.resolvedVariableNames).toBe(0)
  })

  it('D3 对照臂：变量名拿得到时，两处免责声明都必须消失（⇒ ⛔ 不是恒真）', () => {
    const run = runEngine(200)

    // 承载物① 消失
    expect(run.stderr).not.toContain(ENGINE_WARN)

    // 承载物② 消失，且 stub 确实接管了 fetch（这个条数是本文件喂进去的）
    const row = colorsRuleOf(run.stdout)
    expect(row.unverified).toBeNull()
    expect(row.resolvedVariableNames).toBe(1)
  })
})
