import { describe, it, expect } from 'vitest'
import {
  compileConfig,
  buildComponentIndex,
  labelOf,
  collectControls,
  collectStateFrameGroups,
  probeG1,
  probeG2,
  probeG3,
  runProbes,
} from '../scripts/audit-mockup-geometry-consistency.mjs'

/**
 * CANONICAL-F101 — audit-mockup-geometry-consistency.mjs probes.
 *
 * Every probe gets a 双向探针 pair (§M-DISCIPLINE.SCOPE 硬约束 5 ⑤):
 *   • a construction that MUST be reported (guards "judgement always returns 0")
 *   • a construction that MUST report zero (guards "judgement always fires")
 * One direction alone passes for the wrong reason — that is exactly how the
 * probeI2() double-collection bug survived multiple rounds.
 *
 * The reference numbers are the real V4-2333 Config-T measurements (hex box
 * 148 vs 120 across five 1228x1077 state frames; control right 898 vs button
 * right 1089 = Δ191) so the fixtures encode the failure this gate exists for.
 */

type N = {
  id: string
  name: string
  type: string
  componentId?: string
  visible?: boolean
  absoluteBoundingBox?: { x: number; y: number; width: number; height: number }
  children?: N[]
}

const bbox = (x: number, y: number, width: number, height: number) => ({ x, y, width, height })

const inst = (id: string, name: string, x: number, w: number, componentId?: string): N => ({
  id,
  name,
  type: 'INSTANCE',
  componentId,
  absoluteBoundingBox: bbox(x, 0, w, 36),
})

const frame = (id: string, name: string, children: N[], w = 1228, h = 1077): N => ({
  id,
  name,
  type: 'FRAME',
  absoluteBoundingBox: bbox(0, 0, w, h),
  children,
})

const cfg = compileConfig()

describe('classifier + scope', () => {
  it('groups instances by component-SET name, not by layer name', () => {
    const index = buildComponentIndex({
      components: { 'c:1': { name: 'Default', componentSetId: 's:1' }, 'c:2': { name: 'Disabled', componentSetId: 's:1' } },
      componentSets: { 's:1': { name: 'select box/filled' } },
    })
    expect(labelOf(inst('1:1', 'Resolution', 0, 480, 'c:1'), index)).toBe('select box/filled')
    expect(labelOf(inst('1:2', 'Frame Rate', 0, 480, 'c:2'), index)).toBe('select box/filled')
  })

  it('falls back to the layer name when the component is unknown', () => {
    expect(labelOf(inst('1:1', 'input box/filled hex', 0, 148), new Map())).toBe('input box/filled hex')
  })

  it('descends INTO instance subtrees (the case that kept slipping through)', () => {
    const host: N = {
      id: '9:0',
      name: 'Form Item',
      type: 'INSTANCE',
      absoluteBoundingBox: bbox(0, 0, 689, 36),
      children: [inst('9:1', 'select box/filled', 418, 480)],
    }
    const found = collectControls(frame('f:1', 'CT1', [host]), cfg)
    expect(found.map(c => c.id)).toContain('9:1')
  })

  it('excludes invisible controls and non-control layers', () => {
    const hidden = { ...inst('9:2', 'select box/filled', 0, 240), visible: false }
    const card: N = { id: '9:3', name: 'UX Delivery Card', type: 'FRAME', absoluteBoundingBox: bbox(0, 0, 1000, 3000) }
    const found = collectControls(frame('f:1', 'CT1', [hidden, card]), cfg)
    expect(found).toEqual([])
  })
})

