import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import {
  INVENTORIES,
  MIN_IDS_FOR_MIRROR,
  MIRROR_SCAN_DOCS,
  CHAIN_SCAN_DOCS,
  ONBOARDING_AUTHORITIES,
  EXEMPTIONS,
  POINTERIZED_DOCS,
  MIN_ROWS_FOR_TABLE,
  extractHeadingIds,
  extractInventory,
  findEnumerationMirrors,
  findSelfDeclaredChains,
  findPointerizedEnumerations,
  evaluate,
} from '../scripts/audit-rule-inventory.mjs'

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

describe('extractHeadingIds — 活源分母', () => {
  it('H2 标题取分隔符前的首段作 id（— / ： / （ 三种分隔）', () => {
    const md = [
      '## M22 — User Design Intent Acknowledgment（前置 Gate）',
      '## Pre-Phase 0：Product Definition Gate',
      '## Lazy Reference Loading',
    ].join('\n')
    expect(extractHeadingIds(md, 2)).toEqual(['M22', 'Pre-Phase 0', 'Lazy Reference Loading'])
  })

  it('不按普通连字符切（否则 M-PRD-EG / Library-First 会被截断）', () => {
    const md = '## M-PRD-EG — PRD "e.g." 列表必须显式确认 in-scope\n## Library-First + Evidence Discipline\n'
    expect(extractHeadingIds(md, 2)).toEqual(['M-PRD-EG', 'Library-First + Evidence Discipline'])
  })

  it('只收指定层级：H3 子条目不进分母（M11.1 不与 M11 并列）', () => {
    const md = '## M11 — Self-audit Discipline\n### M11.1 — Probe-based 自查\n'
    expect(extractHeadingIds(md, 2)).toEqual(['M11'])
  })

  it('代码围栏里的 ## 不算标题', () => {
    const md = '```\n## M99 — 假的\n```\n## M3 — 真的\n'
    expect(extractHeadingIds(md, 2)).toEqual(['M3'])
  })

  it('真仓库两份目标文件分母都非空（S1 回归钉）', () => {
    for (const inv of INVENTORIES) {
      expect(extractHeadingIds(read(inv.target), inv.level).length).toBeGreaterThan(0)
    }
  })
})

describe('extractInventory', () => {
  it('取标记块里的 id 列表（斜杠分隔、去空白）', () => {
    const md = '<!-- rule-inventory:design-process -->\n> 清单：M6 / M11 / Phase 0\n<!-- /rule-inventory -->\n'
    expect(extractInventory(md, 'design-process')).toEqual({ found: true, ids: ['M6', 'M11', 'Phase 0'] })
  })

  it('只取最后一条 >-行，标签行不污染 id（标签含 1 个斜杠也不误取）', () => {
    const md = [
      '<!-- rule-inventory:x -->',
      '> **本文件规则清单** —— scoped / lazy 加载按本清单挑。',
      '>',
      '> M6 / M11 / Phase 0',
      '<!-- /rule-inventory -->',
    ].join('\n')
    expect(extractInventory(md, 'x')).toEqual({ found: true, ids: ['M6', 'M11', 'Phase 0'] })
  })

  it('剥掉 id 上的 markdown 强调与反引号', () => {
    const md = '<!-- rule-inventory:x -->\n> `M6` / **M11** / Phase 0\n<!-- /rule-inventory -->\n'
    expect(extractInventory(md, 'x').ids).toEqual(['M6', 'M11', 'Phase 0'])
  })

  it('没有标记块 → found:false（S2 fail closed，不是当成空清单通过）', () => {
    expect(extractInventory('# 无标记\n', 'design-process')).toEqual({ found: false, ids: [] })
  })

  it('markerId 不匹配的块不算（两份 inventory 不会互相顶替）', () => {
    const md = '<!-- rule-inventory:domain-tvu -->\n> M3 / M4 / M5\n<!-- /rule-inventory -->\n'
    expect(extractInventory(md, 'design-process').found).toBe(false)
  })
})

