/**
 * INFRA-F123 ①：R13 §Acceptance 的 commit-message 半边。
 *
 * ⚠️ 判据真源是**规则原文**（`docs/internal/code-conventions.md` §R13），不是本文件也不是脚本。
 *    本文件的职责是钉住「脚本对规则原文的读法」不漂。
 */
import { describe, it, expect } from 'vitest'
import { readFileSync, readdirSync } from 'node:fs'
import { resolve } from 'node:path'
import {
  parseAffordanceKeywords,
  collectAffordanceIconIds,
  collectAffordanceIconNames,
  addedIconHits,
  checkMessage,
  evaluateCommit,
} from '../scripts/audit-commit-affordance-trace.mjs'

const REPO = resolve(__dirname, '..')

/** 一张形态与 R13 Step 2 相同的最小表（⛔ 不是从真文件抄的行 —— 那样测的就是真文件而非读法）。 */
const FAKE_TABLE = [
  '#### Step 2 · 视觉词汇扫描（双层）',
  '',
  '| Affordance | 必扫关键词 | typical 命中 |',
  '|---|---|---|',
  '| `directional-vertical` | `up`, `down`, `chevron`, `arrow` | `Arrow/Down` |',
  '| `dismissive` | `close`, `cancel`, `x` | `Edit/Close` |',
  '',
  '#### Step 3 · 下一节',
].join('\n')

const FAKE_DOC = ['## R13 — Affordance-Category Search Discipline', '', FAKE_TABLE, '', '## R14 — 别的规则'].join('\n')

describe('parseAffordanceKeywords —— 从 R13 Step 2 表现读同义词矩阵', () => {
  it('读出 category → 关键词（小写、按表内顺序）', () => {
    const m = parseAffordanceKeywords(FAKE_DOC)
    expect(m.get('directional-vertical')).toEqual(['up', 'down', 'chevron', 'arrow'])
    expect(m.get('dismissive')).toEqual(['close', 'cancel', 'x'])
    expect(m.size).toBe(2)
  })

  it('R13 段落不在 ⇒ throw（判定面塌了，不是「今天没关键词」）', () => {
    expect(() => parseAffordanceKeywords('# 什么都没有')).toThrow(/关键词矩阵/)
  })

  it('R13 在但 Step 2 段被改名 ⇒ throw', () => {
    expect(() => parseAffordanceKeywords(FAKE_DOC.replace('#### Step 2', '#### Step 2222'))).toThrow(
      /关键词矩阵/,
    )
  })

  it('只吃 R13 §Step 2 段内的表 —— 段外同形态的表不许串进来', () => {
    const polluted = FAKE_DOC + '\n\n## R99 — 别的表\n\n| X | Y | Z |\n|---|---|---|\n| `bogus-cat` | `zzz` | — |\n'
    const m = parseAffordanceKeywords(polluted)
    expect(m.has('bogus-cat')).toBe(false)
    expect(m.size).toBe(2)
  })

  it('真活源实测：10 个 category（⛔ 这条会随规则演化而变，变了要去看规则不是改测试）', () => {
    const md = readFileSync(resolve(REPO, 'docs/internal/code-conventions.md'), 'utf8')
    const m = parseAffordanceKeywords(md)
    expect(m.size).toBe(10)
    expect(m.get('sort')).toContain('sorting')
  })
})

