import { describe, it, expect } from 'vitest'
import {
  tokenizeName,
  familyOf,
  collectTopLevelInstances,
  findVariantAxisViolations,
  findThemeAxisMismatch,
  THEME_EXPECTED_FAMILY,
} from '../scripts/audit-mockup-variant-axis.mjs'

/**
 * §M-VARIANT-AXIS — 同一交付内直接放置的表单控件风格族唯一。
 *
 * 每条 must-not-hit 都配一条**同构**的 must-hit 阴性对照：单独一个方向会因为
 * 错误的理由通过 —— 「没报」既可能是判据对，也可能是**根本没扫**。
 * （本仓 `token-hardcode-ratio` 的两条 must-not-hit 曾因单行 `<style>` 整块被跳过
 *  而恒返回 0、恒绿零信息，抓住它的正是它们的阴性对照。）
 */

type N = {
  id: string
  name: string
  type: string
  componentId?: string
  visible?: boolean
  children?: N[]
}

const inst = (id: string, name: string, children: N[] = []): N => ({
  id,
  name,
  type: 'INSTANCE',
  children,
})
const frame = (id: string, children: N[] = []): N => ({ id, name: id, type: 'FRAME', children })

describe('tokenizeName / familyOf — 关键词不得落在更长标识符内部', () => {
  it('切词：驼峰 / 斜杠 / 连字符都切得开', () => {
    expect(tokenizeName('SelectBoxLine')).toEqual(['select', 'box', 'line'])
    expect(tokenizeName('select box/line')).toEqual(['select', 'box', 'line'])
    expect(tokenizeName('input-box_filled')).toEqual(['input', 'box', 'filled'])
  })

  // must-hit（阴性对照）：真的族标识必须被认出来 —— 否则下面那条 must-not-hit
  // 会因为「tokenizer 根本不工作」而通过。
  it('must-hit：真族标识被正确归族', () => {
    expect(familyOf('SelectBoxLine')).toBe('line')
    expect(familyOf('select box/line')).toBe('line')
    expect(familyOf('InputBoxFilled')).toBe('filled')
    expect(familyOf('select box/filled')).toBe('filled')
  })

  // must-not-hit：子串命中必须不发生（本仓量具已栽过四次的同型坑）。
  it('must-not-hit：outline / unfilled / underline 不得被归族', () => {
    expect(familyOf('Icon/Outline')).toBeNull()
    expect(familyOf('Badge Unfilled')).toBeNull()
    expect(familyOf('Text-Underline')).toBeNull()
    expect(familyOf('Airline Card')).toBeNull()
  })

  it('一个名字同时带两族 ⇒ 判不了，返回 null（⛔ 不猜）', () => {
    expect(familyOf('select box/line-to-filled')).toBeNull()
  })
})

describe('collectTopLevelInstances — 组件边界豁免（⛔ 不是名字白名单）', () => {
  it('嵌套 INSTANCE 被跳过，且**留下登记**（不是无声消失）', () => {
    const tree = frame('root', [
      inst('pag:1', 'Pagination', [inst('n:1', 'SelectBoxLine'), inst('n:2', 'InputBoxFilled')]),
      inst('top:1', 'SelectBoxLine'),
    ])
    const { instances, nestedSkipped, nestedSkippedIds } = collectTopLevelInstances(tree)
    expect(instances.map(i => i.id)).toEqual(['pag:1', 'top:1'])
    expect(nestedSkipped).toBe(2)
    expect(nestedSkippedIds).toEqual(['n:1', 'n:2'])
  })

  it('不可见节点整棵跳过', () => {
    const tree = frame('root', [{ ...inst('hidden:1', 'SelectBoxLine'), visible: false }])
    expect(collectTopLevelInstances(tree).instances).toEqual([])
  })
})

