// tests/audit-figma-vs-sot.test.ts
// -----------------------------------------------------------------------------
// `audit:figma-vs-sot` 的**整脚本**回归面（[[INFRA-F138]] 第 16 条兑现，2026-08-26）。
// ⛔ 闸本体一行没改。
//
// 为什么是整脚本而不是 import 判据函数：该闸零导出、七类判据全在 `main()` 调的
// `audit()` 里，退出码由 `main()` 末行合成。量具 `pnpm report:gate-regression-face`
// 此前把它记在「零判据覆盖」（`NAMED(4)` / `P2` 干净 / `P1=FAIL(cwd)` 不封路）。
//
// 🔴 **它为什么排在无挂载那批的最前面**：量具的「(无挂载)」列只扫 4 个 husky hook +
//   3 个 CI workflow + gate-chain + prepublishOnly + release —— **不含编排链**。实测这条闸
//   是 `figma-sync/sync-figma-library.mjs` 的 **Step 14 `runOrAbort`**（该文件头注释逐字
//   「─── SoT drift audit (blocking) ───」）⇒ 它 blocking 中止的是本仓**唯一**的 Figma
//   同步入口。「无挂载」在量具口径里 ≠「从不跑」。
//
// 🔴 **输入面有三层，比闸自己的头注释多一层**（头注释只提 manifest 与 SoT）：
//   1. `figma-data/normalized/components.manifest.json`
//   2. `docs/internal/affordance-categories/`（经 `loadAffordanceSot(ROOT)`，闸传的是**显式**
//      root ⇒ lib 自己那份 `import.meta.url` 算的 DEFAULT_ROOT 不生效）
//   3. **每个 `icon/` component 的 `source` 指向的 tokenized JSON** —— `loadFigmaIcons` 对
//      每一条都 `readJSON(resolve(ROOT, component.source))`。少造这一层直接 ENOENT。
//   ⇒ 同族纪律：判断一条闸的输入面别只读它的头注释 / mode 分支。
//
// ⚠️ `copyFiles` 拷 `figma-sync/lib/affordance-sot.mjs`、`linkDirs: []`（本闸不碰
//   `scripts/lib`，harness 的默认值必须显式关掉 —— 传什么是一条断言）。⛔ 该 lib 不能软链：
//   它自己用 `import.meta.url` 算 DEFAULT_ROOT，ESM 走 realpath 会把 root 解回真仓库。
//
// 🔴 **本闸天然含「输出变、退出码不变」那一档**（本仓 2026-08-26 第四轮首次撞到的镜像形态）：
//   `blockingDrift` 只由 A/B/C 三类合成，而 **D/F/G 三类是 warning ⇒ 命中也 exit 0**。
//   ⇒ 只断言退出码的测试对 D/F/G 整整三类**完全不敏感**。本文件因此对这三类逐条钉
//   「`status` 仍是 0」+「stdout 点名该 finding」两侧，缺一不可。
//   其中 F（`figma-null-key`）复现的是 2026-05-29 那个真实历史 bug：null key 曾被静默
//   filter 掉，INFRA-F38 才改成 warning ——「摘掉 nullKey.push」是一次退出码全程不变的注入。
//
// ⚠️ 一条**刻意**的 fail-closed 探针（别当疏漏修掉）：fixture 里两个非 icon component 的
//   `source` 指向**不存在**的文件。`loadFigmaIcons` 的 `startsWith('icon/')` filter 一旦坏掉，
//   闸会 ENOENT 崩溃 ⇒ 绿档当场红。这是 filter 接线的免费探针。
//
// ⚠️ 绿档非空过用**三个互不相关**的读数（任一单独都可能撞巧合）：
//   `figma=3`（icon 数）· `sot=3`（SoT 数）· MD 里 `(5 entries; 3 icon entries)`
//   （manifest 总条数，含非 icon ⇒ 与前两个不同源）。配反向钉：stdout 不含真仓库的
//   `figma=644` / `sot=644`。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterEach } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import {
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'