describe('collectAffordanceIconIds —— 闭集 = 图标导出名 ∩ 关键词（整词）', () => {
  const kw = parseAffordanceKeywords(FAKE_DOC)

  it('整词命中才算', () => {
    const idx = 'export {\n  IconActionChevronLeft,\n  IconClose,\n  IconActionBill,\n  actionIcons,\n}'
    const ids = collectAffordanceIconIds(idx, kw)
    expect(ids.has('IconActionChevronLeft')).toBe(true)
    expect(ids.has('IconClose')).toBe(true)
    expect(ids.has('IconActionBill')).toBe(false)
    expect(ids.has('actionIcons')).toBe(false) // 不是 Icon* 标识符
  })

  it('数字后缀变体照收（Add2 是 Add 的图形变体，icon-naming R5）', () => {
    const kw2 = parseAffordanceKeywords(FAKE_DOC.replace('`close`, `cancel`, `x`', '`add`'))
    const ids = collectAffordanceIconIds('export {\n  IconActionAdd2,\n  IconActionAdd3,\n}', kw2)
    expect(ids.has('IconActionAdd2')).toBe(true)
    expect(ids.has('IconActionAdd3')).toBe(true)
  })

  it('⛔ 子串不算 —— 这条钉住 2026-08-24 实测的 59 个子串假阳', () => {
    // 关键词 `x`（dismissive）在子串法下会命中 Box / Excel / Text；整词法必须全部排除。
    const ids = collectAffordanceIconIds(
      'export {\n  IconOthersBox,\n  IconFileExcel,\n  IconActionText,\n  IconClose,\n}',
      kw,
    )
    expect([...ids]).toEqual(['IconClose'])
  })

  it('派生词不算（up ≠ update / back ≠ background）—— 已登记的诚实边界', () => {
    // 陪一个真命中，免得撞上「闭集为空 ⇒ throw」那条 fail-closed 出口 —— 本条测的是派生词，不是空集。
    const ids = collectAffordanceIconIds(
      'export {\n  IconStatusUpdate,\n  IconThemeBackground,\n  IconClose,\n}',
      kw,
    )
    expect([...ids]).toEqual(['IconClose'])
  })

  it('闭集为空 ⇒ throw（图标真源塌了 ≠ 今天没 affordance 图标）', () => {
    expect(() => collectAffordanceIconIds('export {\n  actionIcons,\n}', kw)).toThrow(/图标闭集/)
  })

  it('真活源实测：闭集非空、且远小于全库（形态断言，⛔ 不写死会烂的数）', () => {
    const md = readFileSync(resolve(REPO, 'docs/internal/code-conventions.md'), 'utf8')
    const idx = readFileSync(resolve(REPO, 'src/icons/index.ts'), 'utf8')
    const all = new Set([...idx.matchAll(/\bIcon[A-Z][A-Za-z0-9]*/g)].map((m) => m[0]))
    const ids = collectAffordanceIconIds(idx, parseAffordanceKeywords(md))
    expect(ids.size).toBeGreaterThan(50)
    expect(ids.size).toBeLessThan(all.size / 2)
    // R13 表自己列的 typical 命中，逐个必须在闭集里（阳性对照来自规则原文）
    for (const id of [
      'IconArrowDown',
      'IconArrowUp',
      'IconActionSorting',
      'IconClose',
      'IconDelete',
      'IconSelected',
      'IconStatusSuccess',
      'IconStatusWarning',
      'IconStatusError',
      'IconMediaRecordStop',
      'IconActionOpenLink',
      'IconPrevious',
      'IconNext',
      'IconActionDoubleUp',
    ]) {
      expect(ids.has(id), `R13 typical 命中 ${id} 不在闭集里`).toBe(true)
    }
  })
})

// ─────────────────────────────────────────────────────────────────────────────
// 触发面：diff 新增行里的图标命中
// ─────────────────────────────────────────────────────────────────────────────

const IDS = new Set(['IconClose', 'IconActionChevronLeft'])

/** 造一段最小 unified diff。 */
function diffAdd(file: string, added: string) {
  return [
    `diff --git a/${file} b/${file}`,
    `--- a/${file}`,
    `+++ b/${file}`,
    '@@ -1 +1,2 @@',
    ' context',
    `+${added}`,
  ].join('\n')
}

