// tests/backlog-entry-id-collision.test.ts
// -----------------------------------------------------------------------------
// [[INFRA-F109]] 落地的单测 —— 撞号闸把覆盖面从三份规则真源扩到 backlog entry ID。
//
// 钉的是「形态识别器读得对」，不是「闸的 git 比对逻辑」：后者需要真的 origin/master 与
// merge-base，单测里造不出可信的活源 ⇒ 那一半由本轮的**致败探针实跑**（造一条真撞号、看闸
// 真的红）承担，结论记在 commit message 与 backlog entry 里。⛔ 别把这里的绿读成「闸会红」。
//
// 每组都配阴性对照（不该命中的形态），因为这条判据的假阴方向是「缺陷看起来已修」：
// 正则漏掉一种标题形态 ⇒ 那些 ID 静默不进已用号池 ⇒ 闸对它们零覆盖却照样报绿。
// -----------------------------------------------------------------------------
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import {
  BACKLOG_ENTRY_HEADING_RE,
  BACKLOG_ID_PREFIXES,
  BACKLOG_RANGE_MAX_SPAN,
  extractBacklogEntries,
  extractBacklogIds,
  backlogNextIdFromTexts,
  formatBacklogId,
  backlogIdPattern,
  extractRuleEntries,
} from '../scripts/lib/rule-ids.mjs'

const REPO_ROOT = resolve(__dirname, '..')
const read = (rel: string) => readFileSync(resolve(REPO_ROOT, rel), 'utf-8')

describe('BACKLOG_ENTRY_HEADING_RE — 四种实测标题形态', () => {
  it('### + F 中缀（最常见）', () => {
    const e = extractBacklogEntries('### INFRA-F109: 撞号闸不覆盖 backlog entry ID')
    expect(e.map((x) => x.id)).toEqual(['INFRA-F109'])
    expect(e[0].prefix).toBe('INFRA')
    expect(e[0].infix).toBe('F')
  })

  it('#### 层级 + 无 F 中缀 + 两位零填充（FID-04 —— 只匹配 ^### 的正则会静默漏掉它）', () => {
    const e = extractBacklogEntries('#### FID-04 深层保真 pilot（2026-07-20 · Batch F 第一步）')
    expect(e.map((x) => x.id)).toEqual(['FID-04'])
    expect(e[0].width).toBe(2) // 零填充宽度必须保留，否则取号会吐出 FID-5 而不是 FID-05
  })

  it('前缀自己带连字符（META-DSYNC-01）不被切错', () => {
    const e = extractBacklogEntries('### META-DSYNC-01: 给 `/design-sync` 提 config 钩子')
    expect(e[0].prefix).toBe('META-DSYNC')
    expect(e[0].id).toBe('META-DSYNC-01')
  })

  it('umbrella 范围写法展开成全部成员（不展开 = F93/F94 从已用号池漏掉）', () => {
    const e = extractBacklogEntries('### CANONICAL-F92–F94 · 打包裁定入口 —— tab / button')
    expect(e.map((x) => x.id)).toEqual(['CANONICAL-F92', 'CANONICAL-F93', 'CANONICAL-F94'])
    expect(e.filter((x) => x.viaRange).map((x) => x.id)).toEqual([
      'CANONICAL-F93',
      'CANONICAL-F94',
    ])
    // 展开出来的每一条都指回同一行，报错时能定位到那条 umbrella 标题
    expect(new Set(e.map((x) => x.line))).toEqual(new Set([1]))
  })

  it('em-dash 也算范围分隔符', () => {
    expect(extractBacklogEntries('### INFRA-F10—F12: x').map((x) => x.id)).toEqual([
      'INFRA-F10',
      'INFRA-F11',
      'INFRA-F12',
    ])
  })

  it('加粗标题（**ID** 形态）也认', () => {
    expect(extractBacklogEntries('### **INFRA-F7**: x').map((x) => x.id)).toEqual(['INFRA-F7'])
  })
})