describe('findVariantAxisViolations — 双向探针', () => {
  // must-hit：真混用必须被抓到。
  it('must-hit：同一 frame 内 line + filled ⇒ violation', () => {
    const tree = frame('root', [inst('a', 'SelectBoxLine'), inst('b', 'InputBoxFilled')])
    const res = findVariantAxisViolations(tree)
    expect(res.violation).toBe(true)
    expect(res.checkedUnits).toBe(2)
    expect(res.families.line.map(n => n.nodeId)).toEqual(['a'])
    expect(res.families.filled.map(n => n.nodeId)).toEqual(['b'])
  })

  // must-not-hit：同族不得报。
  it('must-not-hit：全 line ⇒ 不报，但分母非 0（证明它真的扫了）', () => {
    const tree = frame('root', [inst('a', 'SelectBoxLine'), inst('b', 'InputBoxLine')])
    const res = findVariantAxisViolations(tree)
    expect(res.violation).toBe(false)
    expect(res.checkedUnits).toBe(2) // 🔴 分母 ⛔ 不得是 0 —— 那就成了「没扫也不报」
  })

  it('must-not-hit：全 filled ⇒ 不报，分母非 0', () => {
    const tree = frame('root', [inst('a', 'SelectBoxFilled'), inst('b', 'InputBoxFilled')])
    const res = findVariantAxisViolations(tree)
    expect(res.violation).toBe(false)
    expect(res.checkedUnits).toBe(2)
  })

  // 🔴 豁免的双向探针 —— 这一对是本文件最要紧的两条。
  it('must-not-hit：混用发生在复合组件【内部】⇒ 不报（口径 A 豁免）', () => {
    const tree = frame('root', [
      inst('pag:1', 'Pagination', [inst('n:1', 'SelectBoxLine'), inst('n:2', 'InputBoxFilled')]),
    ])
    const res = findVariantAxisViolations(tree)
    expect(res.violation).toBe(false)
    expect(res.nestedSkipped).toBe(2)
    expect(res.checkedUnits).toBe(0) // Pagination 自己不带族标识 ⇒ 不进分母
  })

  it('must-hit（同构阴性对照）：**同样两个控件**移出复合组件 ⇒ 立刻报', () => {
    // 与上一条唯一的差别就是「在不在 Pagination 里」——
    // 若豁免被写成「整棵树都跳过」，这一条会跟着变绿而暴露。
    const tree = frame('root', [inst('n:1', 'SelectBoxLine'), inst('n:2', 'InputBoxFilled')])
    const res = findVariantAxisViolations(tree)
    expect(res.violation).toBe(true)
    expect(res.nestedSkipped).toBe(0)
    expect(res.checkedUnits).toBe(2)
  })

  it('复合组件内部混用 + 外部同族 ⇒ 仍不报（豁免不污染外部判定）', () => {
    const tree = frame('root', [
      inst('pag:1', 'Pagination', [inst('n:1', 'SelectBoxLine'), inst('n:2', 'InputBoxFilled')]),
      inst('top:1', 'SelectBoxLine'),
      inst('top:2', 'InputBoxLine'),
    ])
    const res = findVariantAxisViolations(tree)
    expect(res.violation).toBe(false)
    expect(res.checkedUnits).toBe(2)
    expect(res.nestedSkipped).toBe(2)
  })

  it('components map 优先于节点名（实例被改过名也判得对）', () => {
    const tree = frame('root', [
      { ...inst('a', '重命名过的框'), componentId: 'c1' },
      { ...inst('b', '也重命名过'), componentId: 'c2' },
    ])
    const components = { c1: { name: 'select box/line' }, c2: { name: 'select box/filled' } }
    const res = findVariantAxisViolations(tree, components)
    expect(res.violation).toBe(true)
    expect(res.checkedUnits).toBe(2)
  })

  it('无表单控件 ⇒ 分母 0（规则模块据此走 unverified 档，⛔ 不打「没问题」）', () => {
    const tree = frame('root', [inst('a', 'Button/Primary'), inst('b', 'Icon/Outline')])
    const res = findVariantAxisViolations(tree)
    expect(res.violation).toBe(false)
    expect(res.checkedUnits).toBe(0)
  })
})