describe('addedIconHits —— 只看受管路径的新增行', () => {
  it('受管路径新增行命中即触发', () => {
    expect(addedIconHits(diffAdd('src/canonical/Foo.vue', 'import { IconClose } from "../icons"'), { iconIds: IDS })).toEqual([
      { file: 'src/canonical/Foo.vue', ref: 'id:IconClose' },
    ])
  })

  it('playground / react-pilot 也在受管面内', () => {
    expect(addedIconHits(diffAdd('playground/docs/Bar.vue', '<IconActionChevronLeft />'), { iconIds: IDS })).toHaveLength(1)
    expect(addedIconHits(diffAdd('react-pilot/src/Baz.tsx', '<IconClose/>'), { iconIds: IDS })).toHaveLength(1)
  })

  it('删除行不触发（只认 + 行）', () => {
    const d = [
      'diff --git a/src/a.vue b/src/a.vue',
      '--- a/src/a.vue',
      '+++ b/src/a.vue',
      '@@ -1,2 +1 @@',
      '-import { IconClose } from "../icons"',
    ].join('\n')
    expect(addedIconHits(d, { iconIds: IDS })).toEqual([])
  })

  it('⛔ src/icons/** 排除 —— 否则每次 sync:figma-library 恒红', () => {
    expect(addedIconHits(diffAdd('src/icons/generated/action.ts', 'export const IconClose = {}'), { iconIds: IDS })).toEqual([])
  })

  it('tests/ 与 *.test.* / *.spec.* 排除', () => {
    expect(addedIconHits(diffAdd('tests/Foo.test.ts', 'IconClose'), { iconIds: IDS })).toEqual([])
    expect(addedIconHits(diffAdd('src/components/Foo.spec.ts', 'IconClose'), { iconIds: IDS })).toEqual([])
  })

  it('非受管扩展名排除（.md 里提到图标名不是「写了个图标」）', () => {
    expect(addedIconHits(diffAdd('docs/internal/x.md', 'IconClose'), { iconIds: IDS })).toEqual([])
    expect(addedIconHits(diffAdd('src/tokens/variables.css', 'IconClose'), { iconIds: IDS })).toEqual([])
  })

  it('子串不算命中（IconCloseable ≠ IconClose）', () => {
    expect(addedIconHits(diffAdd('src/a.vue', 'IconCloseable'), { iconIds: IDS })).toEqual([])
  })

  it('同文件同图标只报一次，不同图标各报一次', () => {
    const d = diffAdd('src/a.vue', 'IconClose') + '\n+IconClose again\n+IconActionChevronLeft\n'
    const hits = addedIconHits(d, { iconIds: IDS })
    expect(hits).toEqual([
      { file: 'src/a.vue', ref: 'id:IconClose' },
      { file: 'src/a.vue', ref: 'id:IconActionChevronLeft' },
    ])
  })
})

// ─────────────────────────────────────────────────────────────────────────────
// 注册表名臂 —— 这是本仓真实的图标消费形态（`<Icon name="action/close" />`）
// 下面每条「不许命中」都对应 2026-08-24 在真树上量到的一类假阳，⛔ 别放宽
// ─────────────────────────────────────────────────────────────────────────────

const NAMES = new Set(['action/close', 'close', 'navigation/arrow-left', 'arrow-left', 'action/loading', 'loading'])

describe('collectAffordanceIconNames', () => {
  const kw = parseAffordanceKeywords(FAKE_DOC)

  it('从 generated 的 `name:` 里取，并同时收短别名', () => {
    const gen = ["  { name: 'action/close', ... }", "  { name: 'file/excel', ... }"].join('\n')
    const names = collectAffordanceIconNames([gen], kw)
    expect(names.has('action/close')).toBe(true)
    expect(names.has('close')).toBe(true) // 短别名
    expect(names.has('file/excel')).toBe(false) // `x` 是子串不是整词
  })

  it('闭集为空 ⇒ throw', () => {
    expect(() => collectAffordanceIconNames(["{ name: 'file/pdf' }"], kw)).toThrow(/图标名闭集/)
  })

  it('真活源实测：名字闭集非空且远小于全库', () => {
    const md = readFileSync(resolve(REPO, 'docs/internal/code-conventions.md'), 'utf8')
    const dir = resolve(REPO, 'src/icons/generated')
    // U3：`name:` 元数据搬到 generated/manifest/*.ts 后，扫描面须同时覆盖该子目录。
    const manifestDir = resolve(dir, 'manifest')
    const files = [
      ...readdirSync(dir).filter((f) => f.endsWith('.ts')),
      ...readdirSync(manifestDir)
        .filter((f) => f.endsWith('.ts'))
        .map((f) => `manifest/${f}`),
    ]
    const texts = files.map((f) => readFileSync(resolve(dir, f), 'utf8'))
    const names = collectAffordanceIconNames(texts, parseAffordanceKeywords(md))
    // 本仓真实在用的几个（Message/Notification 的关闭、Table 的排序、Select 的下拉箭头）
    for (const n of ['action/close', 'action/sorting', 'navigation/arrow-dropdown', 'action/clear']) {
      expect(names.has(n), `${n} 不在名字闭集里`).toBe(true)
    }
    expect(names.size).toBeGreaterThan(50)
  })
})