const GATE = 'figma-sync/audit-figma-vs-sot.mjs'
const SOT_LIB = 'figma-sync/lib/affordance-sot.mjs'

const MANIFEST = 'figma-data/normalized/components.manifest.json'
const SOT_DIR = 'docs/internal/affordance-categories'
const JSON_OUT = 'figma-data/normalized/figma-vs-sot-drift.audit.json'
const MD_OUT = 'docs/internal/figma-vs-sot-drift-report.md'

type Icon = { name: string; node: string; key: string | null; file: string }
type Sot = { folder: string; leaf: string; registry: string; key: string; name: string; node: string }

/** 基线 3 个 icon —— 全用 `Fx*` 假名（⛔ 别用真实闸/真实图标名，会污染量具读数）。 */
function baseIcons(): Icon[] {
  return [
    { name: 'icon/FxAlpha/One', node: '9001:1', key: 'fxkey-one', file: 'fx_alpha_one' },
    { name: 'icon/FxAlpha/Two', node: '9001:2', key: 'fxkey-two', file: 'fx_alpha_two' },
    { name: 'icon/FxBeta/Three', node: '9002:3', key: 'fxkey-three', file: 'fx_beta_three' },
  ]
}

/** 基线 SoT —— 与 baseIcons 逐条对齐 ⇒ 零 drift。 */
function baseSot(): Sot[] {
  return [
    { folder: 'FxAlpha', leaf: 'One.svg', registry: 'fx/alpha-one', key: 'fxkey-one', name: 'icon/FxAlpha/One', node: '9001:1' },
    { folder: 'FxAlpha', leaf: 'Two.svg', registry: 'fx/alpha-two', key: 'fxkey-two', name: 'icon/FxAlpha/Two', node: '9001:2' },
    { folder: 'FxBeta', leaf: 'Three.svg', registry: 'fx/beta-three', key: 'fxkey-three', name: 'icon/FxBeta/Three', node: '9002:3' },
  ]
}

const tokenizedPath = (file: string) => `figma-data/normalized/components-tokenized/${file}.json`

function buildFiles(icons: Icon[], sot: Sot[]): Record<string, string> {
  const files: Record<string, string> = {}

  // manifest：3 个 icon + 2 个非 icon。非 icon 的 source 故意指向不存在的文件（见头注释探针）。
  const components = [
    ...icons.map((i) => ({
      figmaName: i.name,
      nodeId: i.node,
      codeName: 'IconFx',
      source: tokenizedPath(i.file),
      variantAxes: [],
    })),
    { figmaName: 'FxWidget', nodeId: '9003:4', codeName: 'FxWidget', source: tokenizedPath('fx_widget_absent'), variantAxes: [] },
    { figmaName: 'FxGadget', nodeId: '9003:5', codeName: 'FxGadget', source: tokenizedPath('fx_gadget_absent'), variantAxes: [] },
  ]
  files[MANIFEST] = JSON.stringify({ version: 'fx', strategy: 'fx', components }, null, 2)

  // tokenized：每个 icon 一份，只有 `key` 参与判据。
  for (const i of icons) {
    files[tokenizedPath(i.file)] = JSON.stringify({ figmaName: i.name, nodeId: i.node, key: i.key }, null, 2)
  }

  // SoT：_index.json + 按 folder 分片。
  files[`${SOT_DIR}/_index.json`] = JSON.stringify(
    { _meta: { schema_version: 'fx-0.0.1' }, affordance_categories: { directional: [] } },
    null,
    2,
  )
  const byFolder = new Map<string, unknown[]>()
  for (const s of sot) {
    if (!byFolder.has(s.folder)) byFolder.set(s.folder, [])
    byFolder.get(s.folder)!.push({
      path: `${s.folder}/${s.leaf}`,
      registry_name: s.registry,
      affordance_category: 'directional',
      synonyms_en: [],
      synonyms_zh: [],
      figma_component_key: s.key,
      figma_name: s.name,
      figma_node_id: s.node,
    })
  }
  for (const [folder, entries] of byFolder) {
    files[`${SOT_DIR}/${folder}.json`] = JSON.stringify(entries, null, 2)
  }

  return files
}

