/**
 * `audit:rule-number-collision` 的 **S1–S4 整脚本回归面**。
 *
 * 🔴 **为什么必须是整脚本 spawn，⛔ 不能按符号 import**：
 *   S1–S4 全部写在闸的 `main()` 里、**零导出** —— 闸全文 `^export ` 只有 2 个
 *   （`findDuplicateBacklogDeclarations` / `findReissuedBacklogIds`，即 S5/S6），
 *   而 `tests/audit-rule-number-collision.test.ts` 正是按符号 import 那两个
 *   ⇒ 它**结构上够不到 S1–S4**。那份文件 `:13-14` 自己逐字登记了这条缺口：
 *   「⛔ S1–S4 与 S2 的降级语义仍无单测（它们要 git remote，属独立提案）」。
 *   ⚠️ 2026-09-15 ai-ds-lab 复核：**那个「独立提案」不存在** —— 两侧 backlog 各自
 *   `rule-number-collision` 零命中（带阳性对照：同文件 `audit-` 73 行）⇒ ⛔ 别再去找它。
 *
 * ── 🔴 登记的覆盖边界（⛔ 是边界，不是 TODO）────────────────────────────
 *   ① **本面只验 S1–S4**。S5/S6 由那份符号级测试覆盖，⛔ 两份互不替代。
 *   ② **fixture 的 git 历史是造的**，⛔ 不是真仓库的历史 ⇒ 本面证的是
 *      「判据在给定输入下的分支走对了」，⛔ 不是「真仓库上不会撞号」。
 *   ③ **`--no-fetch` 全程带着** —— fixture 的 remote 是本地 bare repo，
 *      让闸去 `git fetch` 只会增加不确定性且拖慢测试；S2 要验的是「拿不到 ref 时怎么办」，
 *      与「fetch 成没成功」是两件事。
 *   ④ ⛔ **不验文案的全文**，只验点名了哪条判据（`expectGateRed` 的口径）——
 *      文案会漂，判据编号不会。
 */

import { describe, it, expect, afterAll } from 'vitest'
import { execFileSync } from 'node:child_process'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
  createGateFixture,
  writeFixtureFile,
  runGate,
  gitCleanEnv,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'

const GATE = 'scripts/audit-rule-number-collision.mjs'

/** 受管文件里**能正常解析出 ID** 的最小内容 —— 对照臂用它，故障臂只改要测的那一份。 */
const OK_RULE = '## M23.6 — 一条规则\n\n正文。\n'
const OK_BACKLOG = '### CANONICAL-F100: 一条 entry\n\n- 轨道：A\n'
const OK_STATUS = '# STATUS\n\n正文。\n'
const OK_TRACKER = '# tracker\n\n正文。\n'

/** 四份受管文件（`FILES` 常量）+ 两份只在 S5 用到的 SoT。 */
function baseFiles(over: Record<string, string> = {}): Record<string, string> {
  return {
    'docs/internal/mockup-conventions.md': OK_RULE,
    'docs/internal/code-conventions.md': OK_RULE,
    'docs/internal/figma-technical-reference.md': OK_RULE,
    'docs/internal/backlog.md': OK_BACKLOG,
    'docs/STATUS.md': OK_STATUS,
    'docs/internal/retrospection/design-spec-canonical-alignment-tracker.md': OK_TRACKER,
    ...over,
  }
}

function git(cwd: string, args: string[]): string {
  // ⛔ 必须用 gitCleanEnv：不剥 `GIT_*` 的话这些命令会落到继承来的 GIT_DIR / GIT_INDEX_FILE 上，
  //    写进**真仓库**正在提交的 index（`gate-fixture-root` 头注释已登记过这条）。
  return execFileSync('git', args, { cwd, encoding: 'utf8', env: gitCleanEnv(), stdio: ['ignore', 'pipe', 'pipe'] })
}

function initRepo(root: string): void {
  git(root, ['init', '-q', '-b', 'master'])
  git(root, ['config', 'user.email', 'fixture@example.invalid'])
  git(root, ['config', 'user.name', 'fixture'])
  git(root, ['add', '-A'])
  git(root, ['commit', '-q', '-m', 'fixture base'])
}

/**
 * 给 fixture 接一个**本地 bare remote** 并建出 `origin/master`。
 * `remoteEdit` 非空时，先在 remote 上多推一个 commit（模拟「别人已经推了」），
 * 本地则回到推之前那个 commit —— 这样本地的新增才算「不在 merge-base 上」。
 */
