// tests/stale-anchors.test.ts
// 过期锚点轻校验（audit-stale-anchors.mjs）纯函数单测。
import { describe, it, expect } from 'vitest'
import {
  githubSlug,
  collectHeadingSlugs,
  collectRuleRegistry,
  mergeRegistries,
  resolveRuleRef,
  iterNonFenceLines,
  extractAnchors,
} from '../scripts/audit-stale-anchors.mjs'

describe('githubSlug', () => {
  it('小写 + 去标点 + 空格逐个换连字符（双空格→双连字符）', () => {
    // "M23.0 — UX..."：去 "." 与 "—" 后 "m230  ux" 双空格 → "m230--ux"
    expect(githubSlug('M23.0 — UX 交付卡 canonical 结构（正向模板）')).toBe('m230--ux-交付卡-canonical-结构正向模板')
  })
  it('保留 CJK，去全角标点（：，（）—）', () => {
    expect(githubSlug('Q5 — FRAME vs GROUP：canvas 名称标签行为差异')).toBe('q5--frame-vs-groupcanvas-名称标签行为差异')
  })
  it('日期/描述后缀进入 slug（heading 改名即漂移）', () => {
    expect(githubSlug('M43 — Constrained-Cell Text Overflow: wrap vs ellipsis + hover-reveal — 2026-05-27 新增'))
      .toBe('m43--constrained-cell-text-overflow-wrap-vs-ellipsis--hover-reveal--2026-05-27-新增')
  })
  it('触发器中英混排 + 全角冒号', () => {
    expect(githubSlug('触发器 L：AI 不确定时必须 ask + 列举疑问点（不自作主张）'))
      .toBe('触发器-lai-不确定时必须-ask--列举疑问点不自作主张')
  })
})

describe('collectHeadingSlugs', () => {
  it('抽各级 heading 的 slug，跳过 fenced code', () => {
    const text = ['# Top', '```', '## Fake In Fence', '```', '## Real Heading'].join('\n')
    const slugs = collectHeadingSlugs(text)
    expect(slugs.has('top')).toBe(true)
    expect(slugs.has('real-heading')).toBe(true)
    expect(slugs.has('fake-in-fence')).toBe(false)
  })
  it('重名 heading 按 github-slugger 加 -N 后缀', () => {
    const text = ['## Dup', '## Dup'].join('\n')
    const slugs = collectHeadingSlugs(text)
    expect(slugs.has('dup')).toBe(true)
    expect(slugs.has('dup-1')).toBe(true)
  })
})

describe('collectRuleRegistry + resolveRuleRef', () => {
  const text = [
    '### M-COLOR — Color Token Discipline（umbrella）',
    '#### C1 — Brand color',
    '### M23 — Annotation',
    '#### M23.14 — 卡内排版',
    '### M-LIFECYCLE-CRUD — entity CRUD',
    '### 触发器 Q：规则优先于范本',
    '## R17 — Multi-page module reuse',
  ].join('\n')
  const reg = collectRuleRegistry(text)

  it('抽 rule id / trigger / family', () => {
    expect(reg.ids.has('M-COLOR')).toBe(true)
    expect(reg.ids.has('M23.14')).toBe(true)
    expect(reg.ids.has('R17')).toBe(true)
    expect(reg.triggers.has('Q')).toBe(true)
    expect(reg.families.has('M23')).toBe(true)
  })
  it('精确命中', () => {
    expect(resolveRuleRef({ type: 'id', id: 'M23.14' }, reg)).toBe(true)
    expect(resolveRuleRef({ type: 'trigger', letter: 'Q' }, reg)).toBe(true)
  })
  it('父族 fallback：§M23.15 在 M23 族仍在时 PASS（合并/历史叙述不误报）', () => {
    expect(resolveRuleRef({ type: 'id', id: 'M23.15' }, reg)).toBe(true)
  })
  it('整族消失才 FAIL', () => {
    expect(resolveRuleRef({ type: 'id', id: 'M99' }, reg)).toBe(false)
    expect(resolveRuleRef({ type: 'id', id: 'M99.1' }, reg)).toBe(false)
    expect(resolveRuleRef({ type: 'trigger', letter: 'Z' }, reg)).toBe(false)
  })
  it('M-/R- 前缀族 fallback：§M-LIFECYCLE → 定义为 M-LIFECYCLE-CRUD', () => {
    expect(resolveRuleRef({ type: 'id', id: 'M-LIFECYCLE' }, reg)).toBe(true)
    expect(resolveRuleRef({ type: 'id', id: 'M-NONEXIST' }, reg)).toBe(false)
  })
  it('C\\d 自身有 heading → 精确命中；无则 fallback umbrella', () => {
    expect(resolveRuleRef({ type: 'id', id: 'C1' }, reg)).toBe(true)
    // C9 无独立 heading，但 M-COLOR umbrella 在 → fallback PASS
    expect(resolveRuleRef({ type: 'id', id: 'C9' }, reg)).toBe(true)
  })

  it('mergeRegistries 跨文件并集', () => {
    const a = collectRuleRegistry('### M0 — x')
    const b = collectRuleRegistry('### 触发器 A：y')
    const m = mergeRegistries([a, b])
    expect(m.ids.has('M0')).toBe(true)
    expect(m.triggers.has('A')).toBe(true)
  })
})

