// tests/audit-page-recipes.test.ts
//
// figma-data/page-recipes.json 的结构 + 跨引用闸（INFRA-F68 PAT-01 / AIC-01）。
// 缘起 = APID-01 (2026-07-22) ship 首个页面配方时留下的 follow-up 原文：
//   「page-recipes.json 暂无专用 schema 校验闸（仅 JSON-parse），多实例前补
//     validator + pre-commit hook」
// 三层判据各自的 planted-drift 双向测（必须命中 + 不可误报），范式同
// tests/audit-product-code-r14-r19-r9.test.ts：
//   S1 validateShape          — schema 结构（additionalProperties:false 抓拼错字段名）
//   S2 validateComponentRefs  — slots[].component 非 null 时必须是 canonical 的公开导出名
//                              （不是文件名 —— Button 的文件叫 ButtonBridge.vue）
//   S3 validateLayoutTokens   — layoutTokens 的 var(--x) 必须在 variables.css 真定义过
//   S4 validateShippedTokens  — 同一批 token（含 S5 的断点）还必须真的出现在 dist/style.css
//                              里（「源码定义了」≠「消费方拿到了」；dist 取不到时 SKIPPED，
//                                不是静默通过 —— AIC-01 残余② / 2026-07-31）
//   S5 validateResponsivePositions — 响应式改位必须机读：position 不许写条件式散文
//                              （"left (or top)" 说了会变、没说在哪变），要表达就填
//                              responsive{stacksBelow:--bp-*, stackedPosition}
//                              （INFRA-F68 DG-1 后续 / 2026-08-03）
//
// ⚠️ 纪律：凡传窄分母（如 ['Table','Pagination']）的用例只喂**被测那一条** recipe
//    （[bad.recipes[0]]），不要喂整个 real.recipes —— 否则加第 N 个配方时这些用例会
//    因为「窄分母不覆盖新配方」而假失败。2026-07-30 加 entity-form-page 时真踩到 2 次。
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
  validateShape,
  validateComponentRefs,
  validateLayoutTokens,
  readPublicComponentNames,
  readCanonicalFileNames,
  readDefinedTokens,
  readShippedTokens,
  validateShippedTokens,
  validateResponsivePositions,
  extractResponsiveTokens,
} from '../scripts/audit-page-recipes.mjs'

const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const readJson = (rel: string) => JSON.parse(readFileSync(resolve(REPO_ROOT, rel), 'utf8'))

const schema = readJson('figma-data/page-recipes.schema.json')
const real = readJson('figma-data/page-recipes.json')

const clone = () => JSON.parse(JSON.stringify(real))

// ---------------------------------------------------------------------------
// S1 — schema 结构
// ---------------------------------------------------------------------------
describe('validateShape (S1)', () => {
  it('passes on the real file — must-not-fire', () => {
    const r = validateShape(real, schema)
    expect(r.errors).toEqual([])
    expect(r.ok).toBe(true)
  })

  it('fires on a mistyped recipe field name (this is the whole point of additionalProperties:false)', () => {
    const bad = clone()
    bad.recipes[0].behaviourOwnedByApp = true // 英式拼写 typo
    const r = validateShape(bad, schema)
    expect(r.ok).toBe(false)
    expect(r.errors.join(' ')).toMatch(/behaviourOwnedByApp/)
  })

  it('fires on a non-kebab recipe id', () => {
    const bad = clone()
    bad.recipes[0].id = 'dataTablePage'
    expect(validateShape(bad, schema).ok).toBe(false)
  })

  it('fires when a state drops the machine-readable driven_by field', () => {
    const bad = clone()
    delete bad.recipes[0].states[0].driven_by
    const r = validateShape(bad, schema)
    expect(r.ok).toBe(false)
    expect(r.errors.join(' ')).toMatch(/driven_by/)
  })

  it('fires when driven_by is prose instead of prop:/slot:/Component.field', () => {
    const bad = clone()
    bad.recipes[0].states[0].driven_by = '由 app 自己决定什么时候显示空态'
    const r = validateShape(bad, schema)
    expect(r.ok).toBe(false)
    expect(r.errors.join(' ')).toMatch(/driven_by|pattern/)
  })

  it('accepts all three legal driven_by forms — must-not-fire', () => {
    const ok = clone()
    ok.recipes[0].states[0].driven_by = 'slot:empty'
    ok.recipes[0].states[1].driven_by = 'prop:selectedKeys+rowKey'
    ok.recipes[0].states[2].driven_by = 'column.sortOrder'
    expect(validateShape(ok, schema).errors).toEqual([])
  })

  it('fires when layoutTokens holds a raw value instead of a var() reference', () => {
    const bad = clone()
    bad.recipes[0].layoutTokens.toolbarToTableGap = '12px'
    const r = validateShape(bad, schema)
    expect(r.ok).toBe(false)
    expect(r.errors.join(' ')).toMatch(/pattern|layoutTokens/)
  })

  it('fires when behaviorOwnedByApp is flipped to false without a schema change', () => {
    const bad = clone()
    bad.recipes[0].behaviorOwnedByApp = false
    expect(validateShape(bad, schema).ok).toBe(false)
  })

  it('does NOT fire on extra _meta keys — _meta is a docs area, not a data contract', () => {
    const ok = clone()
    ok._meta.some_future_note = 'anything'
    expect(validateShape(ok, schema).ok).toBe(true)
  })
})