describe('G1 — same component must share one width', () => {
  it('reports a component that has two widths (hex box 148 vs 120)', () => {
    const controls = collectControls(
      frame('f:1', 'CT1', [inst('1:1', 'input box/filled hex', 566, 148), inst('1:2', 'input box/filled hex', 566, 120)]),
      cfg,
    )
    const findings = probeG1(controls, cfg)
    expect(findings).toHaveLength(1)
    expect(findings[0].widths).toEqual([120, 148])
  })

  it('reports zero when every instance shares one width', () => {
    const controls = collectControls(
      frame('f:1', 'CT1', [inst('1:1', 'input box/filled hex', 566, 148), inst('1:2', 'input box/filled hex', 566, 148)]),
      cfg,
    )
    expect(probeG1(controls, cfg)).toEqual([])
  })

  it('tolerates sub-pixel differences but not real ones', () => {
    const near = collectControls(frame('f:1', 'CT1', [inst('1:1', 'select box/filled', 0, 480), inst('1:2', 'select box/filled', 0, 480.4)]), cfg)
    expect(probeG1(near, cfg)).toEqual([])
    const real = collectControls(frame('f:1', 'CT1', [inst('1:1', 'select box/filled', 0, 480), inst('1:2', 'select box/filled', 0, 240)]), cfg)
    expect(probeG1(real, cfg)).toHaveLength(1)
  })
})

describe('G2 — same-named control must keep one width across state frames', () => {
  // Renamed instances: they classify via the component SET, not the layer name —
  // the realistic Config-T case (layer renamed "Text Color hex", component is
  // `input box/filled`). Grouping across frames is still by layer name.
  const index = buildComponentIndex({
    components: { 'c:1': { name: 'Default', componentSetId: 's:1' } },
    componentSets: { 's:1': { name: 'input box/filled' } },
  })

  const stateFrames = (wA: number, wB: number): N => ({
    id: 'sec:1',
    name: 'Section',
    type: 'SECTION',
    absoluteBoundingBox: bbox(0, 0, 5000, 5000),
    children: [
      frame('f:1', 'CT1', [inst('1:1', 'Text Color hex', 566, wA, 'c:1')]),
      frame('f:2', 'CT2', [inst('2:1', 'Text Color hex', 566, wB, 'c:1')]),
    ],
  })

  it('reports a control that differs between two equal-size sibling frames', () => {
    const findings = probeG2(stateFrames(148, 120), cfg, index)
    expect(findings).toHaveLength(1)
    expect(findings[0].control).toBe('Text Color hex')
    expect(findings[0].widths).toEqual([120, 148])
    expect(findings[0].stateFrameSize).toBe('1228x1077')
  })

  it('reports zero when the control matches across frames', () => {
    expect(probeG2(stateFrames(148, 148), cfg, index)).toEqual([])
  })

  it('only pairs frames of the same size (different-size frames are not state variants)', () => {
    const mixed: N = {
      id: 'sec:1',
      name: 'Section',
      type: 'SECTION',
      absoluteBoundingBox: bbox(0, 0, 5000, 5000),
      children: [
        frame('f:1', 'CT1', [inst('1:1', 'Text Color hex', 566, 148, 'c:1')]),
        frame('f:2', 'PRD card', [inst('2:1', 'Text Color hex', 566, 120, 'c:1')], 1000, 1696),
      ],
    }
    const scan = collectStateFrameGroups(mixed)
    expect(scan.groups).toEqual([])
    expect(probeG2(mixed, cfg, index)).toEqual([])
    // ⛔「返回空」不够 —— 空了必须说清为什么空，否则「没检查」被输出成「检查过、结果是 0」。
    // 那两个 FRAME 各自 size 唯一各计 1；递归进 f:1/f:2 时其 children 是 INSTANCE，
    // 被 c.type === 'FRAME' 过滤掉 ⇒ 不再累加。
    expect(scan.rejected.singletonSize).toBe(2)
    expect([...scan.rejectedIds].sort()).toEqual(['f:1', 'f:2'])
  })

  // 同宽、仅高度不同 —— 「同一 feature 的状态帧因内容长短而高度漂移」的真实形态。
  // 整份测试套件此前对它零覆盖：上面 `mixed` 的 f:2 连宽都不同（1000 vs 1228），
  // ⇒ 那个 fixture 无法区分「按 WxH 分组」与「只按 width 分组」两种实现。
  // ⚠️ 本条只断言这一态**今天落在 rejected 里、因此可见**；
  // ⛔ 不断言它「应该」被 G2 检查 —— 「状态帧允不允许不等高」是设计立场，不在本次范围。
  const sameWidthDiffHeight: N = {
    id: 'sec:2',
    name: 'Section',
    type: 'SECTION',
    absoluteBoundingBox: bbox(0, 0, 5000, 5000),
    children: [
      frame('f:1', 'CT1', [inst('1:1', 'Text Color hex', 566, 148, 'c:1')]),
      frame('f:2', 'CT2', [inst('2:1', 'Text Color hex', 566, 120, 'c:1')], 1228, 900),
    ],
  }

  it('registers the same-width / different-height frames it silently dropped', () => {
    const scan = collectStateFrameGroups(sameWidthDiffHeight)
    expect(scan.groups).toEqual([])
    expect(probeG2(sameWidthDiffHeight, cfg, index)).toEqual([])
    expect(scan.rejected.singletonSize).toBe(2)
    expect([...scan.rejectedIds].sort()).toEqual(['f:1', 'f:2'])
  })

  it('registers NOTHING when the sibling frames group normally (must-not-hit)', () => {
    const scan = collectStateFrameGroups(stateFrames(148, 148))
    expect(scan.groups).toHaveLength(1)
    expect(scan.rejected.singletonSize).toBe(0)
    expect(scan.rejectedIds).toEqual([])
  })

  it('needs the component index: an unclassifiable layer name is invisible to the gate', () => {
    // 探针的反面 —— 证明「没报」可能只是没被分类到，不等于没问题。
    // 这正是 §M-DISCIPLINE.SCOPE 硬约束 5 要求 --probe 的原因。
    expect(probeG2(stateFrames(148, 120), cfg, new Map())).toEqual([])
  })
})

