// tests/audit-figma-variables-freshness-cli.test.ts
// -----------------------------------------------------------------------------
// `scripts/audit-figma-variables-freshness.mjs` 的**整脚本回归面**，射程限定在它那
// **四条绿档 WARN 通路**（V4 之外的全部 warnings）。
//
// WHY 这四条值得单独钉（⛔ 不是「顺手补个测试」）：
//   它们全部发生在 **exit 0** 那一趟里 —— 收据说抓取失败了 / 恒等式对不上 /
//   已知 stale 被 ack 压着 / ack 过期了没人删。闸照样放行，**WARN 是绿档唯一的免责声明**。
//   这类判据一旦退化成静默（`console.warn` 被吞、warnings 被挪进 `failures.length` 的
//   else 枝），**没有任何退出码会变**，于是没有任何人会发现。
//
// ⚠️ **这条闸与同族别的闸不同的一点**：2026-09-14 真仓库实跑 = **exit 0 且 stderr 有
//   一条 WARN**（403 断流，`lastFailure` 比 `lastSuccess` 新）⇒ 与 artifact-routing 那条
//   「现树零触发」相反，**它现在就在打**。⛔ 但别把真仓库的读数当验证 ——
//   那条 WARN 的内容随 `figma-data/` 的活状态漂，钉它等于钉一个会变的数。
//   ⇒ 本面一律走 fixture，四条通路各有**自己的开关**（见 `receipt()` 的四种参数组合）。
//
// ⛔ 与 `tests/figma-variables-pipeline.test.ts` 的分工（⛔ 别读成重复）：
//   那份钉的是 **V4**（normalized 能否由 raw 重算）——`v4Hit(out)).toBe(1)` 那一族，
//   2026-09-14 造故障实测：摘掉本闸的 `console.warn` ⇒ **只有那一条红**，其余全绿
//   ⇒ V4 有人守，**V4 之外的四条没人守**。本文件补的正是那四条。
//   ⇒ 本面**刻意不测 V4**：fixture 的 normalized 由 `computeNormalizedVariables` 现算
//   （见 `fixture()`），V4 在这里**恒过**，它是布景、⛔ 不是被测对象。
//
// ⛔ 断言必须**点名 WARN 的原文**，⛔ 不许写 `expect(stderr).not.toBe('')`：
//   fixture 不是 git 仓，闸内 `lastChangeIso` 的 `execFileSync git` 会把
//   `fatal: not a git repository` 透传进 stderr（实测 2 行）⇒「非空」这类断言**恒真**。
//   ⇒ 零 WARN 的对照臂（B1）判的是「**没有以 `⚠ ` 开头的行**」，⛔ 不是「stderr 为空」。
//
// ⚠️ **WARN 前缀是 `⚠ `（U+26A0 裸码位 + 一个空格），⛔ 不是 `⚠️ `（带 VS16）**。
//   逐字取自闸的汇报段 `for (const w of warnings) console.warn(\`⚠ ${w}\`)`。
//   两者字节不同（`e2 9a a0` vs `e2 9a a0 ef b8 8f`）⇒ 手打错一个就全组假绿。
//
// ⚠️ **两条流各持一半证据，断言时⛔ 别搞混**（逐字读闸的汇报段确认过）：
//     · WARN 明细 `⚠ <正文>`              → `console.warn`  → **stderr**
//     · FAIL 段 `✗ 变量层新鲜度闸 FAIL（N 条）` → `console.error` → **stderr**
//     · 绿档摘要 `✓ 变量层新鲜度闸 PASS …`   → `console.log`   → **stdout**
//   ⚠️ **红档的 stdout 是零字节**（实测）⇒ 「守卫恒假」与「红档」在 stdout 上同码，
//      分得开它们的只有退出码 + stderr 的 FAIL 段。
//
// ⛔ 污染纪律：本文件除被测闸自身外**不出现任何别的闸的 `.mjs` basename、也不出现任何
//   别的 `audit:` npm key` —— 否则按名字扫的量具会把那条闸误报成「已覆盖」。
//
// ⚠️ **本面登记的覆盖边界（⛔ 是边界，不是 TODO）**：
//   1. 只抓 **must-hit** 那一侧（「摘掉 WARN 会不会红」）。若某处断言的是「WARN **不**
//      出现」，摘掉反而不红 ⇒ 本面结构上看不见那类覆盖。
//   2. **V4 那条 WARN（`:281` 归一化层被单独改动）不在本面射程** —— 见上方分工。
//   3. fixture 的 raw 由 `FIGMA_NAME_TO_CSS` / `INTENTIONALLY_UNMAPPED` **现读现生成**
//      ⇒ 两张表变了 fixture 自动跟上（⛔ 刻意不写死变量名，那会让本面随表漂）。
//      代价：本面因此**依赖那两张表非空**，表被清空时 fixture 会退化 —— 由 B2 的自印
//      分母兜底（它钉死 `raw 20 = normalized 6 + … + 定案不映射 14`）。
//   4. 时间基准取**当前时钟**的相对偏移（2 天 / 40 天，阈值 30 天）⇒ 离边界各 28/10 天，
//      ⛔ 未覆盖「恰好卡在第 30 天」那一格。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterAll } from 'vitest'
import { join } from 'node:path'
import {
  createGateFixture,
  runGate,
  writeFixtureFile,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'
import {
  FIGMA_NAME_TO_CSS,
  FLOAT_CSS_UNIT,
  INTENTIONALLY_UNMAPPED,
} from '../figma-sync/variable-map.mjs'
import { computeNormalizedVariables } from '../figma-sync/normalize.mjs'

const GATE = 'scripts/audit-figma-variables-freshness.mjs'

/**
 * 被测闸 `import` 的同仓模块 —— ⛔ 必须 `copyFiles`（拷）而不是 `linkDirs`（链）：
 * 这三份自己不算 REPO_ROOT，但 ESM 解析 symlink 走 realpath，链进来会让闸的
 * `import.meta.url` 落回真仓库（见 harness 里 `copyFiles` 的头注释）。
 *
 * ⚠️ `linkDirs` **刻意不传**（= 留默认软链 `scripts/lib`）：`variable-map.mjs` 里
 * `import { isCliEntry } from '../scripts/lib/is-cli-entry.mjs'` ⇒ 传 `[]` 会 ENOENT 崩，
 * **而崩溃也是非零退出** ⇒ 红得理由不对。⛔ 别照抄同族那份传 `[]` 的闸。
 */
const COPY_FILES = [
  'figma-sync/variable-map.mjs',
  'figma-sync/normalize.mjs',
  'figma-sync/lib/variables-payload.mjs',
]

afterAll(() => cleanupGateFixtures())

// ── fixture 素材 ────────────────────────────────────────────────────────────
// 真仓库现取（2026-09-14 @`7dcc65d1`）：`raw 98 = normalized 84 + 具名待映射 0 + 定案不映射 14`。
// fixture 的数是 `raw 20 = normalized 6 + 0 + 14` ⇒ 前两项与真仓库**全不相同**，
// 闸若退回真仓库读，B2 必炸。（第三、四项刻意同源 —— 它们读的就是同两张表。）

const COLLECTION_ID = 'VariableCollectionId:FX:1'
/** 六个假色值，⛔ 不取真仓库的值 —— 那样「闸读了哪份数据」就分不出来了。 */
const PALETTE = ['#f0f0f1', '#f0f0f2', '#f0f0f3', '#f0f0f4', '#f0f0f5', '#f0f0f6']

/** 已映射的 COLOR 变量名：cssVar 不在 `FLOAT_CSS_UNIT` 里的那些。 */
const MAPPED_COLOR_NAMES = Object.entries(FIGMA_NAME_TO_CSS)
  .filter(([, css]) => !((css as string) in FLOAT_CSS_UNIT))
  .slice(0, PALETTE.length)
  .map(([name]) => name)

/**
 * ⚠️ `INTENTIONALLY_UNMAPPED` 的名字**必须全员在场**：闸对这张表是 shrink-only 校验，
 * 少一个就报 `[V3] … 上游已经没有的名字` 并让 V4 **判不了** ⇒ 整趟变红，四条 WARN 一条测不到。
 */
const INTENTIONAL_NAMES = Object.keys(INTENTIONALLY_UNMAPPED)

const RAW_JSON = JSON.stringify(
  {
    extractedAt: '2026-09-10T00:00:00.000Z',
    figmaFileKey: 'FXFIXTUREKEY',
    source: 'fixture',
    payloadSha256: '0'.repeat(64),
    collections: [
      {
        id: COLLECTION_ID,
        name: 'FX Fixture Collection',
        modes: [
          { id: 'FX:m1', name: 'Dark' },
          { id: 'FX:m2', name: 'Light' },
        ],
      },
    ],
    variables: [
      ...MAPPED_COLOR_NAMES.map((name, i) => ({
        id: `VariableID:FX:${i + 1}`,
        name,
        collectionId: COLLECTION_ID,
        resolvedType: 'COLOR',
        values: { Dark: PALETTE[i], Light: PALETTE[i] },
      })),
      ...INTENTIONAL_NAMES.map((name, i) => ({
        id: `VariableID:FX:9${i + 1}`,
        name,
        collectionId: COLLECTION_ID,
        resolvedType: 'FLOAT',
        values: { Dark: i + 1, Light: i + 1 },
      })),
    ],
    total: MAPPED_COLOR_NAMES.length + INTENTIONAL_NAMES.length,
  },
  null,
  2,
)

/** 恒等式的右边：`normalized + 具名待映射 + 定案不映射`。收据写这个数就不触发 A2。 */
const BALANCED_COUNT = MAPPED_COLOR_NAMES.length + INTENTIONAL_NAMES.length

const DAY = 86_400_000
const iso = (daysAgo: number) => new Date(Date.now() - daysAgo * DAY).toISOString()
const ymd = (daysAhead: number) =>
  new Date(Date.now() + daysAhead * DAY).toISOString().slice(0, 10)

/** 2 天前 ⇒ 远在 30 天阈值内（不 stale）。 */
const FRESH_AT = iso(2)
/** 40 天前 ⇒ 超阈值（stale）。 */
const STALE_AT = iso(40)

type ReceiptOpts = {
  /** `lastSuccess.at`，默认新鲜。 */
  at?: string
  /** 收据自报的上游条数；默认取平衡值 ⇒ 不触发恒等式 WARN。 */
  count?: number
  /** 造一条比 `lastSuccess` 更新的失败记录 ⇒ 触发「断流」WARN。 */
  failedAt?: string
}

const receipt = (o: ReceiptOpts = {}): string =>
  JSON.stringify(
    {
      lastSuccess: { at: o.at ?? FRESH_AT, variableCount: o.count ?? BALANCED_COUNT },
      ...(o.failedAt
        ? { lastFailure: { at: o.failedAt, message: 'FX fixture: 429 rate limited' } }
        : {}),
    },
    null,
    2,
  )

/** 一份**结构完整**的 ack（reason / evidence / 钉住快照 / 未过期）⇒ 走 WARN 而不是 FAIL。 */
const ack = (ackedFor: string): string =>
  JSON.stringify(
    {
      reason: 'FX fixture: 已知堵死',
      evidence: ['fx-evidence-url'],
      ackedForLastSuccessAt: ackedFor,
      reviewBy: ymd(30),
    },
    null,
    2,
  )

type FxOpts = { receipt?: string | null; ack?: string }

/**
 * 建 fixture root。**两步**，⛔ 不能合成一步：
 *   ① `createGateFixture` 落 raw（`computeNormalizedVariables` 读的就是它）
 *   ② 现算 normalized 再写盘 ⇒ 两者**自洽** ⇒ V4 恒过（布景，见文件头分工）
 * ⚠️ 这里 import 的是**真仓库**那份 `normalize.mjs`，而闸跑的是 fixture 里的拷贝 ——
 *   `copyFiles` 每次跑测试现拷活源 ⇒ 两份同内容。
 */
function fixture(opts: FxOpts = {}): string {
  const files: Record<string, string> = { 'figma-data/raw/variables.json': RAW_JSON }
  if (opts.receipt !== null) {
    files['figma-data/variables-sync-status.json'] = opts.receipt ?? receipt()
  }
  if (opts.ack) files['figma-data/audit-allowlist/variables-staleness-ack.json'] = opts.ack

  const root = createGateFixture({
    gate: GATE,
    prefix: 'variables-freshness-fx',
    copyFiles: COPY_FILES,
    files,
  })
  const { result } = computeNormalizedVariables({ dataDir: join(root, 'figma-data') })
  writeFixtureFile(root, 'figma-data/normalized/variables.json', JSON.stringify(result, null, 2))
  return root
}

/** stderr 里以 `⚠ `（U+26A0 裸）开头的行 —— 唯一可靠的 WARN 计数口径。 */
const warnLines = (stderr: string): string[] =>
  stderr.split('\n').filter((l) => l.startsWith('⚠ '))

describe('A 绿档 WARN 四条通路（本文件存在的理由：exit 0 的那一趟里，免责声明有没有被说出来）', () => {
  it('A1 🔴 收据说上次抓取**失败** ⇒ 仍 exit 0，stderr 逐字打出「断流」WARN', () => {
    // 这正是真仓库 2026-09-14 的现状（403）—— 绿档下唯一说出「变量层是断的」那句话。
    const r = runGate(fixture({ receipt: receipt({ failedAt: iso(1) }) }), GATE)
    expect(r.status, `stderr:\n${r.stderr}\n---stdout---\n${r.stdout}`).toBe(0)
    expect(r.stderr).toContain('最近一次抓取是**失败**')
    expect(r.stderr).toContain('FX fixture: 429 rate limited')
    expect(r.stderr).toContain('变量层当前处于断流状态')
    // must-not-hit：只该有这一条，⛔ 不是「见收据就打」
    expect(warnLines(r.stderr)).toHaveLength(1)
  })

  it('A2 🔴 恒等式对不上 ⇒ 仍 exit 0，WARN 点名**三个分项**而不只说「对不上」', () => {
    // 判据 = `收据的 variableCount !== normalized + 待映射 + 定案不映射`。
    // 钉分项是因为：只报总数时，读者无法判断该去刷新哪一层。
    const r = runGate(fixture({ receipt: receipt({ count: BALANCED_COUNT + 79 }) }), GATE)
    expect(r.status, `stderr:\n${r.stderr}`).toBe(0)
    expect(r.stderr).toContain(`收据记的 variableCount=${BALANCED_COUNT + 79}`)
    expect(r.stderr).toContain(`的 ${MAPPED_COLOR_NAMES.length} 条`)
    expect(r.stderr).toContain(`定案不映射 ${INTENTIONAL_NAMES.length} 条`)
    expect(r.stderr).toContain('归一化层可能没跟上最后一次成功抓取')
    expect(warnLines(r.stderr)).toHaveLength(1)
  })

  it('A3 🔴 已知 stale 被**完整 ack** 压着 ⇒ 从 FAIL 降级成 WARN，且说出「覆盖不等于解决」', () => {
    // ⚠️ 这一条是整组里语义最重的：ack 的存在**把一条红变成了绿**。
    //    那句「覆盖不等于解决」是绿档里唯一还记得这件事的东西。
    const r = runGate(fixture({ receipt: receipt({ at: STALE_AT }), ack: ack(STALE_AT) }), GATE)
    expect(r.status, `stderr:\n${r.stderr}`).toBe(0)
    expect(r.stderr).toContain('未成功刷新')
    expect(r.stderr).toContain('已被 ack 覆盖')
    expect(r.stderr).toContain('FX fixture: 已知堵死')
    expect(r.stderr).toContain('覆盖不等于解决')
    expect(warnLines(r.stderr)).toHaveLength(1)
  })

  it('A4 🔴 已恢复新鲜但 ack 还在 ⇒ 仍 exit 0，WARN 催删（防下一次真 stale 被旧 ack 放行）', () => {
    const r = runGate(fixture({ ack: ack(FRESH_AT) }), GATE)
    expect(r.status, `stderr:\n${r.stderr}`).toBe(0)
    expect(r.stderr).toContain('变量层已恢复新鲜')
    expect(r.stderr).toContain('别让下一次真 stale 被旧 ack 静默放行')
    expect(warnLines(r.stderr)).toHaveLength(1)
  })

  it('A5 🔴 两条通路**并存**时各自都开火 ⇒ stderr 上是 2 条，⛔ 不是 1 条', () => {
    // A1–A4 每条都只验了「单独触发」。若有人把 warnings 收敛成「只报第一条」，
    // 前四条**全都还绿**，只有这一条红。
    const r = runGate(
      fixture({ receipt: receipt({ count: BALANCED_COUNT + 79, failedAt: iso(1) }) }),
      GATE,
    )
    expect(r.status, `stderr:\n${r.stderr}`).toBe(0)
    const lines = warnLines(r.stderr)
    expect(lines).toHaveLength(2)
    expect(r.stderr).toContain('收据记的 variableCount=')
    expect(r.stderr).toContain('变量层当前处于断流状态')
  })
})

describe('B 对照臂（⛔ 缺这一组，A 组分不清「真的打了 WARN」与「恒真」）', () => {
  it('B1 🔴 四个开关全关 ⇒ 仍 exit 0，但一条 `⚠ ` 都没有', () => {
    // ⛔ 判据是「没有 `⚠ ` 开头的行」而**不是** `stderr === ''` ——
    //    fixture 非 git 仓，闸内 `execFileSync git` 会透传 `fatal: not a git repository`。
    const r = runGate(fixture(), GATE)
    expect(r.status, `stderr:\n${r.stderr}`).toBe(0)
    expect(warnLines(r.stderr)).toHaveLength(0)
    // 正向锚点：判据链**真的跑完了**（⛔ 否则「零 WARN」可能只是闸没执行）
    expect(r.stdout).toContain('✓ 变量层新鲜度闸 PASS')
    // 反向锚点：那两行 git 噪声确实在 ⇒ 证明「非空断言会恒真」这件事不是假设
    expect(r.stderr).toContain('not a git repository')
  })

  it('B2 非空过凭据：自印的分母是 **fixture 自己的**，不是真仓库的', () => {
    const r = runGate(fixture(), GATE)
    expect(r.stdout).toContain(
      `raw ${BALANCED_COUNT} = normalized ${MAPPED_COLOR_NAMES.length} + 具名待映射 0 + 定案不映射 ${INTENTIONAL_NAMES.length}`,
    )
    // ⛔ 反向：真仓库读数绝不该出现（闸退回真仓库读 ⇒ 本条必炸）
    expect(r.stdout).not.toContain('raw 98')
    expect(r.stdout).not.toContain('normalized 84')
  })
})

describe('C 接线（这一层只 import 判据函数结构上看不见）', () => {
  it('C1 🔴 入口守卫真的开火了 —— 恒假时是「exit 0 且零输出」，与全绿逐字同码', () => {
    // 闸用 `isCliEntry` 守卫；守卫恒假 ⇒ 整个 main 不执行 ⇒ exit 0 + stdout 空。
    // ⛔ 判据取**终态事实**：绿档首行必须是闸自己的收尾句。
    const r = runGate(fixture(), GATE)
    expect(r.status).toBe(0)
    expect(r.stdout.trim()).not.toBe('')
    expect(r.stdout.trim().split('\n')[0]).toContain('✓ 变量层新鲜度闸 PASS')
  })

  it('C2 阻断出口接上了：收据整份缺失 ⇒ exit 1，FAIL 段走 **stderr**、stdout 零字节', () => {
    const r = runGate(fixture({ receipt: null }), GATE)
    expect(r.status).toBe(1)
    expect(r.stderr).toContain('✗ 变量层新鲜度闸 FAIL')
    expect(r.stderr).toContain('[V1] 缺 figma-data/variables-sync-status.json')
    // ⚠️ 红档 stdout 是零字节（实测）⇒ 它与「守卫恒假」在 stdout 上同码，
    //    分得开二者的只有退出码 + 这段 FAIL 文本。
    expect(r.stdout).toBe('')
  })

  it('C3 🔴 FAIL 与 WARN **并存**时两段都执行 —— WARN 不在 `failures.length` 的 else 里', () => {
    // 把 WARN 那段挪进「没 fail 才提」是个很自然的退化（「都红了还提什么免责」），
    // 而 A 组与 C2 都抓不到它：A 组的 fixture 从不红，C2 的 fixture 从不 WARN。
    const r = runGate(
      fixture({ receipt: receipt({ at: STALE_AT, failedAt: iso(1) }) }),
      GATE,
    )
    expect(r.status).toBe(1)
    expect(warnLines(r.stderr)).toHaveLength(1)
    expect(r.stderr).toContain('变量层当前处于断流状态')
    // FAIL 段同在 stderr ⇒ 两段共存于同一条流，顺序上 WARN 在前
    expect(r.stderr).toContain('✗ 变量层新鲜度闸 FAIL')
    expect(r.stderr).toContain('[V5]')
    expect(r.stderr.indexOf('断流状态')).toBeLessThan(r.stderr.indexOf('✗ 变量层新鲜度闸 FAIL'))
  })
})