describe('阴性对照 —— 这些不该被当成 entry ID', () => {
  it('无 ID 的标题（## Active / #### 中文小节）零命中', () => {
    expect(extractBacklogEntries('## Active\n#### 补测扩展（2026-07-17 · 三批多 Agent）')).toEqual(
      [],
    )
  })

  it('正文里的子发现编号不进池（必须按标题行切，不能全文扫 token）', () => {
    const md = [
      '### INFRA-F68: 平台级分析 findings register',
      '',
      '- AIC-01 / PAT-01 / TRIG-02 / DOCS-03 都是正文里的子发现编号，不是 entry ID',
      '- 也提到了 INFRA-F55 与 CANONICAL-F92/F93/F94（斜杠写法，正文）',
    ].join('\n')
    expect([...extractBacklogIds(md)]).toEqual(['INFRA-F68'])
  })

  it('ASCII 连字符不作范围分隔符（否则 FID-04-2026 会炸成上千个假已用号）', () => {
    const e = extractBacklogEntries('#### FID-04-2026 某个带年份尾巴的标题')
    expect(e.map((x) => x.id)).toEqual(['FID-04'])
  })

  it('倒序 / 超跨度的范围只取起始号，不静默展开', () => {
    expect(extractBacklogEntries('### INFRA-F20–F10: 倒序').map((x) => x.id)).toEqual([
      'INFRA-F20',
    ])
    const wide = 1 + BACKLOG_RANGE_MAX_SPAN + 1
    expect(
      extractBacklogEntries(`### INFRA-F1–F${wide}: 超跨度`).map((x) => x.id),
    ).toEqual(['INFRA-F1'])
  })

  it('规则编号识别器不会把 entry 标题当规则（两套判据互不污染）', () => {
    expect(extractRuleEntries('### INFRA-F109: x')).toEqual([])
    expect(extractBacklogEntries('#### M23.6 某条规则')).toEqual([])
  })

  it('`~~划线标题~~` 这个形态在真源里不存在 —— 所以正则刻意不为它加剥离分支', () => {
    const struck = read('docs/internal/backlog.md')
      .split('\n')
      .filter((l) => /^#{2,4}.*~~/.test(l))
    expect(struck).toEqual([])
  })
})

describe('真源现值 —— 存量必须全部读得出来且前缀全在白名单里（S4 的存量前置）', () => {
  const entries = extractBacklogEntries(read('docs/internal/backlog.md'))

  it('存量解析非空（S3 fail-closed 的反面：解析得出来才谈得上覆盖）', () => {
    expect(entries.length).toBeGreaterThan(0)
  })

  it('存量前缀 0 个落在 BACKLOG_ID_PREFIXES 之外 —— 否则新判据首跑就红在存量上', () => {
    const unknown = entries.filter((e) => !BACKLOG_ID_PREFIXES.includes(e.prefix))
    expect(unknown.map((e) => `${e.id}@${e.line}`)).toEqual([])
  })

  it('前缀白名单是排好序且无重复的（两处共用的那一份）', () => {
    expect(BACKLOG_ID_PREFIXES).toEqual([...new Set(BACKLOG_ID_PREFIXES)].sort())
  })

  it('umbrella 范围标题在真源里确实存在 —— 展开分支不是为理论风险写的', () => {
    // ⚠️ 2026-08-28（第六十九轮）扩输入面：本断言原先只读 `backlog.md`，而当日 owner 拍板把
    // 唯一那条 umbrella（`CANONICAL-F92–F94`）迁进了 `design-review-queue.md` ⇒ 断言当场转红。
    //
    // ⛔ **没有放宽判据**（仍要求 > 0）。三条修法里选了扩输入面，另两条都被否：
    //   · 改成「允许 0」= 把本断言退化成它标题正好反对的那种「为理论风险写的」；
    //   · 删掉 `extractBacklogEntries` 的 range 展开分支 = 造未来盲区（下次有人立
    //     `### CANONICAL-F150–F152`，`F151`/`F152` 会静默不进已用号池 ⇒ 撞号闸对它们零覆盖）。
    // ⇒ 事实是**那个形态没消失、只是搬了家**，所以判据跟着形态走。
    //
    // ⚠️ queue 的标题多一层顺序编号（`### 8. CANONICAL-…`），生产正则刻意只认 `### <PREFIX>-`
    // ⇒ 这里只在**测试侧**把编号前缀归一化，⛔ 不动 `rule-ids.mjs`。若将来 queue 改编号格式，
    // 归一化会失效 ⇒ 下面 `queueEntries` 转空、本断言转红（**可被发现，不是静默放过**）。
    const queueEntries = extractBacklogEntries(
      read('docs/internal/design-review-queue.md').replace(/^(#{3,4}) \d+\. /gm, '$1 '),
    )
    const all = [...entries, ...queueEntries]
    // fail closed：两份都得解析出东西来，否则「> 0」可能是单侧偶然满足
    expect(queueEntries.length).toBeGreaterThan(0)
    expect(all.some((e) => e.viaRange)).toBe(true)
  })
})

describe('取号形态继承 —— 撞号建议与 pnpm new-backlog 用同一份实现', () => {
  it('INFRA 继承 F + 不填零；FID 继承两位零填充', () => {
    expect(formatBacklogId('INFRA', 130, { infix: 'F', width: 2 })).toBe('INFRA-F130')
    expect(formatBacklogId('FID', 5, { infix: '', width: 2 })).toBe('FID-05')
    expect(formatBacklogId('EXTRACT', 10, { infix: '', width: 3 })).toBe('EXTRACT-010')
  })

  it('backlogNextIdFromTexts 从最大号那条继承字面形态', () => {
    const md = ['### INFRA-F9: a', '### INFRA-F129: b', '#### FID-04: c'].join('\n')
    expect(backlogNextIdFromTexts('INFRA', [md]).nextId).toBe('INFRA-F130')
    expect(backlogNextIdFromTexts('FID', [md]).nextId).toBe('FID-05')
    // 该前缀一个号都没用过 → 从 1 起，走历史零填充回退形态
    expect(backlogNextIdFromTexts('BRIDGE', [md]).nextId).toBe('BRIDGE-001')
  })

  it('⛔ 已删档号仍算已用 —— 建议号必须扫全文提及，不能只扫标题行', () => {
    // 这条钉的是 2026-08-20 致败探针实测出来的真错：闸原先按标题行算建议号，给出 `INFRA-F130`，
    // 而那个号已落地删档（没有标题行了）、历史引用仍在 ⇒ 建议一个被占的号比不给建议更糟。
    const backlogHeadingsOnly = '### INFRA-F129: 还开着的那条'
    const statusMention = '原 [[INFRA-F130]] 已于 2026-08-19 落地并删档'
    expect(backlogNextIdFromTexts('INFRA', [backlogHeadingsOnly]).nextId).toBe('INFRA-F130')
    expect(backlogNextIdFromTexts('INFRA', [backlogHeadingsOnly, statusMention]).nextId).toBe(
      'INFRA-F131',
    )
  })

  it('多份文本合并取最大（本地 ∪ origin/master 的用法）· null 文本安全跳过', () => {
    expect(
      backlogNextIdFromTexts('INFRA', ['### INFRA-F5: a', null, '### INFRA-F40: b']).nextId,
    ).toBe('INFRA-F41')
  })

  it('backlogIdPattern 转义前缀里的连字符且能扫全文（new-backlog 的扫描面）', () => {
    const hits = [...'见 META-DSYNC-01 与 META-DSYNC-07'.matchAll(backlogIdPattern('META-DSYNC'))]
    expect(hits.map((m) => m[2])).toEqual(['01', '07'])
    // 阴性对照：META 前缀不该越界匹配到 META-DSYNC-01
    expect([...'见 META-DSYNC-01'.matchAll(backlogIdPattern('META'))]).toEqual([])
  })
})

describe('闸的挂载面 —— 三处缺一是半个闸（entry ①②③ 的机械核）', () => {
  it('② backlog.md 在闸的 FILES 里', () => {
    const gate = read('scripts/audit-rule-number-collision.mjs')
    expect(gate).toMatch(/file: 'docs\/internal\/backlog\.md', kind: 'backlog'/)
  })

  it('③ pre-commit 的条件触发正则含 backlog.md（否则只剩 L5 兜，本机一路绿）', () => {
    const hook = read('.husky/pre-commit')
    const line = hook
      .split('\n')
      .find((l) => l.includes('git diff --cached') && l.includes('audit-rule-number-collision'))
    expect(line, '找不到那条触发行 —— hook 形态变了，这个断言先失效了').toBeTruthy()
    expect(line).toContain('^docs/internal/backlog\\.md$')
  })

  it('CI 侧仍带 --require-remote（S2 在 CI 上必须反转成 fail-closed）', () => {
    expect(read('.gitea/workflows/pr-checks.yml')).toMatch(
      /audit-rule-number-collision\.mjs[^\n]*--require-remote/,
    )
  })

  it('前缀表只在 lib 一处定义 —— new-backlog 不再自带第二份', () => {
    const nb = read('scripts/new-backlog.mjs')
    expect(nb).toMatch(/BACKLOG_ID_PREFIXES as VALID_PREFIXES/)
    expect(nb).not.toMatch(/const VALID_PREFIXES = \[/)
  })
})