describe('G3 — action button right edge must equal control right edge', () => {
  it('reports the 191px misalignment', () => {
    const controls = collectControls(
      frame('f:1', 'CT1a', [inst('1:1', 'select box/filled', 418, 480), inst('1:2', 'Button/dark M', 985, 104)]),
      cfg,
    )
    const findings = probeG3(controls, cfg)
    expect(findings).toHaveLength(1)
    expect(findings[0].controlRight).toBe(898)
    expect(findings[0].buttonRight).toBe(1089)
    expect(findings[0].delta).toBe(191)
  })

  it('reports zero once the button group is right-aligned to the controls', () => {
    const controls = collectControls(
      frame('f:1', 'CT1a', [inst('1:1', 'select box/filled', 418, 480), inst('1:2', 'Button/dark M', 794, 104)]),
      cfg,
    )
    expect(probeG3(controls, cfg)).toEqual([])
  })

  it('skips frames that have controls but no buttons (nothing to align to)', () => {
    const controls = collectControls(frame('f:1', 'CT3', [inst('1:1', 'select box/filled', 418, 480)]), cfg)
    expect(probeG3(controls, cfg)).toEqual([])
  })
})

describe('allow list — named exceptions, not a bare count', () => {
  const twoWidths = (wA: number, wB: number) =>
    frame('f:1', 'CT1', [inst('1:1', 'input box/filled', 566, wA), inst('1:2', 'input box/filled', 418, wB)])

  const allowCfg = compileConfig({
    allow: [{ probe: 'G1', label: 'input box/filled', widths: [148, 480], reason: 'D38 ④ 分类统一：主控件 480 · hex 附属控件 148' }],
  })

  it('absorbs the exact declared exception and surfaces the reason', () => {
    const res = runProbes(twoWidths(148, 480), allowCfg)
    expect(res.findings.G1).toEqual([])
    expect(res.allowed).toHaveLength(1)
    expect(res.allowed[0].allowedBy).toContain('D38')
  })

  it('does NOT absorb a widened width set — a new width is a new finding', () => {
    const widened = frame('f:1', 'CT1', [
      inst('1:1', 'input box/filled', 566, 148),
      inst('1:2', 'input box/filled', 418, 480),
      inst('1:3', 'input box/filled', 418, 240),
    ])
    const res = runProbes(widened, allowCfg)
    expect(res.findings.G1).toHaveLength(1)
    expect(res.findings.G1[0].widths).toEqual([148, 240, 480])
    expect(res.allowed).toEqual([])
  })

  it('does not leak across probes or labels', () => {
    const other = frame('f:1', 'CT1', [inst('1:1', 'select box/filled', 566, 148), inst('1:2', 'select box/filled', 418, 480)])
    expect(runProbes(other, allowCfg).findings.G1).toHaveLength(1)
  })
})