function build(
  opts: {
    icons?: Icon[]
    sot?: Sot[]
    overrides?: Record<string, string>
    drop?: string[]
  } = {},
) {
  const files = { ...buildFiles(opts.icons ?? baseIcons(), opts.sot ?? baseSot()), ...(opts.overrides ?? {}) }
  for (const k of opts.drop ?? []) delete files[k]
  return createGateFixture({
    gate: GATE,
    prefix: 'figma-vs-sot-fx',
    files,
    copyFiles: [SOT_LIB],
    linkDirs: [],
  })
}

const readJson = (root: string, rel: string) => JSON.parse(readFileSync(join(root, rel), 'utf8'))
const readText = (root: string, rel: string) => readFileSync(join(root, rel), 'utf8')

afterEach(() => cleanupGateFixtures())

describe('audit:figma-vs-sot — 绿档与非空过凭据', () => {
  it('零 drift 时 exit 0 并印三个 fixture 自己的读数', () => {
    const root = build()
    const run = runGate(root, GATE)

    expectGateGreen(run, { contains: ['figma=3', 'sot=3', '✅ no drift detected'] })
    // 第三个读数来自 MD 产物：manifest 总条数(5) 与 icon 数(3) 不同源
    expect(readText(root, MD_OUT)).toContain('(5 entries; 3 icon entries)')
  })

  it('反向钉：stdout 不含真仓库的读数（跑错树的话这里会命中）', () => {
    const run = runGate(build(), GATE)
    expect(run.stdout).not.toContain('figma=644')
    expect(run.stdout).not.toContain('sot=644')
  })

  it('两个产物都落在 fixture 内，不写回真仓库', () => {
    const root = build()
    runGate(root, GATE)
    expect(existsSync(join(root, JSON_OUT))).toBe(true)
    expect(existsSync(join(root, MD_OUT))).toBe(true)
  })

  it('JSON 产物的 summary 逐字段反映 fixture', () => {
    const root = build()
    runGate(root, GATE)
    const out = readJson(root, JSON_OUT)
    expect(out.summary).toMatchObject({
      figmaIcons: 3,
      sotIcons: 3,
      matched: 3,
      figmaOnly: 0,
      sotOrphan: 0,
      figmaNameDrift: 0,
      nodeIdDrift: 0,
      nullKey: 0,
      keyMismatch: 0,
    })
    expect(out.strict).toBe(true)
    expect(out.reportOnly).toBe(false)
  })

  it('非 icon component 不进比较（filter 接线；它们的 source 是不存在的文件）', () => {
    const root = build()
    const run = runGate(root, GATE)
    // figma=3 而 manifest 有 5 条 ⇒ filter 生效；filter 若坏掉会 ENOENT 崩溃
    expectGateGreen(run, { contains: ['figma=3'] })
    expect(readJson(root, JSON_OUT).summary.figmaIcons).toBe(3)
  })
})