describe('findEnumerationMirrors — S4', () => {
  const ids = ['M22', 'M11', 'M14', 'Phase 0', 'Pre-Phase 0']

  it('同行既链接目标文件又列 ≥3 个 id → 命中', () => {
    const md = '| [`design-process.md`](./design-process.md) | 通用 process 规则（M22 / M11 / M14）| 所有任务 |'
    expect(findEnumerationMirrors(md, 'design-process.md', ids, 3)).toHaveLength(1)
  })

  it('纯指针（零 id）不命中', () => {
    const md = '| [`design-process.md`](./design-process.md) | 通用 process 规则（清单见该文件头）| 所有任务 |'
    expect(findEnumerationMirrors(md, 'design-process.md', ids, 3)).toEqual([])
  })

  it('1-2 个 id 的散文引用不命中（引用 ≠ 清单）', () => {
    const md = '`design-process.md` M22 / Phase 0 同理，无需新增。'
    expect(findEnumerationMirrors(md, 'design-process.md', ids, 3)).toEqual([])
  })

  it('列了 id 但没提目标文件 → 不命中（避免撞到别的文件自己的规则表）', () => {
    const md = '| Monitoring | M22 / M11 / M14 | 触发 |'
    expect(findEnumerationMirrors(md, 'design-process.md', ids, 3)).toEqual([])
  })

  it('整词匹配：M1 不命中 M11，M11 不命中 M11.1', () => {
    const md = '[`design-process.md`](./x) M11.1 / M11.2 / M11.3'
    expect(findEnumerationMirrors(md, 'design-process.md', ids, 3)).toEqual([])
  })

  it('Phase 0 不被 Pre-Phase 0 顺带计数（前缀边界）', () => {
    const md = '[`design-process.md`](./x) 只提 Pre-Phase 0'
    expect(findEnumerationMirrors(md, 'design-process.md', ids, 1)[0].ids).toEqual(['Pre-Phase 0'])
  })

  it('行内豁免标记放行', () => {
    const md =
      '| [`design-process.md`](./design-process.md) | M22 / M11 / M14 | <!-- rule-inventory-ok: 历史引用 2026-08-03 -->'
    expect(findEnumerationMirrors(md, 'design-process.md', ids, 3)).toEqual([])
  })

  it('不复用 de-mirror-ok 标记放行（两闸语义不同，混用会互相放行）', () => {
    const md = '| [`design-process.md`](./x) | M22 / M11 / M14 | <!-- de-mirror-ok: 别的闸的豁免 -->'
    expect(findEnumerationMirrors(md, 'design-process.md', ids, 3)).toHaveLength(1)
  })

  // —— §-前缀 = 引用不是清单（AGENTS §Mockup Write Gate step 2 的真实形态回归钉）——
  it('§ 前缀的 id 不计数：prescriptive「去读这几节」不是内容复述', () => {
    const md =
      '2. **Read [`design-process.md`](./docs/internal/design-process.md) § Pre-Phase 0 + § Stage 0.5 + § Phase 0 + § M22**'
    expect(findEnumerationMirrors(md, 'design-process.md', ids, 3)).toEqual([])
  })

  it('§ 与裸枚举混写时，只数裸的那些', () => {
    const md = '[`design-process.md`](./x) § Pre-Phase 0 之后，再看 M22 / M11 / M14'
    const hits = findEnumerationMirrors(md, 'design-process.md', ids, 3)
    expect(hits).toHaveLength(1)
    expect(hits[0].ids).toEqual(['M22', 'M11', 'M14'])
  })

  it('同一 id 既 § 又裸出现 → 算裸（不被 § 那次遮住）', () => {
    const md = '[`design-process.md`](./x) § M22，另见 M22 / M11 / M14'
    expect(findEnumerationMirrors(md, 'design-process.md', ids, 3)[0].ids).toContain('M22')
  })
})