describe('addedIconHits —— 注册表名臂', () => {
  it('`<Icon name="action/close">` 触发', () => {
    expect(addedIconHits(diffAdd('src/components/Message/Message.vue', '<Icon name="action/close" />'), { iconNames: NAMES })).toEqual([
      { file: 'src/components/Message/Message.vue', ref: 'name:action/close' },
    ])
  })

  it('短别名形态触发（`name="arrow-left"`）', () => {
    expect(addedIconHits(diffAdd('playground/docs/DocsShell.vue', '<Icon name="arrow-left" :size="16" />'), { iconNames: NAMES })).toHaveLength(1)
  })

  it('`icon-name=` / `iconName=` 两种 prop 形态都触发', () => {
    expect(addedIconHits(diffAdd('playground/docs/pages/StepsPage.vue', '<StepItem icon-name="action/loading" />'), { iconNames: NAMES })).toHaveLength(1)
    expect(addedIconHits(diffAdd('react-pilot/src/demos/Steps.tsx', '<StepItem iconName="action/loading" />'), { iconNames: NAMES })).toHaveLength(1)
  })

  it('⛔ 对象属性 `name:` 不触发 —— API 文档表的事件名/prop 名（实测这类 ~45 行假阳）', () => {
    expect(addedIconHits(diffAdd('playground/docs/pages/MessagePage.vue', '{ name: "close", description: ["Emitted when…"] },'), { iconNames: NAMES })).toEqual([])
    expect(addedIconHits(diffAdd('playground/docs/pages/SwitchPage.vue', "    name: 'loading',"), { iconNames: NAMES })).toEqual([])
  })

  it('⛔ 裸 `icon=` 不触发 —— 那是 Button 的 variant 轴值不是注册表名', () => {
    expect(addedIconHits(diffAdd('playground/App.vue', '<Button canonical-icon="loading">Loading</Button>'), { iconNames: NAMES })).toEqual([])
  })

  it('⛔ descriptor 里的 `icon: \'loading\'` 不触发', () => {
    expect(addedIconHits(diffAdd('react-pilot/src/demos/descriptors/button.ts', "  { figmaVariantName: 'icon=loading, style=filling', props: { icon: 'loading' } },"), { iconNames: NAMES })).toEqual([])
  })

  it('⛔ tracked 构建产物排除 —— react-pilot/dist 内联了全部 643 个名字', () => {
    expect(addedIconHits(diffAdd("react-pilot/dist/assets/index-abc.js", "{\"action/close\":\"<svg/>\"}"), { iconNames: NAMES })).toEqual([])
  })

  it('⛔ 公共 barrel src/index.ts 排除 —— re-export 不是「用了个图标」', () => {
    expect(addedIconHits(diffAdd('src/index.ts', '  IconClose,'), { iconIds: IDS })).toEqual([])
  })

  it('整行注释不触发（`//` / `*` / `<!--` 起头）', () => {
    for (const c of ['// <Icon name="action/close" />', '  * 见 <Icon name="action/close">', '<!-- <Icon name="action/close" /> -->']) {
      expect(addedIconHits(diffAdd('src/a.vue', c), { iconNames: NAMES })).toEqual([])
    }
  })

  it('行尾 `//` 注释被剥掉，但字符串里的 `//` 不当注释', () => {
    expect(addedIconHits(diffAdd('src/a.vue', 'const x = 1 // <Icon name="action/close" />'), { iconNames: NAMES })).toEqual([])
    expect(addedIconHits(diffAdd('src/a.vue', 'const u = "https://x.dev"; //'), { iconNames: NAMES })).toEqual([])
    // 阳性对照：同一行形态下真调用照样命中（⛔ 防「剥注释顺手把代码也剥了」）
    expect(addedIconHits(diffAdd('src/a.vue', '<Icon name="action/close" /> // 关闭'), { iconNames: NAMES })).toHaveLength(1)
  })

  it('两条臂同时命中时各报一条', () => {
    const d = diffAdd('src/a.vue', '<Icon name="action/close" />') + '\n+import { IconClose } from "../icons"\n'
    expect(addedIconHits(d, { iconNames: NAMES, iconIds: IDS })).toEqual([
      { file: 'src/a.vue', ref: 'name:action/close' },
      { file: 'src/a.vue', ref: 'id:IconClose' },
    ])
  })
})