describe('audit:figma-vs-sot — Type A/B/C 是 blocking（exit 1）', () => {
  it('A figma-only：figma 多一个 icon，SoT 无该 key 也无该 name', () => {
    const icons = [...baseIcons(), { name: 'icon/FxGamma/Four', node: '9004:4', key: 'fxkey-four', file: 'fx_gamma_four' }]
    const root = build({ icons })
    const run = runGate(root, GATE)

    expectGateRed(run, { checks: ['A=1', 'figma-only', 'icon/FxGamma/Four'] })
    expect(readJson(root, JSON_OUT).findings.figmaOnly).toHaveLength(1)
  })

  it('B sot-orphan：SoT 多一条，figma 无该 key 也无该 name', () => {
    const sot = [
      ...baseSot(),
      { folder: 'FxGamma', leaf: 'Ghost.svg', registry: 'fx/gamma-ghost', key: 'fxkey-ghost', name: 'icon/FxGamma/Ghost', node: '9004:9' },
    ]
    const root = build({ sot })
    const run = runGate(root, GATE)

    // ⚠️ stdout 的 label 走 `figma_name || last_figma_name || …` ⇒ sot-orphan 印的是
    //    `last_figma_name`，不是 registry_name（registry 在产物侧钉）。
    expectGateRed(run, { checks: ['B=1', 'sot-orphan', 'icon/FxGamma/Ghost'] })
    const orphans = readJson(root, JSON_OUT).findings.sotOrphan
    expect(orphans).toHaveLength(1)
    expect(orphans[0].registry_name).toBe('fx/gamma-ghost')
  })

  it('C figma-name-drift：key 相同但 figma_name 不同', () => {
    const sot = baseSot()
    sot[0] = { ...sot[0], name: 'icon/FxAlpha/OneRenamed' }
    const root = build({ sot })
    const run = runGate(root, GATE)

    expectGateRed(run, { checks: ['C=1', 'figma-name-drift'] })
    const finding = readJson(root, JSON_OUT).findings.figmaNameDrift[0]
    expect(finding.sot_figma_name).toBe('icon/FxAlpha/OneRenamed')
    expect(finding.figma_library_figma_name).toBe('icon/FxAlpha/One')
  })

  it('A+B+C 同时命中时三类都点名，blockingDrift 累加仍只 exit 1', () => {
    const icons = [...baseIcons(), { name: 'icon/FxGamma/Four', node: '9004:4', key: 'fxkey-four', file: 'fx_gamma_four' }]
    const sot = baseSot()
    sot[0] = { ...sot[0], name: 'icon/FxAlpha/OneRenamed' }
    sot.push({ folder: 'FxGamma', leaf: 'Ghost.svg', registry: 'fx/gamma-ghost', key: 'fxkey-ghost', name: 'icon/FxGamma/Ghost', node: '9004:9' })

    const run = runGate(build({ icons, sot }), GATE)
    expectGateRed(run, { checks: ['A=1', 'B=1', 'C=1', 'figma-only', 'sot-orphan', 'figma-name-drift'] })
  })
})