// ---------------------------------------------------------------------------
// S2 — slots[].component 必须是真 canonical 组件
// ---------------------------------------------------------------------------
describe('validateComponentRefs (S2)', () => {
  it('passes on the real file against the real canonical exports — must-not-fire', () => {
    const r = validateComponentRefs(real.recipes, readPublicComponentNames())
    expect(r.errors).toEqual([])
    expect(r.ok).toBe(true)
  })

  it('fires when a slot names a component that is not a canonical export', () => {
    const bad = clone()
    bad.recipes[0].slots.find((s: { component: string | null }) => s.component).component = 'DataGrid'
    // 只喂被测那一条 —— 窄分母 ['Table','Pagination'] 不覆盖文件里的其它配方
    const r = validateComponentRefs([bad.recipes[0]], ['Table', 'Pagination'])
    expect(r.ok).toBe(false)
    expect(r.errors.join(' ')).toMatch(/DataGrid/)
  })

  it('accepts component: null as a pure layout placeholder — must-not-fire', () => {
    const ok = clone()
    ok.recipes[0].slots[0].component = null
    expect(validateComponentRefs([ok.recipes[0]], ['Table', 'Pagination']).ok).toBe(true)
  })

  it('names the offending recipe and slot so the failure is actionable', () => {
    const bad = clone()
    bad.recipes[0].slots[1].component = 'Nope'
    const msg = validateComponentRefs([bad.recipes[0]], ['Table', 'Pagination']).errors.join(' ')
    expect(msg).toMatch(/data-table-page/)
    expect(msg).toMatch(new RegExp(bad.recipes[0].slots[1].slot))
  })
})

// ---------------------------------------------------------------------------
// S3 — layoutTokens 的 var(--x) 必须在 variables.css 真定义过
// ---------------------------------------------------------------------------
describe('validateLayoutTokens (S3)', () => {
  it('passes on the real file against the real variables.css — must-not-fire', () => {
    const r = validateLayoutTokens(real.recipes, readDefinedTokens())
    expect(r.errors).toEqual([])
    expect(r.ok).toBe(true)
  })

  it('fires on an invented token', () => {
    const bad = clone()
    bad.recipes[0].layoutTokens.toolbarToTableGap = 'var(--sp-invented)'
    const r = validateLayoutTokens([bad.recipes[0]], ['--sp-m'])
    expect(r.ok).toBe(false)
    expect(r.errors.join(' ')).toMatch(/--sp-invented/)
  })

  it('unwraps var() before looking the token up (not comparing the raw string)', () => {
    const ok = clone()
    ok.recipes[0].layoutTokens = { onlyGap: 'var(--sp-l)' }
    expect(validateLayoutTokens([ok.recipes[0]], ['--sp-l']).ok).toBe(true)
    expect(validateLayoutTokens([ok.recipes[0]], ['var(--sp-l)']).ok).toBe(false)
  })

  it('readDefinedTokens picks up real spacing tokens from variables.css', () => {
    const defined = readDefinedTokens()
    expect(defined).toContain('--sp-m')
    expect(defined).toContain('--sp-l')
    expect(defined).not.toContain('--sp-invented')
  })
})