describe('findSelfDeclaredChains — S6', () => {
  it('framing 关键词 + ≥3 个 .md 路径 → 命中一次（重叠 framing 行不重复报）', () => {
    const md = [
      '## 收到唤醒词后的标准动作',
      '1. 先阅读：',
      '   - `docs/a.md`',
      '   - `docs/b.md`',
      '   - `docs/c.md`',
    ].join('\n')
    const hits = findSelfDeclaredChains(md)
    expect(hits).toHaveLength(1)
    expect(hits[0].paths).toEqual(['a.md', 'b.md', 'c.md'])
  })

  it('同一文件被写三种写法只算一个路径（按 basename 归一）', () => {
    const md = [
      '## 起手必读',
      '- [`docs/STATUS.md`](./STATUS.md)',
      '- `docs/STATUS.md`',
      '- 见 ../docs/STATUS.md',
    ].join('\n')
    expect(findSelfDeclaredChains(md)).toEqual([])
  })

  it('散文指针不命中（路径必须在列表结构里）—— CLAUDE.md 那句「起手第一份必读 X，然后读 Y」形态', () => {
    const md = [
      '**起手第一份必读 [`docs/STATUS.md`](./docs/STATUS.md)，然后读 `AGENTS.md` 及其指向的链路。**',
      '> 项目契约真源是 AGENTS.md + docs/PROJECT_GOAL.md',
    ].join('\n')
    expect(findSelfDeclaredChains(md)).toEqual([])
  })

  it('无 framing 关键词的普通文件清单不命中', () => {
    const md = '## 产出物\n- `docs/a.md`\n- `docs/b.md`\n- `docs/c.md`\n'
    expect(findSelfDeclaredChains(md)).toEqual([])
  })

  it('模板占位（无真实 .md 路径）不命中 —— 这是正确行为，不是漏', () => {
    const md = '## 起手必读（按顺序）\n[新 session 起手要读的文件路径，按读取顺序排]\n'
    expect(findSelfDeclaredChains(md)).toEqual([])
  })

  it('围栏内的化石链路照样命中（S6 刻意不剥代码围栏）', () => {
    const md = ['```markdown', '## 起手必读', '- `docs/a.md`', '- `docs/b.md`', '- `docs/c.md`', '```'].join('\n')
    expect(findSelfDeclaredChains(md)).toHaveLength(1)
  })

  // —— 真实假阳回归钉：multi-session §反模式举例 + §实证 的路径被凑成「链路」——
  it('反模式讨论里的举例路径 + 实证记录路径不凑成链路（散落 ≠ 列表）', () => {
    const md = [
      '- ❌ Handoff 不列必读文件（新 session 凭直觉开工，重蹈覆辙）',
      '- ❌ Handoff 无验证锚点（无法判断新 session 是否真 onboard）',
      '- ❌ 必读文件路径用相对 cwd 不能确定的路径（如 `../foo.md`，新 session cwd 不同就找不到）',
      '- ❌ Handoff 写在 chat 里没落仓库（关 session 就丢）',
      '',
      '### 实证',
      '',
      '- `docs/handoffs/2026-05-11-next-session.md` —— 第一份按本协议产出的 handoff，结合',
      '  `retrospection/2026-05-11-library-key-confusion.md` 用',
    ].join('\n')
    expect(findSelfDeclaredChains(md)).toEqual([])
  })

  it('段内夹一行非 .md（如 .json）不截断 —— session-handoff 那张表的真实形态', () => {
    const md = [
      '## 收到唤醒词后的标准动作',
      '1. 先阅读：',
      '   - `docs/a.md`',
      '   - `docs/b.md`',
      '   - `docs/site-review-manifest.json`',
      '   - `docs/c.md`',
    ].join('\n')
    expect(findSelfDeclaredChains(md)[0].paths).toEqual(['a.md', 'b.md', 'c.md'])
  })

  it('连续列表项被非列表行打断 → 分成两段各自不够数', () => {
    const md = [
      '## 起手必读',
      '- `docs/a.md`',
      '- `docs/b.md`',
      '正文一句话打断。',
      '- `docs/c.md`',
    ].join('\n')
    expect(findSelfDeclaredChains(md)).toEqual([])
  })
})

