// tests/audit-rule-number-collision.test.ts
//
// 本闸的**第一份测试**（2026-09-07 —— 此前 `ls tests | grep rule-number` 是空的）。
// 覆盖 2026-09-07 新增的两条判据：
//   S5 backlog 号复用（含**已删档**历史号）—— S1 只看 remote 的标题行，看不见这一类
//   S6 同一份 backlog 里同号声明两次 —— `newLocal` 按 merge-base 过滤，S1/S5 都看不见这一类
//
// ⚠️ **S6 是验 S5 时造故障撞出来的，不是设计时想到的**（预注册 P4 的原断言是错的：
//    它以为「拿一个当前有标题行的号新建第二条 entry」会被 S1 报，实测 EXIT=0 两条都没报）。
//    ⇒ 判据没被放宽，是判据面被补上了。见
//    `ai-ds-lab/docs/2026-09-07-backlog-f-number-reuse-preregistration.md`。
//
// ~~⛔ S1–S4 与 S2 的降级语义仍无单测（它们要 git remote，属独立提案）——
//    如实登记，别把本文件读成「这条闸有测试了」。~~
// 🔴 **2026-09-15 订正**：S1–S4 已补 ⇒ `tests/audit-rule-number-collision-cli.test.ts`
//    （**整脚本 spawn** 形态 —— S1–S4 全在 `main()` 里零导出，**本文件这种按符号 import
//    的形态结构上够不到它们**，那才是当初那条缺口的真正成因）。
//    该面自带 fixture 内建的 git remote（本地 bare repo），**四臂造故障已实测**：
//    拆 S1 ⇒ 2 failed · 拆 S2 ⇒ 2 failed · 拆 S3 ⇒ 1 failed · 拆 S4 ⇒ 1 failed
//    · 干净对照臂 ⇒ 10 passed。
//    ⚠️ 顺带纠正「属独立提案」那句：**那个提案不存在** —— 两侧 backlog 各自
//    `rule-number-collision` 零命中（阳性对照：同文件 `audit-` 73 行）。
// ⛔ **本文件仍然只覆盖 S5/S6** —— 别把本文件读成「这条闸有测试了」那句**依然成立**，
//    只是现在它的准确说法是「本文件只管 S5/S6，S1–S4 在 `-cli` 那份」。
import { describe, it, expect } from 'vitest'
import {
  findDuplicateBacklogDeclarations,
  findReissuedBacklogIds,
} from '../scripts/audit-rule-number-collision.mjs'
import { extractBacklogEntries } from '../scripts/lib/rule-ids.mjs'

// remote 三份 SoT 的替身：F130 只在「历史提及」里出现（标题行已随删档消失）——
// 这正是真实形态，逐字照 2026-09-07 亲验的 INFRA-F130 处境构造。
const REMOTE_TEXTS = [
  // remote backlog.md：F130 **没有标题行**
  '### INFRA-F145 — 活着的 entry\n\n正文。\n',
  // remote STATUS.md：F130 的历史提及还在
  '本轮落地 [[INFRA-F130]] 并删档；另见 INFRA-F144。\n',
  // remote tracker：无关内容
  '# tracker\n\nCANONICAL-F94 已完成。\n',
]

const entry = (id: string, line: number, extra: Record<string, unknown> = {}) => ({
  id,
  line,
  heading: `### ${id} — 标题`,
  prefix: id.replace(/-F?\d+$/, ''),
  ...extra,
})

// ---------------------------------------------------------------------------
// S5 —— 必须命中
// ---------------------------------------------------------------------------
describe('S5 findReissuedBacklogIds — 必须命中', () => {
  it('已删档号（remote 无标题行、有提及）被新 entry 占用 ⇒ 命中并给建议号', () => {
    const hits = findReissuedBacklogIds([entry('INFRA-F130', 662)], REMOTE_TEXTS)
    expect(hits).toHaveLength(1)
    expect(hits[0].id).toBe('INFRA-F130')
    expect(hits[0].line).toBe(662)
    // 建议号 = 三份 SoT 全文的 max + 1（F145 是最大值 ⇒ F146）
    expect(hits[0].suggestion).toBe('INFRA-F146')
  })

  it('remote 仍有标题行的号也算已发出（S1 会先报，此处验判据本身不漏）', () => {
    const hits = findReissuedBacklogIds([entry('INFRA-F145', 10)], REMOTE_TEXTS)
    expect(hits.map((h) => h.id)).toEqual(['INFRA-F145'])
  })
})