// 🔴 这一组是「输出变、退出码不变」的镜像档 —— 只钉退出码的测试对整组零敏感。
describe('audit:figma-vs-sot — Type D/F/G 是 warning（命中也 exit 0）', () => {
  it('D node-id-drift：key+name 相同、nodeId 不同 ⇒ 仍 exit 0 但点名', () => {
    const sot = baseSot()
    sot[1] = { ...sot[1], node: '9001:999' }
    const run = runGate(build({ sot }), GATE)

    expect(run.status).toBe(0) // ← 镜像档：退出码不动
    expect(run.stdout).toContain('D=1')
    expect(run.stdout).toContain('node-id-drift')
  })

  it('F figma-null-key：tokenized.key 为 null ⇒ 仍 exit 0 但点名（复现 2026-05-29 静默丢弃）', () => {
    const icons = baseIcons()
    icons[2] = { ...icons[2], key: null }
    const root = build({ icons })
    const run = runGate(root, GATE)

    expect(run.status).toBe(0)
    expect(run.stdout).toContain('F=1')
    expect(run.stdout).toContain('figma-null-key')
    expect(run.stdout).toContain('null-key')
  })

  it('F 的 name-join：null-key 但 figma_name 命中 SoT ⇒ proposed_action 走「已匹配」分支', () => {
    const icons = baseIcons()
    icons[2] = { ...icons[2], key: null } // name 仍是 icon/FxBeta/Three，SoT 有同名条目
    const root = build({ icons })
    runGate(root, GATE)

    const finding = readJson(root, JSON_OUT).findings.nullKey[0]
    expect(finding.name_matched_sot_registry).toBe('fx/beta-three')
    expect(finding.proposed_action).toContain('name matches SoT entry')
  })

  it('F 的 name-join：null-key 且 name 也匹配不上 ⇒ 走另一条 proposed_action', () => {
    const icons = [...baseIcons(), { name: 'icon/FxGamma/Nameless', node: '9004:7', key: null, file: 'fx_gamma_nameless' }]
    const root = build({ icons })
    runGate(root, GATE)

    const finding = readJson(root, JSON_OUT).findings.nullKey[0]
    expect(finding.name_matched_sot_registry).toBeNull()
    expect(finding.proposed_action).toContain('no SoT name match')
  })

  it('G key-mismatch：figma_name 相同但 component_key 不同 ⇒ 仍 exit 0 但点名', () => {
    const sot = baseSot()
    sot[0] = { ...sot[0], key: 'fxkey-one-drifted' }
    const root = build({ sot })
    const run = runGate(root, GATE)

    expect(run.status).toBe(0)
    expect(run.stdout).toContain('G=1')
    expect(run.stdout).toContain('key-mismatch')
  })

  it('G 抑制 A：name-join fallback 命中就不该再报 figma-only', () => {
    const sot = baseSot()
    sot[0] = { ...sot[0], key: 'fxkey-one-drifted' }
    const root = build({ sot })
    const run = runGate(root, GATE)

    expect(run.stdout).toContain('A=0')
    expect(readJson(root, JSON_OUT).findings.figmaOnly).toHaveLength(0)
  })

  it('G 抑制 B：同一次 key 漂移不该同时被记成 sot-orphan（双重计数钉）', () => {
    const sot = baseSot()
    sot[0] = { ...sot[0], key: 'fxkey-one-drifted' }
    const root = build({ sot })
    const run = runGate(root, GATE)

    expect(run.stdout).toContain('B=0')
    expect(readJson(root, JSON_OUT).findings.sotOrphan).toHaveLength(0)
  })

  it('D/F/G 同时命中仍 exit 0 —— 三类合起来也不进 blockingDrift', () => {
    const icons = baseIcons()
    icons[2] = { ...icons[2], key: null } // F
    const sot = baseSot()
    sot[0] = { ...sot[0], key: 'fxkey-one-drifted' } // G
    sot[1] = { ...sot[1], node: '9001:999' } // D

    const run = runGate(build({ icons, sot }), GATE)
    expect(run.status).toBe(0)
    expect(run.stdout).toContain('D=1')
    expect(run.stdout).toContain('F=1')
    expect(run.stdout).toContain('G=1')
    expect(run.stdout).toContain('A=0')
    expect(run.stdout).toContain('B=0')
    expect(run.stdout).toContain('C=0')
  })
})

describe('audit:figma-vs-sot — 三个 mode 的退出码接线', () => {
  const withBlockingDrift = () => {
    const icons = [...baseIcons(), { name: 'icon/FxGamma/Four', node: '9004:4', key: 'fxkey-four', file: 'fx_gamma_four' }]
    return build({ icons })
  }

  it('默认（无参）= STRICT ⇒ blocking drift 让它红', () => {
    expectGateRed(runGate(withBlockingDrift(), GATE), { checks: ['A=1'] })
  })

  it('--report-only ⇒ 同样的 drift 只报不拦（exit 0）', () => {
    const root = withBlockingDrift()
    const run = runGate(root, GATE, ['--report-only'])
    expect(run.status).toBe(0)
    expect(run.stdout).toContain('A=1') // 判据照跑，只是不阻断
    expect(readJson(root, JSON_OUT).reportOnly).toBe(true)
    expect(readJson(root, JSON_OUT).strict).toBe(false)
  })

  it('--no-strict ⇒ 同样不拦，但 reportOnly 仍是 false（两个 flag 语义不同）', () => {
    const root = withBlockingDrift()
    const run = runGate(root, GATE, ['--no-strict'])
    expect(run.status).toBe(0)
    expect(readJson(root, JSON_OUT).strict).toBe(false)
    expect(readJson(root, JSON_OUT).reportOnly).toBe(false)
  })
})