// ---------------------------------------------------------------------------
// S4 — 「源码定义了」≠「消费方拿到了」（AIC-01 残余② / 2026-07-31）
// ---------------------------------------------------------------------------

describe('validateShippedTokens (S4)', () => {
  it('reproduces the ship-token-css shape: S3 would pass, S4 must fire', () => {
    // 源码分母有它、dist 分母没有 —— 正是 v0.9.0 潜伏六个版本那类 bug 的形态
    const bad = clone()
    bad.recipes[0].layoutTokens = { probeGap: 'var(--sp-defined-but-not-shipped)' }
    expect(validateLayoutTokens([bad.recipes[0]], ['--sp-defined-but-not-shipped']).ok).toBe(true)
    const r = validateShippedTokens([bad.recipes[0]], ['--sp-m'])
    expect(r.ok).toBe(false)
    expect(r.errors.join(' ')).toMatch(/--sp-defined-but-not-shipped/)
    expect(r.errors.join(' ')).toMatch(/dist\/style\.css/)
  })

  it('must-not-fire when the token is in the shipped CSS', () => {
    const ok = clone()
    ok.recipes[0].layoutTokens = { probeGap: 'var(--sp-l)' }
    expect(validateShippedTokens([ok.recipes[0]], ['--sp-l']).ok).toBe(true)
  })

  it('SKIPS (does not silently pass) when the shipped CSS is unavailable', () => {
    const r = validateShippedTokens(real.recipes, null)
    expect(r.skipped).toBe(true)
    // ok:true 是为了不让 CLI 退非零，但 skipped 必须显式为 true —— 调用方靠它印警告。
    // 若哪天有人把 skipped 去掉只留 ok:true，这条会红。
    expect(r).toHaveProperty('skipped')
  })

  it('does not double-report malformed values (S3 owns that)', () => {
    const bad = clone()
    bad.recipes[0].layoutTokens = { probeGap: '8px' }
    expect(validateShippedTokens([bad.recipes[0]], ['--sp-m']).errors).toEqual([])
    expect(validateLayoutTokens([bad.recipes[0]], ['--sp-m']).ok).toBe(false)
  })

  it('readShippedTokens matches minified CSS too (dist has no line-leading tokens)', () => {
    const shipped = readShippedTokens()
    if (shipped === null) return // dist 未构建的环境：这条无从判，由 CLI 的 SKIPPED 分支覆盖
    expect(shipped).toContain('--sp-m')
    expect(shipped.length).toBeGreaterThan(100)
  })

  it('the real recipes pass S4 whenever dist is built (must-not-fire)', () => {
    const shipped = readShippedTokens()
    if (shipped === null) return
    expect(validateShippedTokens(real.recipes, shipped).errors).toEqual([])
  })
})

// ---------------------------------------------------------------------------
// S5 — 响应式改位必须机读（INFRA-F68 DG-1 后续 / 2026-08-03）
//
// 缘起是活源里真实存在过的两处：master-detail-page 的 master/detail 槽写着
// "left (or top)" / "right (or below)" —— 说了位置会变、没说在哪个宽度变。DG-1 落
// --bp-* 之前配方里根本没有可引用的断点名，只能写散文；有了之后这就是可修的缺陷。
// ---------------------------------------------------------------------------

/** 造一个只含被测 slot 的最小 recipe —— 窄分母纪律：不喂 real.recipes（见文件头 ⚠️）。 */
const probeRecipe = (slot: Record<string, unknown>) => ({
  id: 'probe',
  slots: [{ slot: 'master', required: true, component: null, description: 'probe slot', ...slot }],
  layoutTokens: { g: 'var(--sp-m)' },
})

