// tests/audit-upstream-gate-cli.test.ts
// -----------------------------------------------------------------------------
// `scripts/validate-upstream-gate.mjs`（npm key `audit:upstream-gate`）的 **整脚本**回归面。
//
// 🔴 **为什么是这一条**（第四十七轮第 2 条，按量具现取挑）：它在 `package.json` 的 `files[]`
// 里（具名，不是 glob 顺带）⇒ **随 npm 包发到每个 consumer**，且 `templates/upstream-gate-workflow.yml`
// 逐字教 consumer 跑 `node node_modules/@ux-team/tvu-design-system/scripts/validate-upstream-gate.mjs
// --repo-root=.`。它是 blocking，挂 **L5** `.gitea/workflows/pr-checks.yml`。
//
// 此前它的回归面是 `I` 类 —— `tests/upstream-gate.test.ts` import 了它导出的纯函数（含它自己
// 那个恰好也叫 `runGate` 的导出，**与本文件用的 harness `runGate` 同名不同物**）。那份测试
// 结构上碰不到：argv 解析（`--artifact=` / `--repo-root=` / `--no-network`）、`walkArtifacts`
// 与 `walkMd` 的递归、`loadSchema()` 读同目录 schema 文件、跨模块 import
// `collectHeadingSlugs`（来自 audit-stale-anchors）在真实文件树上的解析、以及**四个进程出口**
// （no-op 0 / 全过 0 / 有 FAIL 1 / 解析失败 1）。
//
// ⛔ **闸本体一行没改。**
//
// 🔴 **顺带现取到两件本仓实况，如实登记（⛔ 不是本文件要修的东西）**：
//   `find` 全仓（2026-08-27）**零个 `upstream-gate.*.md`、零个 `product-context.md`**
//   ⇒ `pnpm audit:upstream-gate` 在 DS 仓自己身上**只走 no-op PASS 那条出口**，判据从未开火。
//   这与设计一致（闸头注释逐字写 **chokepoint-bound**：artifact 不存在 → no-op PASS，
//   目标落点是消费仓的 PR），**但「本仓 CI 里它恒 no-op」此前没有任何东西记录**。
//   ⇒ 下方第一条用例就把那条出口钉住；⛔ 别把它读成「闸没用」，也别读成「闸在守本仓」。
//
// 🔴 **反向钉的形态因此和上一条闸不同**：真仓库两类文件都为零 ⇒ 「跑错了树」的表现**不是**
//   印出真文件名，而是印出 no-op 那句话。所以绿档的反向钉 = **必须出现 fixture 自己的
//   artifact 名，且必须不出现 no-op 那句**。
//
// ⚠️ **fixture 里 `scripts/lib` 是软链回真仓库的**（harness 默认，闸链上两个文件都 import
//   `./lib/is-cli-entry`）。`walkArtifacts` / `walkMd` 只跳 `node_modules` / `.git` / `dist`
//   ⇒ 它们会**跟着软链走进真仓库的 `scripts/lib/`**。已实测那个目录里两类目标文件都为零
//   （只有 .mjs），所以不泄漏；⛔ 但真往 `scripts/lib/` 放一个 `product-context.md` 会让
//   本文件的 SKIP 档假绿 —— 那时候该修的是这行注释指的假设，不是把断言放宽。
//
// ⚠️ **零网络是刻意设计的**：`main()` 调 `runGate(a, { repoRoot, network })` **不传 `fetchImpl`**
//   ⇒ CLI 路径永远用 `globalThis.fetch`。所以本文件的 competitive 用例全部走**不发请求就能判**
//   的两条：占位域名（`PLACEHOLDER_HOSTS`）与 URL 格式非法；绿档用 `competitive: []`（循环体
//   一次不进）。⛔ 别在本文件里放会真发请求的 URL —— 那会让测试结果取决于网络。
//   `--no-network` 那条用例是**闸自己的文档化开关**（`docs/_archive/_prompts/upstream-gate-codex.prompt.md`
//   逐字教这么跑），不是测试专用后门。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterEach } from 'vitest'
import {
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'

const GATE = 'scripts/validate-upstream-gate.mjs'
/** artifact 放在子目录里，顺带走一遍 `walkArtifacts` 的递归。 */
const DIR = 'docs/fx-upstream'
const ARTIFACT = `${DIR}/upstream-gate.fx-probe.md`
/** no-op 出口那句话 —— 多处用作反向钉。 */
const NOOP_LINE = '未发现 upstream-gate.<feature>.md'

afterEach(() => {
  cleanupGateFixtures()
})

// ---------------------------------------------------------------------------
// fixture 构造
// ---------------------------------------------------------------------------

type Fm = Record<string, string>

/** 合规 front-matter 的七个必填字段。`competitive: []` ⇒ 零网络（见文件头）。 */
function baseFm(): Fm {
  return {
    feature: 'fx-probe',
    understanding: 'fx 需求复述摘要',
    sources_read: '["fx-source.md#fx-anchor"]',
    competitive: '[]',
    persona_ia: 'fx persona 摘要',
    data_feasibility: '{"fields": ["fxField"]}',
    mvp_scope: 'fx MVP 摘要',
  }
}

function artifactText(fm: Fm, body = 'fx 正文'): string {
  const lines = Object.entries(fm).map(([k, v]) => `${k}: ${v}`)
  return ['---', ...lines, '---', '', body, ''].join('\n')
}

/** `sources_read` 指的真源：`## fx anchor` → GitHub slug `fx-anchor`。 */
const SOURCE_MD = ['# fx source', '', '## fx anchor', '', 'fx 内容', ''].join('\n')

type BuildOpts = {
  /** 覆盖 / 删除（值为 undefined 即删）front-matter 字段 */
  fm?: Record<string, string | undefined>
  /** 整份 artifact 文本直接给（测 front-matter 解析失败用） */
  rawArtifact?: string
  /** 不放 artifact —— 测 no-op 出口 */
  noArtifact?: boolean
  /** 不放 fx-source.md —— 测死文件 */
  noSource?: boolean
  /** 放一份 product-context.md，其 available_data_fields 为此值 */
  contextFields?: string[]
  /** 额外文件（fixture 相对路径 → 内容） */
  extra?: Record<string, string>
}

function build(opts: BuildOpts = {}): string {
  const files: Record<string, string> = {}

  if (!opts.noArtifact) {
    if (opts.rawArtifact !== undefined) {
      files[ARTIFACT] = opts.rawArtifact
    } else {
      const fm = baseFm()
      for (const [k, v] of Object.entries(opts.fm ?? {})) {
        if (v === undefined) delete fm[k]
        else fm[k] = v
      }
      files[ARTIFACT] = artifactText(fm)
    }
  }
  if (!opts.noSource) files[`${DIR}/fx-source.md`] = SOURCE_MD
  if (opts.contextFields) {
    files[`${DIR}/product-context.md`] = artifactText(
      { available_data_fields: JSON.stringify(opts.contextFields) },
      'fx product context',
    )
  }
  Object.assign(files, opts.extra ?? {})

  return createGateFixture({
    gate: GATE,
    prefix: 'fx-upstream-gate',
    dirs: [DIR],
    files,
    // 闸 import 的同仓资产：schema 由 `loadSchema()` 从**同目录**读，
    // `collectHeadingSlugs` 来自 audit-stale-anchors。⛔ 两者都必须**拷**不能软链
    // （软链会让被链文件的 import.meta.url 落回真仓库 —— 见 harness 头注释）。
    copyFiles: ['scripts/upstream-gate.schema.json', 'scripts/audit-stale-anchors.mjs'],
    // linkDirs 刻意不传 ⇒ 默认 ['scripts/lib']（两个文件都 import ./lib/is-cli-entry）
  })
}

// ===========================================================================
describe('validate-upstream-gate — no-op 出口（本仓当前唯一走的那条）', () => {
  it('repoRoot 下无 upstream-gate.<feature>.md ⇒ no-op PASS 且 exit 0', () => {
    // 🔴 这不是「测了个空壳」：本仓实测零个 artifact ⇒ `pnpm audit:upstream-gate` 走的
    // 就是这条。它是**登记在闸头注释里的刻意 fail-open**（chokepoint-bound），
    // 钉住它是为了 ① 被静默改掉时有东西响 ② 让「exit 0 且几乎无输出」这一档有个
    // 只有真跑过才有的读数，从而与「entry guard 恒假」区分开。
    const root = build({ noArtifact: true, noSource: true })
    const run = runGate(root, GATE)
    expectGateGreen(run, { contains: [`validate-upstream-gate OK — ${NOOP_LINE}`] })
    expect(run.stdout.trim()).not.toBe('')
  })

  it('⛔ must-not-hit：模板文件 `_*.template.md` 不算 artifact', () => {
    const root = build({
      noArtifact: true,
      noSource: true,
      extra: { [`${DIR}/_upstream-gate.fx-tmpl.template.md`]: artifactText(baseFm()) },
    })
    const run = runGate(root, GATE)
    // 正向锚点：走到了 no-op 那条 ⇒ 模板确实没被收进分母
    expectGateGreen(run, { contains: [NOOP_LINE] })
    expect(run.stdout).not.toContain('fx-tmpl')
  })
})

// ===========================================================================
describe('validate-upstream-gate — 绿档（非空过）', () => {
  it('合规 artifact ⇒ exit 0，回显 fixture 自己的 artifact 路径 + SKIP 降级说明', () => {
    const root = build()
    const run = runGate(root, GATE)
    expectGateGreen(run, {
      contains: [
        // 只有真跑过 CLI 才有的读数：递归找到的相对路径 + 逐 artifact 的分节头
        `── ${ARTIFACT} ──`,
        '✓ 确定层 PASS',
        '无 product-context —— 该检降级（不阻断）',
        '人工层（understanding / persona_ia / mvp_scope + competitive 相关性）由 owner PR review 验收',
        'validate-upstream-gate OK — 确定层全过',
      ],
    })
    // ⛔ 反向钉（形态见文件头）：跑错了树就会落到 no-op 那条
    expect(run.stdout).not.toContain(NOOP_LINE)
  })

  it('`--repo-root=` 那一路（templates 教 consumer 的真实形态）⇒ 同样绿', () => {
    const root = build()
    // consumer 侧逐字是 `--repo-root=.`，cwd 即 fixture 根 ⇒ 等价
    expectGateGreen(runGate(root, GATE, ['--repo-root=.']), {
      contains: [`── ${ARTIFACT} ──`, 'validate-upstream-gate OK — 确定层全过'],
    })
  })

  it('`--artifact=` 显式单文件那一路（prompt 文档教的形态）⇒ 只审那一个', () => {
    const root = build({
      extra: { [`${DIR}/upstream-gate.fx-second.md`]: artifactText({ ...baseFm(), feature: 'fx-second' }) },
    })
    const run = runGate(root, GATE, [`--artifact=${ARTIFACT}`])
    expectGateGreen(run, { contains: [`── ${ARTIFACT} ──`] })
    // ⛔ must-not-hit：显式那一路不该把目录里第二个 artifact 也审进来
    expect(run.stdout).not.toContain('fx-second')
  })
})

// ===========================================================================
describe('validate-upstream-gate — schema 结构层', () => {
  it('缺必填字段 ⇒ 红且点名该字段', () => {
    const root = build({ fm: { mvp_scope: undefined } })
    expectGateRed(runGate(root, GATE), {
      checks: [
        '✗ 确定层 FAIL',
        '[schema.mvp_scope] 必填字段缺失或为空',
        'validate-upstream-gate FAIL',
      ],
    })
  })

  it('必填字段类型错 ⇒ 红且点名期望/实际类型', () => {
    const root = build({ fm: { sources_read: 'fx-not-an-array' } })
    expectGateRed(runGate(root, GATE), {
      checks: ['[schema.sources_read] 类型应为 array，实为 string'],
    })
  })

  it('结构挂了 ⇒ 深层检整块跳过（structureOk 那道门）', () => {
    // 同一份 artifact 同时有：结构错（缺 feature）+ 一个必然死的锚点。
    // 期望只出 schema finding，⛔ 不出 sources_read finding。
    const root = build({
      fm: { feature: undefined, sources_read: '["fx-nonexistent-source.md#fx-dead"]' },
    })
    const run = runGate(root, GATE)
    expectGateRed(run, { checks: ['[schema.feature] 必填字段缺失或为空'] })
    const out = `${run.stderr}\n${run.stdout}`
    expect(out).not.toContain('sources_read')
    expect(out).not.toContain('目标文件不存在')
  })
})

// ===========================================================================
describe('validate-upstream-gate — sources_read 锚点层（跨模块 import 的实证）', () => {
  it('目标文件不存在 ⇒ 红且点名路径', () => {
    const root = build({ noSource: true })
    expectGateRed(runGate(root, GATE), {
      checks: ['[sources_read: fx-source.md#fx-anchor] 目标文件不存在', 'fx-source.md'],
    })
  })

  it('文件存在但 heading slug 不存在 ⇒ 红（collectHeadingSlugs 真的在 fixture 里跑了）', () => {
    // 这条是 `collectHeadingSlugs` 经 `copyFiles` 拷进来的 audit-stale-anchors 解析成功的
    // 唯一证据：拷贝失败 / import 解析不到，闸会崩在 import 阶段而不是给出这条 finding。
    const root = build({ fm: { sources_read: '["fx-source.md#fx-no-such-anchor"]' } })
    expectGateRed(runGate(root, GATE), {
      checks: ['heading slug 不存在（已删/改名/合并）：#fx-no-such-anchor'],
    })
  })

  it('非 .md 目标不校 anchor ⇒ 绿（正向锚点：上面两条不是恒红）', () => {
    const root = build({
      fm: { sources_read: '["fx-data.json#fx-whatever"]' },
      extra: { [`${DIR}/fx-data.json`]: '{"fx": 1}\n' },
    })
    expectGateGreen(runGate(root, GATE), { contains: ['✓ 确定层 PASS'] })
  })
})

// ===========================================================================
describe('validate-upstream-gate — competitive 层（全部零网络，见文件头）', () => {
  it('item 缺字段 ⇒ 红且逐字段点名（省略 url ⇒ 连 checkUrl 都不进）', () => {
    const root = build({ fm: { competitive: '[{"vendor": "fxVendor"}]' } })
    const run = runGate(root, GATE)
    expectGateRed(run, { checks: ['[competitive[0]] competitive[0] 缺/空字段：url, finding'] })
    // ⛔ must-not-hit：没有 url ⇒ 不该出现任何 URL 判据的输出
    expect(`${run.stderr}\n${run.stdout}`).not.toContain('competitive[0].url')
  })

  it('占位/示例域名 ⇒ 红（PLACEHOLDER_HOSTS 在 fetch 之前就判掉）', () => {
    const root = build({
      fm: {
        competitive: '[{"vendor": "fxVendor", "url": "https://example.com/fx", "finding": "fx 结论"}]',
      },
    })
    expectGateRed(runGate(root, GATE), {
      checks: ['[competitive[0].url] 占位/示例域名：example.com'],
    })
  })

  it('URL 格式非法 ⇒ 红（new URL 抛在 fetch 之前）', () => {
    const root = build({
      fm: {
        competitive: '[{"vendor": "fxVendor", "url": "fx-not-a-url", "finding": "fx 结论"}]',
      },
    })
    expectGateRed(runGate(root, GATE), {
      checks: ['[competitive[0].url] URL 格式非法'],
    })
  })

  it('`--no-network` ⇒ 结构合规的 item 不发请求、记 UNVERIFIED 降级、exit 0', () => {
    const root = build({
      fm: {
        // 这个 host 不可解析；`--no-network` 下它**不该**被访问 ⇒ 用它当探针：
        // 若哪天该分支真发了请求，本条会因超时变慢/结果不稳而暴露。
        competitive:
          '[{"vendor": "fxVendor", "url": "https://fx-no-such-host.invalid/x", "finding": "fx 结论"}]',
      },
    })
    expectGateGreen(runGate(root, GATE, ['--no-network']), {
      contains: [
        '[UNVERIFIED] competitive[].url —— --no-network：跳过 HTTP 可达性检查（格式 / 占位域名仍已判）',
        '✓ 确定层 PASS',
      ],
    })
  })

  // 2026-09-09 用追溯回填的真 artifact 造故障时撞到：旧版 `--no-network` 把不需要网络的
  // 格式 / 占位域名检查也一并跳过 ⇒ example.com 在无网模式下全绿。网络只是「可达性」那一步。
  it('`--no-network` 仍判占位域名 ⇒ 红且点名 competitive[i].url（⛔ 无网 ≠ 免检格式）', () => {
    const root = build({
      fm: {
        competitive:
          '[{"vendor": "fxVendor", "url": "https://example.com/x", "finding": "fx 结论"}]',
      },
    })
    expectGateRed(runGate(root, GATE, ['--no-network']), {
      checks: [
        '✗ 确定层 FAIL',
        '[competitive[0].url] 占位/示例域名：example.com',
        'validate-upstream-gate FAIL',
      ],
    })
  })
})

// ===========================================================================
describe('validate-upstream-gate — data_feasibility 层', () => {
  it('有 product-context 且字段齐 ⇒ 绿，且**不再**印 SKIP 降级', () => {
    const root = build({ contextFields: ['fxField', 'fxOther'] })
    const run = runGate(root, GATE)
    expectGateGreen(run, { contains: ['✓ 确定层 PASS'] })
    // ⛔ must-not-hit + 正向锚点成对：product-context 被找到了，所以不该再降级
    expect(run.stdout).not.toContain('无 product-context')
  })

  it('有 product-context 但字段不在其中 ⇒ 红且点名缺的字段', () => {
    const root = build({
      fm: { data_feasibility: '{"fields": ["fxField", "fxMissing"]}' },
      contextFields: ['fxField'],
    })
    expectGateRed(runGate(root, GATE), {
      checks: ['[data_feasibility] 字段不在 product-context.available_data_fields：fxMissing'],
    })
  })
})

// ===========================================================================
describe('validate-upstream-gate — 解析失败出口', () => {
  it('artifact 缺 front-matter ⇒ 红且点名解析失败（第四个出口）', () => {
    const root = build({ rawArtifact: 'fx 没有 front-matter 的正文\n' })
    expectGateRed(runGate(root, GATE), {
      checks: ['解析失败：artifact 缺 front-matter（必须以 --- 开头）', 'validate-upstream-gate FAIL'],
    })
  })

  it('front-matter 未闭合 ⇒ 同一出口的另一条通路', () => {
    const root = build({ rawArtifact: '---\nfeature: fx-probe\nfx 没有结束分隔符\n' })
    expectGateRed(runGate(root, GATE), {
      checks: ['解析失败：artifact front-matter 未闭合（缺结束 ---）'],
    })
  })
})