/**
 * ②「族由主题定」（[[INFRA-F146]] 接线，2026-09-10）。
 *
 * `detect` 在这里被注入 —— ⛔ **不是为了省事**，是为了把三个主题分支各钉一次而不依赖
 * 真 paint 数据；真检测器自己有 `tests/DetectFrameTheme.test.ts` 的 54 条钉子。
 * ⚠️ 注入式测试的**已知代价**（这是边界，不是 TODO）：它测不到
 * `detectFrameThemeForGate` 与本判据之间的接线本身 ⇒ 下面
 * 「钉住 arm 真的传到了检测器」那条就是补这一格的（must-hit on the wiring）。
 */
describe('findThemeAxisMismatch — ②「族由主题定」（report-only 判据）', () => {
  const lineOnly = (): N => frame('root', [inst('a', 'SelectBox/Line'), inst('b', 'InputBox/Line')])
  const filledOnly = (): N =>
    frame('root', [inst('a', 'SelectBox/Filled'), inst('b', 'InputBox/Filled')])
  const fixedDetect = (theme: string) => () => theme
  const ids = (ns: { nodeId: string }[]) => ns.map(n => n.nodeId)

  it('主题→族 的映射方向钉死（写反了整条判据会静默反号）', () => {
    // §M23.18 + §M-VARIANT-AXIS：深色 `:root` 用 filled、浅色 `[data-theme="light"]` 用 line。
    expect(THEME_EXPECTED_FAMILY).toEqual({ dark: 'filled', light: 'line' })
    // `unknown` **⛔ 不在这张表里** —— 它该落 unverified，⛔ 不该有「期望族」。
    expect((THEME_EXPECTED_FAMILY as Record<string, string>).unknown).toBeUndefined()
  })

  it('fail closed：缺 arm ⇒ 抛（⛔ 不隐式吃 GATE_ARM_DEFAULT）', () => {
    expect(() => findThemeAxisMismatch(lineOnly(), {}, { detect: fixedDetect('dark') })).toThrow(
      /arm 必填/,
    )
  })

  it('fail closed：arm 传了域外值 ⇒ 抛', () => {
    expect(() =>
      findThemeAxisMismatch(lineOnly(), {}, { arm: 'STRICT', detect: fixedDetect('dark') }),
    ).toThrow(/arm 必填/)
  })

  it('fail closed：缺 detect ⇒ 抛（判据真源刻意不 import 检测器）', () => {
    expect(() => findThemeAxisMismatch(lineOnly(), {}, { arm: 'strict' })).toThrow(/detect 必填/)
  })

  it('fail closed：检测器返回域外值 ⇒ 抛，⛔ 不静默归入 unknown', () => {
    // 「检测器换代」与「探不到铺底」是两件事，静默归一会让前者伪装成后者。
    expect(() =>
      findThemeAxisMismatch(lineOnly(), {}, { arm: 'strict', detect: fixedDetect('DARK') }),
    ).toThrow(/主题取值超出/)
  })

  it('must-hit：深色页整页 line ⇒ 全部不匹配（正是 ① 结构性抓不到的那个形态）', () => {
    const res = findThemeAxisMismatch(lineOnly(), {}, { arm: 'strict', detect: fixedDetect('dark') })
    expect(res.theme).toBe('dark')
    expect(res.expectedFamily).toBe('filled')
    expect(ids(res.mismatches)).toEqual(['a', 'b'])
    // 同一棵树上 ① 必须是绿的 ⇒ 两条判据**互不覆盖**（⛔ 别把一条的绿读成另一条的）
    expect(findVariantAxisViolations(lineOnly()).violation).toBe(false)
  })

  it('must-not-hit（同构阴性对照）：深色页整页 filled ⇒ 0 不匹配，且分母非 0', () => {
    const res = findThemeAxisMismatch(
      filledOnly(),
      {},
      { arm: 'strict', detect: fixedDetect('dark') },
    )
    expect(res.mismatches).toHaveLength(0)
    expect(res.checkedUnits).toBe(2) // ⛔ 「没报」不能是因为根本没扫
  })

  it('must-hit：浅色页整页 filled ⇒ 全部不匹配（方向相反的那一侧）', () => {
    const res = findThemeAxisMismatch(
      filledOnly(),
      {},
      { arm: 'strict', detect: fixedDetect('light') },
    )
    expect(res.expectedFamily).toBe('line')
    expect(ids(res.mismatches)).toEqual(['a', 'b'])
  })

  it('must-not-hit：浅色页整页 line ⇒ 0 不匹配，分母非 0', () => {
    const res = findThemeAxisMismatch(lineOnly(), {}, { arm: 'strict', detect: fixedDetect('light') })
    expect(res.mismatches).toHaveLength(0)
    expect(res.checkedUnits).toBe(2)
  })

  it('unknown ⇒ expectedFamily 为 null 且 0 不匹配 —— ⛔ 这不是「匹配」，是「没验」', () => {
    const res = findThemeAxisMismatch(
      lineOnly(),
      {},
      { arm: 'strict', detect: fixedDetect('unknown') },
    )
    expect(res.theme).toBe('unknown')
    expect(res.expectedFamily).toBeNull() // 规则模块据此把它送进 unverified 档
    expect(res.mismatches).toHaveLength(0)
    expect(res.checkedUnits).toBe(2) // 分母仍非 0 ⇒ 「0 不匹配」不是因为没扫
  })

  it('must-not-hit：错族发生在复合组件【内部】⇒ 不报（继承 ① 的组件边界豁免）', () => {
    const tree = frame('root', [
      inst('pg', 'Pagination', [inst('p1', 'InputBox/Line'), inst('p2', 'SelectBox/Line')]),
    ])
    const res = findThemeAxisMismatch(tree, {}, { arm: 'strict', detect: fixedDetect('dark') })
    expect(res.mismatches).toHaveLength(0)
    expect(res.checkedUnits).toBe(0)
    expect(res.nestedSkipped).toBe(2) // 被跳过的面**有登记**，⛔ 不是无声消失
  })

  it('must-hit（同构阴性对照）：同样两个控件移出复合组件 ⇒ 立刻报', () => {
    const tree = frame('root', [inst('p1', 'InputBox/Line'), inst('p2', 'SelectBox/Line')])
    const res = findThemeAxisMismatch(tree, {}, { arm: 'strict', detect: fixedDetect('dark') })
    expect(res.mismatches).toHaveLength(2)
  })

  it('① 与 ② 可以同时报 —— 深色页里 line + filled 混用', () => {
    const tree = frame('root', [inst('a', 'SelectBox/Line'), inst('b', 'InputBox/Filled')])
    expect(findVariantAxisViolations(tree).violation).toBe(true) // ① 阻塞档
    const res = findThemeAxisMismatch(tree, {}, { arm: 'strict', detect: fixedDetect('dark') })
    expect(ids(res.mismatches)).toEqual(['a']) // ② 只点 line 那个
  })

  it('钉住 arm 真的传到了检测器（⛔ 否则「显式传 arm」只是注释里的话）', () => {
    const seen: unknown[] = []
    findThemeAxisMismatch(
      lineOnly(),
      {},
      {
        arm: 'fallback',
        detect: (_n: object, opts: { arm?: string }) => {
          seen.push(opts?.arm)
          return 'dark'
        },
      },
    )
    expect(seen).toEqual(['fallback'])
  })

  it('components map 优先于节点名（实例被改过名也判得对）', () => {
    const tree = frame('root', [{ ...inst('a', '改过的名字'), componentId: 'C1' }])
    const res = findThemeAxisMismatch(
      tree,
      { C1: { name: 'SelectBox/Line' } },
      { arm: 'strict', detect: fixedDetect('dark') },
    )
    expect(res.mismatches).toHaveLength(1)
  })
})