function attachRemote(root: string, remoteEdit?: { rel: string; content: string }): void {
  const bare = mkdtempSync(join(tmpdir(), 'rnc-remote-'))
  git(bare, ['init', '-q', '--bare', '-b', 'master'])
  git(root, ['remote', 'add', 'origin', bare])
  git(root, ['push', '-q', 'origin', 'master'])

  if (remoteEdit) {
    const base = git(root, ['rev-parse', 'HEAD']).trim()
    writeFixtureFile(root, remoteEdit.rel, remoteEdit.content)
    git(root, ['add', '-A'])
    git(root, ['commit', '-q', '-m', 'remote-side change'])
    git(root, ['push', '-q', 'origin', 'master'])
    // 本地退回 base ⇒ merge-base = base，而 origin/master 领先一个 commit
    git(root, ['reset', '-q', '--hard', base])
  }
  git(root, ['fetch', '-q', 'origin', 'master'])
}

function build(over: Record<string, string> = {}): string {
  return createGateFixture({
    gate: GATE,
    prefix: 'rule-number-collision-fx',
    dirs: ['docs/internal', 'docs/internal/retrospection'],
    files: baseFiles(over),
  })
}

afterAll(() => cleanupGateFixtures())

// ────────────────────────────────────────────────────────────
describe('S2 —— 拿不到 origin/master 时的两个降级出口（方向相反的两侧）', () => {
  it('默认 fail-open：没有 origin/master ⇒ exit 0 且明说自己降级了', () => {
    const root = build()
    initRepo(root) // ⛔ 刻意不接 remote
    const run = runGate(root, GATE, ['--no-fetch'])
    expect(run.status).toBe(0)
    expect(run.stdout).toContain('S2 fail-open')
  })

  it('🔴 带 --require-remote 反转为 fail-closed：同一个输入 ⇒ exit 1', () => {
    const root = build()
    initRepo(root)
    const run = runGate(root, GATE, ['--no-fetch', '--require-remote'])
    expect(run.status).toBe(1)
    expect(run.stdout).toContain('S2 在 --require-remote 下反转为 fail-closed')
  })

  it('⚠️ 两臂的差别【只】来自那个 flag —— 同一 fixture 跑两次，退出码 0 vs 1', () => {
    // 这一条是前两条的**对照**：⛔ 不许它们各自绿在不同的 fixture 上，
    // 否则「反转」可能来自输入差异而不是 flag（`AGENTS §3.2` 推论一同族）。
    const root = build()
    initRepo(root)
    const open = runGate(root, GATE, ['--no-fetch'])
    const closed = runGate(root, GATE, ['--no-fetch', '--require-remote'])
    expect([open.status, closed.status]).toEqual([0, 1])
  })
})

describe('S3 —— 受管文件在、却一个 ID 都解析不出来 ⇒ fail-closed', () => {
  it('把一份规则真源换成无标题正文 ⇒ exit 1 且点名 S3 与那个文件', () => {
    const root = build({ 'docs/internal/code-conventions.md': '只有正文，没有任何编号标题。\n' })
    initRepo(root)
    attachRemote(root)
    const run = runGate(root, GATE, ['--no-fetch'])
    expect(run.status).toBe(1)
    expect(run.stdout).toContain('S3 fail-closed')
    expect(run.stdout).toContain('docs/internal/code-conventions.md')
  })

  it('⚠️ 文件【不存在】走的是另一条路（跳过），⛔ 不该被 S3 抓 —— 两种「没有 ID」不是一回事', () => {
    const files = baseFiles()
    delete files['docs/internal/code-conventions.md']
    const root = createGateFixture({
      gate: GATE, prefix: 'rule-number-collision-fx-missing',
      dirs: ['docs/internal', 'docs/internal/retrospection'], files,
    })
    initRepo(root)
    attachRemote(root)
    const run = runGate(root, GATE, ['--no-fetch'])
    expect(run.stdout).toContain('本地不存在，跳过')
    expect(run.stdout).not.toContain('S3 fail-closed')
    expect(run.status).toBe(0)
  })
})

describe('S4 —— backlog 标题行用了不在前缀表里的前缀 ⇒ fail-closed', () => {
  it('造一个 NOTAPREFIX- 前缀 ⇒ exit 1 且点名 S4 与那个前缀', () => {
    const root = build({
      'docs/internal/backlog.md': `${OK_BACKLOG}\n### NOTAPREFIX-F7: 手误造出来的前缀\n\n- 轨道：A\n`,
    })
    initRepo(root)
    attachRemote(root)
    const run = runGate(root, GATE, ['--no-fetch'])
    expect(run.status).toBe(1)
    expect(run.stdout).toContain('S4 fail-closed')
    expect(run.stdout).toContain('NOTAPREFIX')
  })
})