// ─────────────────────────────────────────────────────────────────────────────
// message 判据：与 handoff 半边逐字同一套短语
// ─────────────────────────────────────────────────────────────────────────────

const FULL_MSG = [
  'feat(select): add close affordance to chip',
  '',
  'Affordance: dismissive (intent: clear the selected chip)',
  'Vocabulary scan: [Edit/Close (dist), Edit/Delete (dist), Edit/Clear (dist)]',
  'Chosen: Edit/Close — same geometry, dismissive usage',
].join('\n')

describe('checkMessage', () => {
  it('3 行齐全 ⇒ 无缺项', () => {
    const r = checkMessage(FULL_MSG)
    expect(r.anchor).toBe(true)
    expect(r.missing).toEqual([])
  })

  it('只缺 Vocabulary scan ⇒ 单侧点名（⛔ 不是「全清零」那种注入）', () => {
    expect(checkMessage(FULL_MSG.replace(/Vocabulary scan:.*\n/, '')).missing).toEqual(['Vocabulary scan:'])
  })

  it('只缺 Chosen ⇒ 单侧点名', () => {
    expect(checkMessage(FULL_MSG.replace(/Chosen:.*/, '')).missing).toEqual(['Chosen:'])
  })

  it('连锚点都没有 ⇒ 3 行全缺', () => {
    const r = checkMessage('feat: something unrelated')
    expect(r.anchor).toBe(false)
    expect(r.missing).toEqual(['Affordance:', 'Vocabulary scan:', 'Chosen:'])
  })

  it('N/A 带理由 ⇒ 算齐（与 handoff 半边同一档）', () => {
    const r = checkMessage('chore: move file\n\nAffordance: N/A — 纯搬移，本 commit 未新增 affordance 元素')
    expect(r.na).toBe(true)
    expect(r.naRationale).toBe(true)
    expect(r.missing).toEqual([])
  })

  it('N/A 不带理由 ⇒ 不算（M42/CRUD 范式：「故意没有」必配一句理由）', () => {
    const r = checkMessage('chore: x\n\nAffordance: N/A')
    expect(r.na).toBe(true)
    expect(r.naRationale).toBe(false)
    expect(r.missing).toHaveLength(1)
  })
})

describe('evaluateCommit', () => {
  it('未触发 ⇒ 不要求 trace（⛔ 闸不许恒红）', () => {
    const r = evaluateCommit({ diffText: diffAdd('README.md', 'hello'), message: 'docs: x', iconIds: IDS })
    expect(r.triggered).toBe(false)
    expect(r.ok).toBe(true)
  })

  it('触发 + 齐全 ⇒ 过', () => {
    const r = evaluateCommit({ diffText: diffAdd('src/a.vue', 'IconClose'), message: FULL_MSG, iconIds: IDS })
    expect(r.triggered).toBe(true)
    expect(r.ok).toBe(true)
  })

  it('触发 + 缺行 ⇒ 不过，且理由里点名缺的那几行', () => {
    const r = evaluateCommit({ diffText: diffAdd('src/a.vue', 'IconClose'), message: 'feat: x', iconIds: IDS })
    expect(r.ok).toBe(false)
    expect(r.reasons.join(' ')).toMatch(/Affordance:/)
    expect(r.reasons.join(' ')).toMatch(/Vocabulary scan:/)
  })
})
