// tests/audit-figma-library-vs-canonical.test.ts
// -----------------------------------------------------------------------------
// `audit:figma-library-vs-canonical`（L4 pre-commit + L5 gate-chain）的**整脚本**回归面。
//
// 为什么是整脚本而不是 import 判据函数：该闸 136 行**零导出**、判据写在顶层，
// 结构上没有可 import 的符号。量具 `pnpm report:gate-regression-face` 把它记为
// 「零判据覆盖 · P1/P2 双 PASS」。本文件是那条判定的兑现（[[INFRA-F138]]）。
// ⛔ 闸本体一行没改。
//
// 这条闸做的是**双向** diff，两侧的判据与豁免通路完全不同：
//   A 侧：Figma 发布了但没有 canonical / mapping  → 豁免走脚本内的 `KNOWN_FIGMA_NO_CANONICAL`
//   B 侧：canonical 存在但没有 mapping 条目        → 豁免走共享 allowlist 的 `mappingReason` 字段
// ⇒ 用例按两侧分组，且各配一条 must-not-hit —— 一侧的豁免不许放行另一侧。
//
// ⚠️ 顺带如实登记（**不在本轮范围内、⛔ 未修**）：`orphanVariantStrings` 收集了含 `=`
// 的 variant 片段却从不输出，是一段死变量。它没有行为影响（过滤本身是生效的，见
// 「must-not-hit：variant string」用例），所以本文件只钉过滤行为，不碰那段代码。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterEach } from 'vitest'
import {
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'

const GATE = 'scripts/audit-figma-library-vs-canonical.mjs'
const COMPONENTS = 'figma-data/normalized/components.manifest.json'
const MAPPING = 'figma-data/figma-to-code-mapping.json'
const EXEMPT = 'figma-data/audit-allowlist/canonical-exempt.json'

afterEach(cleanupGateFixtures)

type Mapping = { figmaName: string; codeNames: string[]; status?: string }

type Overrides = {
  /** Figma 已发布组件名 */
  published?: string[]
  /** `src/canonical/` 下的组件名（不含 .vue） */
  canonicals?: string[]
  mappings?: Mapping[]
  exempt?: Record<string, { mappingReason: string }>
  files?: Record<string, string>
}

function build(o: Overrides = {}) {
  const published = o.published ?? ['Fx/Probe']
  const canonicals = o.canonicals ?? ['FxProbe']
  const mappings = o.mappings ?? [{ figmaName: 'Fx/Probe', codeNames: ['FxProbe'], status: 'approved' }]

  const vueFiles: Record<string, string> = {}
  for (const c of canonicals) vueFiles[`src/canonical/${c}.vue`] = `<template><div /></template>\n`

  return createGateFixture({
    gate: GATE,
    prefix: 'fig-vs-canon-fx',
    dirs: ['src/canonical'],
    files: {
      ...vueFiles,
      [COMPONENTS]: JSON.stringify({ components: published.map((figmaName) => ({ figmaName })) }),
      [MAPPING]: JSON.stringify({ mappings }),
      [EXEMPT]: JSON.stringify({ exempt: o.exempt ?? {} }),
      ...(o.files ?? {}),
    },
  })
}

describe('audit:figma-library-vs-canonical — 绿档 + 自印非空过', () => {
  it('双向对齐 → exit 0，且自印的三个数全是 fixture 自己的（≠ 真仓库 ⇒ 非空过）', () => {
    const run = runGate(build(), GATE)
    expectGateGreen(run, {
      contains: [
        'audit-figma-library-vs-canonical: 1 figma published, 1 canonicals, 1 mapped',
        'Findings: 0 figma-without-canonical, 0 canonical-without-mapping',
        'OK — bidirectional parity.',
      ],
    })
  })
})

describe('audit:figma-library-vs-canonical — A 侧：Figma 有、code 没有', () => {
  it('published 里多一个没 mapping 的名字 → 红并在 A 段点名它', () => {
    const run = runGate(build({ published: ['Fx/Probe', 'Fx/Orphan'] }), GATE)
    expectGateRed(run, {
      marker: 'A. Figma published but no canonical wrapper / mapping',
      checks: ['Fx/Orphan', 'Findings: 1 figma-without-canonical, 0 canonical-without-mapping'],
    })
  })

  it('KNOWN_FIGMA_NO_CANONICAL 三个成员逐个放行（foundation 预览帧不是组件）', () => {
    const run = runGate(build({ published: ['Fx/Probe', 'Brand color', 'Neutral Color', 'Chart'] }), GATE)
    expectGateGreen(run, {
      contains: ['4 figma published, 1 canonicals, 1 mapped', 'Findings: 0 figma-without-canonical'],
    })
  })

  it('⛔ must-not-hit：含 `=` 的 variant 片段不算已发布组件（extract 漏出来的轴串）', () => {
    const run = runGate(build({ published: ['Fx/Probe', 'size=L', 'Button/dark=on'] }), GATE)
    // 分母里只剩 1 —— 证明过滤发生在计入 figmaPublished **之前**
    expectGateGreen(run, { contains: ['1 figma published,', 'OK — bidirectional parity.'] })
  })

  it('⛔ must-not-hit：`icon/*` 不进这条闸（图标走 Icon registry，另有 audit 路径）', () => {
    const run = runGate(build({ published: ['Fx/Probe', 'icon/Arrow/Down', 'Icon/Close'] }), GATE)
    // 大小写不敏感（正则带 /i）⇒ `Icon/Close` 也被排除
    expectGateGreen(run, { contains: ['1 figma published,', 'OK — bidirectional parity.'] })
  })

  it('⛔ must-not-hit：A 侧的豁免**不**放行 B 侧（两侧豁免表互不通用）', () => {
    // 'Chart' 在 KNOWN_FIGMA_NO_CANONICAL 里，但 canonical/Chart.vue 无 mapping 仍要报 B
    const run = runGate(build({ published: ['Fx/Probe', 'Chart'], canonicals: ['FxProbe', 'Chart'] }), GATE)
    expectGateRed(run, {
      marker: 'B. canonical/*.vue without figma-to-code-mapping.json entry',
      checks: ['Findings: 0 figma-without-canonical, 1 canonical-without-mapping'],
    })
  })
})

describe('audit:figma-library-vs-canonical — B 侧：code 有、mapping 没有', () => {
  it('canonical 多一个没 mapping 的 → 红并在 B 段点名它', () => {
    const run = runGate(build({ canonicals: ['FxProbe', 'FxLonely'] }), GATE)
    expectGateRed(run, {
      marker: 'B. canonical/*.vue without figma-to-code-mapping.json entry',
      checks: ['FxLonely', 'Findings: 0 figma-without-canonical, 1 canonical-without-mapping'],
    })
  })

  it('共享 allowlist 的 `mappingReason` 字段放行 B 侧', () => {
    const run = runGate(
      build({ canonicals: ['FxProbe', 'FxLonely'], exempt: { FxLonely: { mappingReason: 'fixture: 有意 Figma-divorced' } } }),
      GATE,
    )
    expectGateGreen(run, { contains: ['Findings: 0 figma-without-canonical, 0 canonical-without-mapping'] })
  })

  it('CANONICAL_ALIAS：ButtonBridge 用 Button 的 mapping 满足 B 侧', () => {
    const run = runGate(
      build({
        published: ['Fx/Button'],
        canonicals: ['ButtonBridge'],
        mappings: [{ figmaName: 'Fx/Button', codeNames: ['Button'], status: 'approved' }],
      }),
      GATE,
    )
    expectGateGreen(run, { contains: ['OK — bidirectional parity.'] })
  })

  it('mapping status ≠ approved → 两侧都不算数（A 报 orphan、B 报 no-mapping）', () => {
    const run = runGate(
      build({ mappings: [{ figmaName: 'Fx/Probe', codeNames: ['FxProbe'], status: 'draft' }] }),
      GATE,
    )
    expectGateRed(run, {
      checks: ['Findings: 1 figma-without-canonical, 1 canonical-without-mapping', 'Fx/Probe', 'FxProbe'],
    })
    expect(run.stdout).toContain('1 figma published, 1 canonicals, 0 mapped')
  })

  it('⛔ must-not-hit：canonical 目录下的非 .vue 文件不算 canonical', () => {
    const run = runGate(build({ files: { 'src/canonical/notes.md': '# x\n' } }), GATE)
    expectGateGreen(run, { contains: ['1 canonicals,'] })
  })
})

describe('audit:figma-library-vs-canonical — 接线钉（判据 → 退出码 → 点名输出）', () => {
  it('A + B 同时违例 → 两段都印、计数分别正确、总退出码 1（接线不吞任一侧）', () => {
    const run = runGate(
      build({ published: ['Fx/Probe', 'Fx/OrphanA'], canonicals: ['FxProbe', 'FxLonelyB'] }),
      GATE,
    )
    expect(run.status).toBe(1)
    expect(run.stdout).toContain('Findings: 1 figma-without-canonical, 1 canonical-without-mapping')
    expect(run.stdout).toContain('A. Figma published but no canonical wrapper / mapping')
    expect(run.stdout).toContain('Fx/OrphanA')
    expect(run.stdout).toContain('B. canonical/*.vue without figma-to-code-mapping.json entry')
    expect(run.stdout).toContain('FxLonelyB')
    expect(run.stdout).toContain('Next action:')
  })

  it('多条 A 侧违例按名排序全部印出（不止报第一条）', () => {
    const run = runGate(build({ published: ['Fx/Probe', 'Zeta/X', 'Alpha/X'] }), GATE)
    expect(run.status).toBe(1)
    expect(run.stdout).toContain('Alpha/X')
    expect(run.stdout).toContain('Zeta/X')
    expect(run.stdout.indexOf('Alpha/X')).toBeLessThan(run.stdout.indexOf('Zeta/X'))
  })

  it('豁免表 JSON 缺失 → 非零崩溃（fail-closed），⛔ 不是当成空豁免表继续', () => {
    // ⚠️ 如实登记：readFileSync 抛 ENOENT 的**崩溃式** fail-closed，不是判据红。
    const root = createGateFixture({
      gate: GATE,
      prefix: 'fig-vs-canon-fx-noexempt',
      dirs: ['src/canonical'],
      files: {
        'src/canonical/FxProbe.vue': '<template><div /></template>\n',
        [COMPONENTS]: '{"components":[]}',
        [MAPPING]: '{"mappings":[]}',
      },
    })
    const run = runGate(root, GATE)
    expect(run.status).not.toBe(0)
    expect(`${run.stderr}${run.stdout}`).toContain('ENOENT')
  })

  it('manifest 无 components 字段 → 按空发布面继续（`?? []` 分支），B 侧判据照跑', () => {
    const run = runGate(build({ files: { [COMPONENTS]: '{}' } }), GATE)
    // A 侧分母 0、B 侧仍被 mapping 满足 ⇒ 绿；钉的是「缺字段不崩、也不假红」
    expectGateGreen(run, { contains: ['0 figma published, 1 canonicals, 1 mapped'] })
  })
})