describe('runProbes — scope probe surface', () => {
  it('exposes every classified node id so --probe can assert reachability', () => {
    const res = runProbes(frame('f:1', 'CT1', [inst('1:1', 'select box/filled', 418, 480), inst('1:2', 'Button/dark M', 794, 104)]), cfg)
    expect(res.inScopeIds.has('1:1')).toBe(true)
    expect(res.inScopeIds.has('1:2')).toBe(true)
    expect(res.scanned).toEqual({ controls: 1, buttons: 1 })
  })

  // G2 的判定对象是 state FRAME，而 inScopeIds 曾只收 control / row / divider 的 id
  // ⇒ 拿帧 id 当探针在**故障臂与正常臂上恒红**，零信息。恒红与恒绿一样，
  // 都不携带被测对象的信息（§M-DISCIPLINE.SCOPE 硬约束 5：Absent probe = the filters are wrong）。
  describe('state frames are probe-reachable on BOTH arms', () => {
    const index = buildComponentIndex({
      components: { 'c:1': { name: 'Default', componentSetId: 's:1' } },
      componentSets: { 's:1': { name: 'input box/filled' } },
    })
    const arm = (wA: number, wB: number, hB = 1077): N => ({
      id: 'sec:1',
      name: 'Section',
      type: 'SECTION',
      absoluteBoundingBox: bbox(0, 0, 5000, 5000),
      children: [
        frame('f:1', 'CT1', [inst('1:1', 'Text Color hex', 566, wA, 'c:1')]),
        frame('f:2', 'CT2', [inst('2:1', 'Text Color hex', 566, wB, 'c:1')], 1228, hB),
      ],
    })

    it('is a discriminator, not a constant: grouped frames reachable on the failing AND the passing arm', () => {
      const failing = runProbes(arm(148, 120), cfg, index)
      const passing = runProbes(arm(148, 148), cfg, index)
      // 先证两臂真的是两臂（否则「都能探到」可能只是根本没跑 G2）
      expect(failing.findings.G2).toHaveLength(1)
      expect(passing.findings.G2).toEqual([])
      // must-hit
      expect(failing.inScopeIds.has('f:1')).toBe(true)
      expect(failing.inScopeIds.has('f:2')).toBe(true)
      expect(passing.inScopeIds.has('f:1')).toBe(true)
      expect(passing.inScopeIds.has('f:2')).toBe(true)
      // must-not-hit（阴性对照）—— ⛔ 不是「把整棵树塞进 Set」：
      // 父 SECTION 从来不是 G2 的判定对象，不该因本次改动变成可探。
      expect(failing.inScopeIds.has('sec:1')).toBe(false)
      expect(failing.inScopeIds.has('no-such-node')).toBe(false)
    })

    it('a DROPPED state frame is reachable too — and is registered as excluded, not as absent', () => {
      // ⛔ 只把帧 id 塞进 Set 而不登记丢弃面 = 把恒红换成假绿，更糟。
      // 所以这一条同时断言两侧：可探（inScopeIds）+ 可解释（stateFramesRejected）。
      const res = runProbes(arm(148, 120, 900), cfg, index)
      expect(res.findings.G2).toEqual([]) // 这一态今天不被 G2 检查
      expect(res.inScopeIds.has('f:1')).toBe(true)
      expect(res.inScopeIds.has('f:2')).toBe(true)
      expect(res.stateFramesRejected).toEqual({ singletonSize: 2 })
    })

    it('the controls run reports a ZERO rejection count when every frame grouped (must-not-hit)', () => {
      const res = runProbes(arm(148, 148), cfg, index)
      expect(res.stateFramesRejected).toEqual({ singletonSize: 0 })
    })
  })
})