describe('audit:figma-vs-sot — 输入面 fail-closed（三层各一）', () => {
  it('manifest 缺失 ⇒ 崩溃且非零，路径指向 fixture（无 fallback 回真仓库）', () => {
    const root = build({ drop: [MANIFEST] })
    const run = runGate(root, GATE)
    expect(run.status).not.toBe(0)
    expect(`${run.stderr}${run.stdout}`).toContain('ENOENT')
    expect(`${run.stderr}${run.stdout}`).toContain(root)
  })

  it('SoT 目录缺失 ⇒ 抛 affordance-sot 的具名错误', () => {
    const root = build({ drop: [`${SOT_DIR}/_index.json`, `${SOT_DIR}/FxAlpha.json`, `${SOT_DIR}/FxBeta.json`] })
    const run = runGate(root, GATE)
    expect(run.status).not.toBe(0)
    expect(`${run.stderr}${run.stdout}`).toContain('SoT directory not found')
  })

  it('tokenized JSON 缺失 ⇒ ENOENT（证明第三层输入真的被读）', () => {
    const root = build({ drop: [tokenizedPath('fx_alpha_two')] })
    const run = runGate(root, GATE)
    expect(run.status).not.toBe(0)
    expect(`${run.stderr}${run.stdout}`).toContain('fx_alpha_two')
  })

  it('SoT 分片不是数组 ⇒ 抛具名错误，不静默当空', () => {
    const root = build({ overrides: { [`${SOT_DIR}/FxBeta.json`]: JSON.stringify({ notAnArray: true }) } })
    const run = runGate(root, GATE)
    expect(run.status).not.toBe(0)
    expect(`${run.stderr}${run.stdout}`).toContain('is not a JSON array')
  })
})

describe('audit:figma-vs-sot — 产物幂等接线', () => {
  it('连跑两次：第二次两个产物都印 skipped（generatedAt 被剥掉后内容相同）', () => {
    const root = build()
    const first = runGate(root, GATE)
    expect(first.stdout).not.toContain('(skipped — no content change)')

    const second = runGate(root, GATE)
    expect(second.stdout.match(/\(skipped — no content change\)/g)).toHaveLength(2)
  })

  it('输入变了就重写，不再 skipped', () => {
    const root = build()
    runGate(root, GATE)

    const sot = baseSot()
    sot[0] = { ...sot[0], name: 'icon/FxAlpha/OneRenamed' }
    const root2 = build({ sot })
    const run = runGate(root2, GATE)
    expect(run.stdout).not.toContain('(skipped — no content change)')
  })
})

describe('audit:figma-vs-sot — MD 报告结构', () => {
  it('七个 Type 段标题齐全（A–G）', () => {
    const root = build()
    runGate(root, GATE)
    const md = readText(root, MD_OUT)
    for (const heading of [
      '## Type A — figma-only',
      '## Type B — sot-orphan',
      '## Type C — figma-name-drift',
      '## Type D — node-id-drift',
      '## Type E — svg-content-drift',
      '## Type F — figma-null-key',
      '## Type G — key-mismatch',
    ]) {
      expect(md).toContain(heading)
    }
  })

  it('drift summary 行的数值与 stdout 一致', () => {
    const icons = [...baseIcons(), { name: 'icon/FxGamma/Four', node: '9004:4', key: 'fxkey-four', file: 'fx_gamma_four' }]
    const root = build({ icons })
    runGate(root, GATE)
    expect(readText(root, MD_OUT)).toContain('> Drift summary: A=1 B=0 C=0 D=0 F=0 G=0')
  })

  it('findings 带 evidenceLevel / evidenceSource 溯源字段（数据产出契约）', () => {
    const icons = [...baseIcons(), { name: 'icon/FxGamma/Four', node: '9004:4', key: 'fxkey-four', file: 'fx_gamma_four' }]
    const root = build({ icons })
    runGate(root, GATE)
    const finding = readJson(root, JSON_OUT).findings.figmaOnly[0]
    expect(finding.evidenceLevel).toBe('direct')
    expect(finding.evidenceSource).toContain('docs/internal/affordance-categories/')
  })
})