describe('S1 —— 本地新增的号已被 origin/master 上别的条目占用 ⇒ 撞号', () => {
  /**
   * 🔴 **这条断言必须钉死 `✗ FAIL（S1）` 这个精确文案，⛔ 不许用 `/S1|…/` 这种宽正则。**
   *
   * 立此纪律的实证（2026-09-15，造故障当场抓到）：初版断言写的是
   * `expect(run.status).toBe(1)` + `toContain('CANONICAL-F200')` + `toMatch(/S1|…/)`，
   * 而把闸里的 `collisions.push({…})` 整条拆掉（= S1 永不报）之后，
   * **这条测试照样绿** —— 因为我构造的场景**同时满足 S5**（号复用）的形态，
   * exit 1 与 F200 两个断言在 S5 报的时候也都成立，宽正则里的 `S1` 又会命中别处文本。
   * ⇒ 那是一条**没有判别力的假绿**（`AGENTS §26` 推论十：测试全绿 ⛔ 不等于它在测那个东西）。
   */
  it('remote 先占 CANONICAL-F200，本地再用同号开另一条 entry ⇒ exit 1 且点名 S1', () => {
    const root = build()
    initRepo(root)
    // remote 侧先推一个占了 F200 的 entry；本地随后退回 base
    attachRemote(root, {
      rel: 'docs/internal/backlog.md',
      content: `${OK_BACKLOG}\n### CANONICAL-F200: remote 上的条目\n\n- 轨道：A\n`,
    })
    // 本地拿同一个号开**另一条** entry（标题不同 ⇒ 是两个条目抢一个号）
    writeFixtureFile(root, 'docs/internal/backlog.md',
      `${OK_BACKLOG}\n### CANONICAL-F200: 本地新开的另一条\n\n- 轨道：B\n`)
    const run = runGate(root, GATE, ['--no-fetch'])
    expect(run.status).toBe(1)
    expect(run.stdout).toContain('CANONICAL-F200')
    // ⛔ 精确文案 —— 见上方注释，宽正则会被 S5 冒充
    expect(run.stdout).toContain('✗ FAIL（S1）')
  })

  it('⚠️ 同一场景下 S5 ⛔ 不得重报同一个号（闸自己的 skip 集合）', () => {
    // 这一条钉住 S1/S5 的分工：闸 `:406` 用 `skip = new Set([...collisions.map(c => c.id), …])`
    // 把 S1 已报的号排除出 S5。⛔ 没有这一条，「S1 报了」与「S5 也报了」分不开，
    // 上面那条的精确文案断言就可能是被 S5 的输出顺带满足的。
    const root = build()
    initRepo(root)
    attachRemote(root, {
      rel: 'docs/internal/backlog.md',
      content: `${OK_BACKLOG}\n### CANONICAL-F200: remote 上的条目\n\n- 轨道：A\n`,
    })
    writeFixtureFile(root, 'docs/internal/backlog.md',
      `${OK_BACKLOG}\n### CANONICAL-F200: 本地新开的另一条\n\n- 轨道：B\n`)
    const run = runGate(root, GATE, ['--no-fetch', '--json'])
    const j = JSON.parse(run.stdout)
    expect(j.code).toBe('S1')
    expect(j.collisions.map((c: { id: string }) => c.id)).toContain('CANONICAL-F200')
    expect(j.reissued.map((r: { id: string }) => r.id)).not.toContain('CANONICAL-F200')
  })
})

describe('对照臂 —— 干净 fixture 必须绿（⛔ 否则上面四组的红分不清是判据还是 fixture）', () => {
  it('四份受管文件都正常 + 有 origin/master ⇒ exit 0，且没有任何 S1–S4 的 FAIL 文案', () => {
    const root = build()
    initRepo(root)
    attachRemote(root)
    const run = runGate(root, GATE, ['--no-fetch'])
    expect(run.status).toBe(0)
    for (const s of ['S1', 'S2 在 --require-remote', 'S3 fail-closed', 'S4 fail-closed']) {
      expect(run.stdout).not.toContain(`✗ FAIL（${s}`)
    }
  })

  it('⚠️ 非空过：对照臂确实扫到了文件，⛔ 不是「一个都没扫」的空绿', () => {
    const root = build()
    initRepo(root)
    attachRemote(root)
    const run = runGate(root, GATE, ['--no-fetch'])
    // 判定面非空的证据：四份受管文件里至少有一份被印出来了
    expect(run.stdout).toContain('docs/internal/backlog.md')
    expect(run.stdout).toContain('origin/master')
  })
})