describe('iterNonFenceLines', () => {
  it('跳过 ``` 围栏内容', () => {
    const lines = [...iterNonFenceLines(['a', '```', 'b', '```', 'c'].join('\n'))]
    expect(lines).toEqual(['a', 'c'])
  })
})

describe('extractAnchors', () => {
  it('markdown 链接抽 path + fragment', () => {
    const a = extractAnchors('见 [x](./foo.md#bar)')
    expect(a).toContainEqual(expect.objectContaining({ kind: 'path', raw: './foo.md' }))
    expect(a).toContainEqual(expect.objectContaining({ kind: 'fragment', raw: 'bar', file: './foo.md' }))
  })
  it('跳过 http / 伪链接（无扩展名无斜杠）', () => {
    const a = extractAnchors('[ext](https://x.com) [pseudo](memory)')
    expect(a.filter((x) => x.kind === 'path')).toHaveLength(0)
  })
  it('同文件 fragment（无 path）file 为空', () => {
    const a = extractAnchors('[y](#sec)')
    expect(a).toContainEqual(expect.objectContaining({ kind: 'fragment', raw: 'sec', file: '' }))
  })
  it('§ rule-ref + 触发器 形式（跳过 heading 定义行）', () => {
    const a = extractAnchors('详见 §M23.14 和 触发器 Q\n### 触发器 A：定义行不算引用')
    expect(a).toContainEqual(expect.objectContaining({ kind: 'rule-ref', raw: '§M23.14' }))
    expect(a).toContainEqual(expect.objectContaining({ kind: 'rule-ref', raw: '触发器 Q' }))
    expect(a.some((x) => x.raw === '触发器 A')).toBe(false)
  })
  it('script 引用从反引号取，反引号文件路径不当锚点', () => {
    const a = extractAnchors('跑 `pnpm audit:foo` 见 `meta-rules.md`')
    expect(a).toContainEqual(expect.objectContaining({ kind: 'script', raw: 'audit:foo' }))
    expect(a.some((x) => x.kind === 'path')).toBe(false)
  })
  it('env 用精确信号（process.env / ${}），ALLCAPS 反引号不误抓', () => {
    const a = extractAnchors('用 process.env.FIGMA_TOKEN 和 ${MY_VAR}；节点类型 `COMPONENT_SET` 不算')
    const envRaws = a.filter((x) => x.kind === 'env').map((x) => x.raw)
    expect(envRaws).toContain('FIGMA_TOKEN')
    expect(envRaws).toContain('MY_VAR')
    expect(envRaws).not.toContain('COMPONENT_SET')
  })
  it('fenced code block 整段跳过', () => {
    const a = extractAnchors('```\n见 [x](./should-skip.md) §M99\n```')
    expect(a).toHaveLength(0)
  })
})
