// tests/audit-consumer-contract-cli.test.ts
// -----------------------------------------------------------------------------
// `scripts/audit-consumer-contract.mjs` 的**整脚本回归面**（[[INFRA-F138]]，2026-08-27）。
//
// WHY 选这条（21 条「P2 干净 + P3 ∈ {NAMED,DIRLIST}」候选里的排序理由）：
//   它自己**不**随包，但它守的东西**全部随包** —— consumer `npm install` 之后拿到什么：
//   ① 文档示例里的 import 名 / 子路径是不是真存在（S1）② `package.json` 声明的每个
//   entry point 是不是真在 tarball 里（S2，`INFRA-F69③` 的形态：本地 resolve 得到、
//   到 consumer 机器上 `ERR_MODULE_NOT_FOUND`）。⇒ 候选集里假绿的爆炸半径最宽的一条。
//   挂载也最深：L4 pre-commit + L5 gitea-pr-checks + L5 gate-chain 三层。
//
// 🔴 **补它之前先修了 harness 的一个平台依赖，那件事本身比本文件更值钱**：
//   本闸的入口守卫是**原始** `process.argv[1] === fileURLToPath(import.meta.url)`（:329），
//   而 harness 的 fixture root 走 `mkdtempSync(tmpdir())` —— macOS 上是 `/var/folders/…`，
//   **`/var` 自己是 symlink** ⇒ 守卫恒假 ⇒ **main() 整个不执行、exit 0、stdout 零字节**。
//   实测：不 realpath ⇒ `EXIT=0` / `STDOUT_LEN=0`；realpath 后 ⇒ 5 行自印 + `✅ PASS`。
//   ⇒ 修在 `tests/lib/gate-fixture-root.ts`（root 加 `realpathSync`），零改动被测闸。
//   ⚠️ **机制是 2026-08-26 第七/八轮就记过的，⛔ 别读成新发现** —— 变的是它当时只在
//   两份自建 fixture 的测试里各绕过一次，没进共享 harness ⇒ 走 harness 的人会再撞一次。
//   这不是本闸特有：21 条候选里 **14 条**用这三种原始守卫之一，全都卡在同一处。
//   ⛔ 别把这理解成「本 harness 现在覆盖了入口守卫的 symlink 安全性」—— 它没有，
//   那条判据的真源是 `scripts/lib/is-cli-entry.mjs` + `tests/is-cli-entry.test.ts`。
//
// ⛔ 与 `tests/audit-consumer-contract.test.ts` 的分工（**别合并**）：那份 import
//   `checkConsumerContract` / `checkPackagedTargets` 两个纯函数并**注入** ctx 与 probe，
//   覆盖判据逻辑；本份 spawn 整个脚本，覆盖只有整脚本能看见的四样 ——
//     · 接线（入口守卫 → main → 两个出口）
//     · `buildRealContext()`：从**真文件**解析导出面（`export type {}` 不进值导出面这条，
//       纯函数测试注入的是现成 Set，结构上看不见）
//     · `DOC_FILES` 两份都真的被扫（只扫 README 的退化不会被纯函数测试发现）
//     · `probeRepoPath()`：真文件系统的 present / absent / unknown 三态
// -----------------------------------------------------------------------------
import { describe, it, expect, afterAll } from 'vitest'
import { rmSync } from 'node:fs'
import { join } from 'node:path'
import {
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'

const GATE = 'scripts/audit-consumer-contract.mjs'
const PKG = '@ux-team/tvu-design-system'

afterAll(() => cleanupGateFixtures())

/** markdown 代码围栏 —— 闸只在围栏内找 import（`FENCE_RE`），围栏外的散文不算。 */
function fence(...lines: string[]): string {
  return ['```ts', ...lines, '```'].join('\n')
}

// ── fixture 素材 ────────────────────────────────────────────────────────────
// 数字全部挑成**真仓库不可能撞上**的（真仓库现取：`.: 50, ./chart: 1; 15 declared
// subpaths; 26 declared target(s); 0 non-path skipped`）——绿档钉的就是这几个数。
const CLEAN_PKG = {
  name: PKG,
  files: ['dist', 'dist-wc'],
  main: './dist/index.js',
  module: './dist/index.mjs',
  types: './dist/index.d.ts',
  exports: {
    '.': { types: './dist/index.d.ts', import: './dist/index.mjs' },
    './chart': './dist/chart.mjs',
    './style.css': './dist/style.css',
    './web-components': './dist-wc/index.js',
    // 非 './' 开头 ⇒ 闸判为「不是能解析的路径」，只进 skipped 计数，不做覆盖/存在校验。
    './self': 'tvu-fixture-bare-specifier',
  },
}

// 值导出 2 个；`export type {}` 那行**刻意**放着 —— 闸的 `EXPORT_BLOCK_RE` 逐字声明
// 不匹配它，本 fixture 让那条声明可被证伪（见下方 C3）。
const CLEAN_INDEX_TS = [
  "export { Button, Input } from './components'",
  "export type { FixtureOnlyProps } from './components'",
  '',
].join('\n')

const CLEAN_CHART_TS = ["export { Chart } from './charts/index'", ''].join('\n')

const CLEAN_README = [
  '# Fixture README',
  '',
  fence(`import TVUDesignSystem, { Button, Input } from '${PKG}'`),
  '',
].join('\n')

const CLEAN_GETTING_STARTED = [
  '# Fixture Getting Started',
  '',
  fence(`import { Chart as FixtureChart } from '${PKG}/chart'`),
  '',
  // 已声明、但不在 `NAME_CHECKED_SUBPATHS` 里的子路径 ⇒ 名字不校验。
  // `FixtureOnlyWidget` 在**任何**源里都不存在（全仓 grep 零命中）⇒ 这行同时是
  // 「别把各子路径的导出面并成一个 union」的正向锚点：一旦并集化，这行立刻红。
  fence(`import { FixtureOnlyWidget } from '${PKG}/web-components'`),
  '',
].join('\n')

type Overrides = { pkg?: unknown; files?: Record<string, string>; dirs?: string[] }

/**
 * 一份干净的 fixture 树：exit 0，且 `dist/` / `dist-wc/` **都不建** ——
 * 那正是闸文档里那条「未构建的树 ⇒ probe 返回 unknown ⇒ 永不误红」的分支。
 */
function cleanFixture(o: Overrides = {}): string {
  return createGateFixture({
    gate: GATE,
    prefix: 'consumer-contract-fx',
    // ⛔ linkDirs 用默认（软链 scripts/lib）—— 闸 import `./lib/files-coverage.mjs`，
    //    传 [] 会让它 ENOENT 崩掉，而**崩溃也是非零退出** ⇒ 红得理由不对。
    dirs: o.dirs ?? [],
    files: {
      'package.json': JSON.stringify(o.pkg ?? CLEAN_PKG, null, 2),
      'src/index.ts': CLEAN_INDEX_TS,
      'src/chart.ts': CLEAN_CHART_TS,
      'README.md': CLEAN_README,
      'docs/GETTING_STARTED.md': CLEAN_GETTING_STARTED,
      ...o.files,
    },
  })
}

describe('A 绿档（非空过凭据：钉 fixture 自己的读数，⛔ 不只看 exit 0）', () => {
  it('干净 fixture ⇒ exit 0，且自印的是 fixture 自己的导出面 / 声明面，不是真仓库的', () => {
    const root = cleanFixture()
    const r = runGate(root, GATE)
    // 与下面 expectGateGreen 的 status 断言重复，**刻意保留**：绿档被意外打红时
    // 光看「expected 1 to be 0」无从下手，把闸原始输出带进失败消息。
    expect(r.status, `闸的 stderr:\n${r.stderr}\n---stdout---\n${r.stdout}`).toBe(0)
    expectGateGreen(r, {
      contains: [
        '✓ README.md: no consumer-contract violations',
        '✓ docs/GETTING_STARTED.md: no consumer-contract violations',
        // 8 = 9 个声明目标里 8 个以 './' 开头；1 = 那个裸 specifier
        '✓ package.json: all 8 declared entry-point target(s) are covered by "files"',
        'exports by subpath — .: 2, ./chart: 1',
        '5 declared subpaths',
        '8 declared target(s) checked against "files", 1 non-path skipped',
      ],
    })
    // ⛔ 反向：真仓库的读数绝不该出现在 fixture 的输出里
    expect(r.stdout).not.toContain('.: 50')
    expect(r.stdout).not.toContain('15 declared subpaths')
  })
})

describe('B 接线（这一层只 import 判据函数结构上看不见）', () => {
  it('🔴 CLI 入口真的调用了 main —— 守卫恒假时会是「exit 0 且零输出」', () => {
    // 这条钉的是「程序自己最后那句话是输出的最后一行」，⛔ 不是退出码：
    // 本闸的入口守卫是原始 argv[1] 比较，root 不 realpath 时它恒假 ⇒ 闸整个不执行、
    // exit 0、stdout 双空 —— 与本仓 2026-08-26 在 pnpm consumer 上量到的形态同型。
    const root = cleanFixture()
    const r = runGate(root, GATE)
    expect(r.status).toBe(0)
    expect(r.stdout.trim()).not.toBe('')
    const lines = r.stdout.trim().split('\n')
    expect(lines[lines.length - 1]).toContain('✅ PASS — 2 doc(s) scanned')
  })

  it('阻断出口真的接上了：有 violation 时 exit 1，且明细 + 修法都印在 stderr', () => {
    const root = cleanFixture({
      files: { 'README.md': ['# R', '', fence(`import { Nope } from '${PKG}'`), ''].join('\n') },
    })
    const r = runGate(root, GATE)
    // 摘掉 `process.exit(1)` 那一支 ⇒ 明细照印而 status 变 0，本条抓的就是那个差
    expect(r.status).toBe(1)
    expect(r.stderr).toContain('❌ README.md: 1 violation(s)')
    expect(r.stderr).toContain('[audit:consumer-contract] FAIL — 1 total violation(s)')
    expect(r.stderr).toContain('Fix (S1)')
    expect(r.stderr).toContain('Fix (S2)')
  })

  it('DOC_FILES 两份都真的被扫 —— 故障只造在 GETTING_STARTED 里也必须被点名', () => {
    // 退化成「只扫 README」时本条红，而 A 的绿档与上一条都照样绿 ⇒ 只有它抓得到。
    const root = cleanFixture({
      files: {
        'docs/GETTING_STARTED.md': [
          '# G',
          '',
          fence(`import { Button } from '${PKG}/no-such-subpath'`),
          '',
        ].join('\n'),
      },
    })
    expectGateRed(runGate(root, GATE), {
      checks: [
        '❌ docs/GETTING_STARTED.md: 1 violation(s)',
        "undeclared subpath './no-such-subpath'",
        'not in package.json "exports"',
      ],
    })
    // 正向锚点：同一个故障不在 README 里 ⇒ README 那行仍是 ✓（否则本条会因「两份都红」空过）
    const r = runGate(root, GATE)
    expect(r.stdout).toContain('✓ README.md: no consumer-contract violations')
  })
})

describe('C main() 的真实输入构建（buildRealContext / probeRepoPath）', () => {
  it('C1 导出面真的从 fixture 的 src/index.ts 解析 —— 拿掉 Input ⇒ README 那行红', () => {
    const root = cleanFixture({
      files: { 'src/index.ts': "export { Button } from './components'\n" },
    })
    expectGateRed(runGate(root, GATE), {
      checks: [
        "'Input' is not exported",
        '(checked against real src/index.ts exports)',
      ],
    })
  })

  it('C2 每个子路径查自己的源，⛔ 不并成 union（Chart 从根导入必须红）', () => {
    // INFRA-F61 那次 Chart 搬出根 barrel 造成的回归类。
    const root = cleanFixture({
      files: { 'README.md': ['# R', '', fence(`import { Chart } from '${PKG}'`), ''].join('\n') },
    })
    expectGateRed(runGate(root, GATE), {
      checks: ["'Chart' is not exported", 'checked against real src/index.ts exports'],
    })
    // 正向锚点在 A 的绿档里：同一个 Chart 从 './chart' 导入不红。
  })

  it('C3 `export type {}` 不进值导出面（闸源码逐字声明 Deliberately does NOT match）', () => {
    // ⚠️ 如实登记的**边界**，⛔ 不是缺陷：文档若写 `import type { X }` 而 X 只在
    //    `export type {}` 里，闸会报红。真 README / GETTING_STARTED 现零处这么写
    //    （实测 grep 无命中）⇒ 今天不产生假阳。改动 parseExportNames 时本条会红。
    const root = cleanFixture({
      files: {
        'README.md': [
          '# R',
          '',
          fence(`import type { FixtureOnlyProps } from '${PKG}'`),
          '',
        ].join('\n'),
      },
    })
    expectGateRed(runGate(root, GATE), {
      checks: ["'FixtureOnlyProps' is not exported"],
    })
  })

  it('C4 probe=absent：dist/ 在、声明的文件不在 ⇒ 红并点名「指向了不存在的东西」', () => {
    const root = cleanFixture({
      dirs: ['dist'],
      // 只放 index.js / index.mjs / chart.mjs / style.css，独缺 index.d.ts
      files: {
        'dist/index.js': '',
        'dist/index.mjs': '',
        'dist/chart.mjs': '',
        'dist/style.css': '',
      },
    })
    expectGateRed(runGate(root, GATE), {
      checks: [
        "types -> './dist/index.d.ts' is covered by \"files\" but does not exist on disk",
        'the declaration points at nothing',
      ],
    })
  })

  it('C5 probe=unknown 正向锚点：同一份声明在未构建的树上不红', () => {
    // 缺了这条，C4 会因为「反正总是红」而空过 —— 分不出「真抓到 absent」和
    // 「闸对任何缺文件都红（含合法的未构建树）」。
    const root = cleanFixture() // 不建 dist/ 也不建 dist-wc/
    const r = runGate(root, GATE)
    expect(r.status).toBe(0)
    expect(`${r.stderr}\n${r.stdout}`).not.toContain('does not exist on disk')
  })

  it('C6 files[] 未覆盖 ⇒ 红并点名 tarball（INFRA-F69③ 的形态）', () => {
    const root = cleanFixture({
      pkg: { ...CLEAN_PKG, files: ['dist'] }, // 抽掉 dist-wc
    })
    expectGateRed(runGate(root, GATE), {
      checks: [
        'exports["./web-components"] -> \'./dist-wc/index.js\' is not covered by package.json "files"',
        'would be MISSING from the published tarball',
      ],
    })
  })
})

describe('D 两类判据的计数真的合并进同一个出口', () => {
  it('S1 + S2 同时开火 ⇒ FAIL 行的 total 是两者之和，不是任一方', () => {
    // 只累加其中一类（或先 return 掉一类）时，total 会是 1 而不是 2。
    const root = cleanFixture({
      pkg: { ...CLEAN_PKG, files: ['dist'] }, // S2：dist-wc 未覆盖 → 1 条
      files: {
        'README.md': ['# R', '', fence(`import { Nope } from '${PKG}'`), ''].join('\n'), // S1 → 1 条
      },
    })
    const r = runGate(root, GATE)
    expect(r.status).toBe(1)
    expect(r.stderr).toContain('❌ README.md: 1 violation(s)')
    expect(r.stderr).toContain('❌ package.json: 1 unpublished-target violation(s)')
    expect(r.stderr).toContain('[audit:consumer-contract] FAIL — 2 total violation(s)')
  })
})

describe('E fixture 有效性自证（⛔ 防「fixture 建错了但测试照样绿」）', () => {
  it('闸读的是 fixture 的 package.json：删掉 ./chart 声明 ⇒ 那行 import 变 undeclared', () => {
    // 真仓库的 package.json **有** './chart'（实测 15 个 subpath 里含它）⇒ 闸若读的是
    // 真仓库那份，这条永远绿。它红，才证明 exports 面来自 fixture。
    const pkg = { ...CLEAN_PKG, exports: { ...CLEAN_PKG.exports } } as {
      exports: Record<string, unknown>
    }
    delete pkg.exports['./chart']
    expectGateRed(runGate(cleanFixture({ pkg }), GATE), {
      checks: ["undeclared subpath './chart'"],
    })
  })

  it('缺输入不回落真仓库：删掉 src/chart.ts ⇒ ENOENT，且路径指向 fixture', () => {
    // 这条不是判据（闸没有针对它的显式前置），是**产物/读数不回落**的证据：
    // 若 REPO_ROOT 算成了真仓库，真仓库有 src/chart.ts ⇒ 闸会照常跑绿。
    const root = cleanFixture()
    rmSync(join(root, 'src/chart.ts'))
    const r = runGate(root, GATE)
    expect(r.status).not.toBe(0)
    const out = `${r.stderr}\n${r.stdout}`
    expect(out).toContain('ENOENT')
    expect(out).toContain(join(root, 'src/chart.ts'))
  })
})