describe('evaluate — 判据编码', () => {
  const okInv = { id: 'x', target: 't.md', liveIds: ['A', 'B'], inventory: { found: true, ids: ['A', 'B'] } }
  const base = { inventories: [okInv], mirrors: [], chains: [], exemptions: [] }

  it('全绿时零 failure', () => {
    expect(evaluate(base).failures).toEqual([])
  })

  it('S1: 活源零 id → fail closed', () => {
    const f = evaluate({ ...base, inventories: [{ ...okInv, liveIds: [] }] }).failures
    expect(f.map((x) => x.code)).toContain('S1')
  })

  it('S1 红时不再叠报 S3（避免一条根因刷屏）', () => {
    const f = evaluate({
      ...base,
      inventories: [{ ...okInv, liveIds: [], inventory: { found: true, ids: ['A', 'B'] } }],
    }).failures
    expect(f.map((x) => x.code)).toEqual(['S1'])
  })

  it('S2: 没有 inventory 标记块 → 红', () => {
    const f = evaluate({ ...base, inventories: [{ ...okInv, inventory: { found: false, ids: [] } }] }).failures
    expect(f.map((x) => x.code)).toContain('S2')
  })

  it('S3: 缺项与 phantom 各自点名', () => {
    const f = evaluate({
      ...base,
      inventories: [{ ...okInv, inventory: { found: true, ids: ['A', 'Z'] } }],
    }).failures
    const s3 = f.filter((x) => x.code === 'S3')
    expect(s3).toHaveLength(2)
    expect(s3.some((x) => /缺/.test(x.message) && /\bB\b/.test(x.message))).toBe(true)
    expect(s3.some((x) => /phantom/.test(x.message) && /\bZ\b/.test(x.message))).toBe(true)
  })

  it('S4: 镜像枚举 → 红并点名文件与行号', () => {
    const f = evaluate({
      ...base,
      mirrors: [{ file: 'AGENTS.md', line: 130, target: 'design-process.md', ids: ['M22', 'M11', 'M14'] }],
    }).failures
    const s4 = f.filter((x) => x.code === 'S4')
    expect(s4).toHaveLength(1)
    expect(s4[0].message).toMatch(/AGENTS\.md:130/)
  })

  it('S6: 自建起手链路 → 红并点名', () => {
    const f = evaluate({
      ...base,
      chains: [{ file: 'docs/session-handoff.md', line: 14, paths: ['a.md', 'b.md', 'c.md'] }],
    }).failures
    const s6 = f.filter((x) => x.code === 'S6')
    expect(s6).toHaveLength(1)
    expect(s6[0].message).toMatch(/session-handoff\.md:14/)
  })

  it('S5: 登记但零命中的豁免 → 红并要求删行', () => {
    const f = evaluate({
      ...base,
      exemptions: [{ file: 'gone.md', line: 1, reason: 'x 2026-08-03', hit: false }],
    }).failures
    const s5 = f.filter((x) => x.code === 'S5')
    expect(s5).toHaveLength(1)
    expect(s5[0].message).toMatch(/删/)
  })

  it('S5: 仍命中的豁免不报（shrink-only 只反向要求）', () => {
    const f = evaluate({
      ...base,
      exemptions: [{ file: 'live.md', line: 9, reason: 'x 2026-08-03', hit: true }],
    }).failures
    expect(f.filter((x) => x.code === 'S5')).toEqual([])
  })
})

describe('注册表自身的形状约束', () => {
  it('豁免表默认为空（表空着是终态，不是待办）', () => {
    expect(EXEMPTIONS).toEqual([])
  })

  it('两个起手链路真源不在 S6 扫描面里（它们就是真源，不该被自己判违规）', () => {
    for (const a of ONBOARDING_AUTHORITIES) {
      expect(CHAIN_SCAN_DOCS).not.toContain(a.file)
    }
  })

  it('阈值 ≥3 —— 1-2 个 id 是引用不是清单', () => {
    expect(MIN_IDS_FOR_MIRROR).toBe(3)
  })

  it('S4 扫描面含所有已知曾镜像的文档', () => {
    for (const f of [
      'AGENTS.md',
      'docs/internal/code-conventions.md',
      'docs/internal/mockup-conventions.md',
    ]) {
      expect(MIRROR_SCAN_DOCS).toContain(f)
    }
  })
})