// ---------------------------------------------------------------------------
// S5 —— 不可误报（🔴 这组是本批最要紧的：没有它，「恒红」也能通过必须命中那组）
// ---------------------------------------------------------------------------
describe('S5 findReissuedBacklogIds — 不可误报', () => {
  it('真正没用过的号 ⇒ 0 条', () => {
    expect(findReissuedBacklogIds([entry('INFRA-F146', 700)], REMOTE_TEXTS)).toHaveLength(0)
  })

  it('别的前缀不互相污染（CANONICAL-F130 ≠ INFRA-F130）', () => {
    expect(findReissuedBacklogIds([entry('CANONICAL-F130', 5)], REMOTE_TEXTS)).toHaveLength(0)
  })

  it('skipIds 里的 ID ⛔ 不重报（S1 / S6 已点名的那些）', () => {
    const hits = findReissuedBacklogIds(
      [entry('INFRA-F130', 662)],
      REMOTE_TEXTS,
      new Set(['INFRA-F130']),
    )
    expect(hits).toHaveLength(0)
  })

  it('viaRange（umbrella 范围标题展开）⇒ 0 条', () => {
    const hits = findReissuedBacklogIds(
      [entry('INFRA-F130', 662, { viaRange: true })],
      REMOTE_TEXTS,
    )
    expect(hits).toHaveLength(0)
  })

  it('remoteTexts 为空 ⇒ 0 条（⛔ 不许把「没输入」判成违例）', () => {
    expect(findReissuedBacklogIds([entry('INFRA-F130', 662)], [])).toHaveLength(0)
  })

  // 🔴 口径的承重点：判据只吃 remote 文本。掺本地 ⇒ 新 entry 自己就在里面 ⇒ 恒红。
  it('掺入「本地」文本会让一个全新号变红 —— 这就是判据刻意只吃 remote 的原因', () => {
    const localBacklog = '### INFRA-F146 — 我刚写的新 entry\n\n正文。\n'
    const green = findReissuedBacklogIds([entry('INFRA-F146', 700)], REMOTE_TEXTS)
    const red = findReissuedBacklogIds([entry('INFRA-F146', 700)], [...REMOTE_TEXTS, localBacklog])
    expect(green).toHaveLength(0)
    expect(red).toHaveLength(1) // ← 若哪天有人把本地文本掺进去，这条会提醒他后果
  })
})

// ---------------------------------------------------------------------------
// S6 —— 两侧
// ---------------------------------------------------------------------------
describe('S6 findDuplicateBacklogDeclarations', () => {
  it('同号两个非范围声明点 ⇒ 命中，且两处行号都带出来（⛔ 不只报一处）', () => {
    const dups = findDuplicateBacklogDeclarations([
      entry('INFRA-F145', 30),
      entry('INFRA-F146', 100),
      entry('INFRA-F145', 662),
    ])
    expect(dups).toHaveLength(1)
    expect(dups[0].id).toBe('INFRA-F145')
    expect(dups[0].points.map((p) => p.line)).toEqual([30, 662])
  })

  it('每个 ID 只有一个声明点 ⇒ 0 条', () => {
    expect(
      findDuplicateBacklogDeclarations([entry('INFRA-F145', 30), entry('INFRA-F146', 100)]),
    ).toHaveLength(0)
  })

  it('viaRange 造成的重复 ⇒ 0 条（umbrella 范围标题是正常形态）', () => {
    expect(
      findDuplicateBacklogDeclarations([
        entry('INFRA-F145', 30),
        entry('INFRA-F145', 31, { viaRange: true }),
      ]),
    ).toHaveLength(0)
  })

  it('空输入 ⇒ 0 条', () => {
    expect(findDuplicateBacklogDeclarations([])).toHaveLength(0)
    expect(findDuplicateBacklogDeclarations(undefined as never)).toHaveLength(0)
  })
})

// ---------------------------------------------------------------------------
// 真仓库对照 —— 上闸前量过的那个分母，锁住「零存量误伤」
// ---------------------------------------------------------------------------
describe('真 backlog.md 上的现状（锁住上闸时的分母）', () => {
  it('S6 在真 backlog 上零命中 —— 非范围重复声明 = 0', () => {
    const text = readBacklog()
    const dups = findDuplicateBacklogDeclarations(extractBacklogEntries(text))
    expect(dups.map((d) => `${d.id}@${d.points.map((p) => p.line).join(',')}`)).toEqual([])
  })
})

function readBacklog(): string {
  // 用 node: 前缀导入以满足 audit:scripts-stdlib 同族约束（测试侧同风格）
  // eslint-disable-next-line @typescript-eslint/no-var-requires
  const { readFileSync } = require('node:fs') as typeof import('node:fs')
  const { resolve } = require('node:path') as typeof import('node:path')
  return readFileSync(resolve(process.cwd(), 'docs/internal/backlog.md'), 'utf8')
}