describe('validateResponsivePositions (S5)', () => {
  it('passes on the real file against the real variables.css — must-not-fire', () => {
    const r = validateResponsivePositions(real.recipes, readDefinedTokens())
    expect(r.errors).toEqual([])
    expect(r.ok).toBe(true)
  })

  it('fires on the exact prose that used to live in master-detail-page — 防回归', () => {
    for (const prose of ['left (or top)', 'right (or below)']) {
      const r = validateResponsivePositions([probeRecipe({ position: prose })], ['--bp-lg'])
      expect(r.ok).toBe(false)
      expect(r.errors.join(' ')).toMatch(/条件式散文/)
    }
  })

  it('fires on other conditional phrasings too（黑名单覆盖的几种）', () => {
    for (const prose of ['left when wide', 'right, or below on narrow', '左侧（窄屏改到顶部）']) {
      expect(validateResponsivePositions([probeRecipe({ position: prose })], ['--bp-lg']).ok).toBe(false)
    }
  })

  it('does NOT fire on the real single-position values already in the file — must-not-fire', () => {
    for (const fine of ['above table', 'core', 'below table', 'inside form, repeated', 'top', 'left', 'right']) {
      expect(validateResponsivePositions([probeRecipe({ position: fine })], ['--bp-lg']).ok).toBe(true)
    }
  })

  it('checks stackedPosition for prose as well — 换个字段写条件式一样红', () => {
    const r = validateResponsivePositions(
      [probeRecipe({ position: 'left', responsive: { stacksBelow: '--bp-lg', stackedPosition: 'top or inline' } })],
      ['--bp-lg'],
    )
    expect(r.ok).toBe(false)
    expect(r.errors.join(' ')).toMatch(/stackedPosition/)
  })

  it('fires on a breakpoint token that passes the schema pattern but does not exist', () => {
    // `--bp-mdd` 形状合法（^--bp-），所以 schema 放行 —— 只有查活源分母才抓得到
    const bad = probeRecipe({ position: 'left', responsive: { stacksBelow: '--bp-mdd', stackedPosition: 'top' } })
    expect(validateShape({ ...real, recipes: [{ ...real.recipes[2], slots: bad.slots }] }, schema).ok).toBe(true)
    const r = validateResponsivePositions([bad], readDefinedTokens())
    expect(r.ok).toBe(false)
    expect(r.errors.join(' ')).toMatch(/--bp-mdd/)
  })

  it('fires on a token that IS defined but is not a breakpoint（断点只能引断点）', () => {
    const bad = probeRecipe({ position: 'left', responsive: { stacksBelow: '--sp-m', stackedPosition: 'top' } })
    const r = validateResponsivePositions([bad], ['--sp-m'])
    expect(r.ok).toBe(false)
    expect(r.errors.join(' ')).toMatch(/不属 --bp-\* 断点组/)
  })

  it('a slot with no responsive block is fine — 不写 = 各宽度位置不变', () => {
    expect(validateResponsivePositions([probeRecipe({ position: 'core' })], []).ok).toBe(true)
  })

  it('names the offending recipe and slot so the failure is actionable', () => {
    const msg = validateResponsivePositions([probeRecipe({ position: 'left (or top)' })], ['--bp-lg']).errors.join(' ')
    expect(msg).toMatch(/probe/)
    expect(msg).toMatch(/master/)
  })
})