describe('findPointerizedEnumerations — S7', () => {
  it('单行 ≥3 个裸 id → inline 命中（不要求提到目标文件名 —— 与 S4 的关键差异）', () => {
    const hits = findPointerizedEnumerations('本文件规则含 M900 / M901 / M902 三条。')
    expect(hits).toHaveLength(1)
    expect(hits[0]).toMatchObject({ kind: 'inline', ids: ['M900', 'M901', 'M902'] })
  })

  it('1-2 个 id 的引用不命中（引用 ≠ 清单，阈值与 S4 同源）', () => {
    expect(findPointerizedEnumerations('M31 ↔ R14 是 mirror pair 的例子。')).toEqual([])
  })

  it('§ 前缀 id 不计数（prescriptive 引用形态放行，同 S4 裁定）', () => {
    expect(findPointerizedEnumerations('读 §M900 + §M901 + §M902 三节。')).toEqual([])
  })

  it('US-N 场景号计入 token（US-7 漂移的病面）', () => {
    expect(findPointerizedEnumerations('场景有 US-1 / US-2 / US-3。')[0].ids).toEqual(['US-1', 'US-2', 'US-3'])
  })

  it('I 系列计入 token —— 2026-09-03 执行日实测的漏网形态（tvu-design-mockup/SKILL.md 自检行）', () => {
    const hits = findPointerizedEnumerations('把 `Integrity audit: I1=… / I2=… / I3=x 0 / I4=y 0` 贴进 handoff')
    expect(hits).toHaveLength(1)
    expect(hits[0].ids).toEqual(['I1', 'I2', 'I3', 'I4'])
  })

  it('连续 ≥3 行首列 ID 的表格 → table 命中（每行只 1 个 id 也拦 —— OVERVIEW §3.1 的真实形态）', () => {
    const md = ['| ID | Rule |', '|---|---|', '| **M900** | a |', '| M901 | b |', '| `M902` | c |'].join('\n')
    const hits = findPointerizedEnumerations(md)
    expect(hits).toHaveLength(1)
    expect(hits[0]).toMatchObject({ kind: 'table', line: 3, rows: 3 })
  })

  it('2 行 ID 打头的表不命中（阈值以下）', () => {
    expect(findPointerizedEnumerations('| M900 | a |\n| M901 | b |')).toEqual([])
  })

  it('首列非 ID（脚本路径 / 文案）的表不命中 —— §5.3 audit 脚本表的合法形态', () => {
    const md = ['| scripts/a.mjs | M900 audit |', '| scripts/b.mjs | M901 audit |', '| scripts/c.mjs | M902 audit |'].join('\n')
    expect(findPointerizedEnumerations(md)).toEqual([])
  })

  it('表格 run 被非 ID 行截断 → 各段不够数', () => {
    const md = ['| M900 | a |', '| M901 | b |', '| 说明 | x |', '| M902 | c |'].join('\n')
    expect(findPointerizedEnumerations(md)).toEqual([])
  })

  it('rule-inventory-ok 行内标记放行（与 S4 同一 escape；本计划存量清零未使用它）', () => {
    expect(findPointerizedEnumerations('M900 / M901 / M902 <!-- rule-inventory-ok: 假设的历史引用 -->')).toEqual([])
  })

  it('已知边界回归钉：ASCII 连字符与 .. 区间是 0 token（形态代理非语义判定）', () => {
    expect(findPointerizedEnumerations('迁出：M2-M9 / M11-M22；场景 US-1..6。')).toEqual([])
  })

  it('真仓库回归钉：受管指针化文档非空且 0 命中', () => {
    for (const rel of POINTERIZED_DOCS) {
      const text = read(rel)
      expect(text.length).toBeGreaterThan(0)
      expect(findPointerizedEnumerations(text)).toEqual([])
    }
  })
})

describe('evaluate — S7 编码', () => {
  const okInv = { id: 'x', target: 't.md', liveIds: ['A'], inventory: { found: true, ids: ['A'] } }
  const base7 = { inventories: [okInv], mirrors: [], chains: [], exemptions: [] }

  it('S7: 枚举回潮 → 红并点名 file:line', () => {
    const f = evaluate({
      ...base7,
      pointerized: [{ file: 'docs/internal/CONVENTIONS-OVERVIEW.md', missing: false, hits: [{ line: 49, kind: 'table', rows: 13, ids: ['M900'] }] }],
    }).failures
    const s7 = f.filter((x) => x.code === 'S7')
    expect(s7).toHaveLength(1)
    expect(s7[0].message).toMatch(/CONVENTIONS-OVERVIEW\.md:49/)
  })

  it('S7: 受管文件读不到 → fail closed（注册表 stale 也要红，不是 skip）', () => {
    const f = evaluate({ ...base7, pointerized: [{ file: 'gone.md', missing: true, hits: [] }] }).failures
    expect(f.map((x) => x.code)).toContain('S7')
  })

  it('S7 注册面含 4 份已指针化文档', () => {
    for (const f of [
      'docs/internal/CONVENTIONS-OVERVIEW.md',
      'skills/tvu-design-mockup/SKILL.md',
      'skills/tvu-design-code/SKILL.md',
      'skills/role-ux/SKILL.md',
    ]) {
      expect(POINTERIZED_DOCS).toContain(f)
    }
  })

  it('表格阈值 ≥3 行（与 MIN_IDS_FOR_MIRROR 同刻度：1-2 是引用/示例，3 起是清单）', () => {
    expect(MIN_ROWS_FOR_TABLE).toBe(3)
  })
})