describe('responsive 的 schema 形态 (S1) 与随包发 (S4)', () => {
  it('S1 fires when responsive drops stackedPosition — 半条契约不算数', () => {
    const bad = clone()
    bad.recipes[2].slots[1].responsive = { stacksBelow: '--bp-lg' }
    expect(validateShape(bad, schema).ok).toBe(false)
  })

  it('S1 fires on var(--bp-lg) — 断点刻意收裸 token 名（@media 吃不了 var()）', () => {
    const bad = clone()
    bad.recipes[2].slots[1].responsive = { stacksBelow: 'var(--bp-lg)', stackedPosition: 'top' }
    const r = validateShape(bad, schema)
    expect(r.ok).toBe(false)
    expect(r.errors.join(' ')).toMatch(/pattern|stacksBelow/)
  })

  it('S1 fires on a stray key inside responsive (additionalProperties:false)', () => {
    const bad = clone()
    bad.recipes[2].slots[1].responsive = { stacksBelow: '--bp-lg', stackedPosition: 'top', note: 'x' }
    expect(validateShape(bad, schema).ok).toBe(false)
  })

  it('S4 covers responsive tokens too — 源码有、dist 没有必须红', () => {
    const bad = probeRecipe({
      position: 'left',
      responsive: { stacksBelow: '--bp-lg', stackedPosition: 'top' },
    })
    const r = validateShippedTokens([bad], ['--sp-m'])
    expect(r.ok).toBe(false)
    expect(r.errors.join(' ')).toMatch(/--bp-lg/)
    expect(r.errors.join(' ')).toMatch(/dist\/style\.css/)
  })

  it('extractResponsiveTokens 只产出真声明了 responsive 的 slot', () => {
    const found = extractResponsiveTokens(real.recipes)
    expect(found.length).toBeGreaterThan(0)
    for (const e of found) expect(e.token).toMatch(/^--bp-/)
    // 没声明的 slot 不该混进来：条目数 ≤ 总 slot 数，且每条都能对回一个真 slot
    const totalSlots = real.recipes.reduce((n: number, r: { slots: unknown[] }) => n + r.slots.length, 0)
    expect(found.length).toBeLessThanOrEqual(totalSlots)
  })
})

// ---------------------------------------------------------------------------
// S2 的分母口径 — 导出名 vs 文件名的分叉（2026-07-30 写 entity-form-page 时撞到的真缺陷）
// ---------------------------------------------------------------------------
describe('readPublicComponentNames vs readCanonicalFileNames (S2 分母口径)', () => {
  it('两者确实分叉 —— 所以「随便挑一个」不是无害选择', () => {
    const exports_ = readPublicComponentNames()
    const files = readCanonicalFileNames()
    expect(exports_).toContain('Button')
    expect(files).not.toContain('Button')
    expect(files).toContain('ButtonBridge')
    expect(exports_).not.toContain('ButtonBridge')
  })

  it('并集口径生效：Chart 只在 canonical barrel、PillCounter/Logo 只在包入口，三者都该在', () => {
    const names = readPublicComponentNames()
    expect(names).toContain('Chart')        // canonical barrel 独有（经 ./chart 子路径发）
    expect(names).toContain('PillCounter')  // 包入口独有
    expect(names).toContain('Logo')         // 包入口独有
  })

  it('两个 barrel 都没导出的名字必须被拒 —— 这是正确信号不是误报', () => {
    const names = readPublicComponentNames()
    for (const notExported of ['UserMenu', 'MenuList', 'InputBoxBase', 'SelectBoxBase']) {
      expect(names).not.toContain(notExported)
    }
  })

  it('用导出名当分母时 component:"Button" 合法 — must-not-fire', () => {
    const recipe = {
      id: 'probe',
      slots: [{ slot: 'actions', required: true, component: 'Button', position: 'x', description: 'probe recipe' }],
      layoutTokens: { g: 'var(--sp-m)' },
    }
    expect(validateComponentRefs([recipe], readPublicComponentNames()).ok).toBe(true)
  })

  it('用文件名当分母会红掉合法的 "Button" —— 这正是首版 S2 的 bug，此断言防回归', () => {
    const recipe = {
      id: 'probe',
      slots: [{ slot: 'actions', required: true, component: 'Button', position: 'x', description: 'probe recipe' }],
      layoutTokens: { g: 'var(--sp-m)' },
    }
    expect(validateComponentRefs([recipe], readCanonicalFileNames()).ok).toBe(false)
  })

  it('未被公开导出的 "ButtonBridge" 必须被拒 —— 配方不能把 AI 引向非公开 API', () => {
    const recipe = {
      id: 'probe',
      slots: [{ slot: 'actions', required: true, component: 'ButtonBridge', position: 'x', description: 'probe recipe' }],
      layoutTokens: { g: 'var(--sp-m)' },
    }
    expect(validateComponentRefs([recipe], readPublicComponentNames()).ok).toBe(false)
  })
})
