import { describe, it, expect } from 'vitest'
import {
  buildGateOutput,
  emitGateOutput,
  parseGateOutputTail,
  validateGateOutput,
  crossCheckExitVsTotals,
  CROSS_CHECK_VERDICTS,
  CROSS_CHECK_ANOMALIES,
  CONTRACT_REQUIRED_TOP_KEYS,
  CONTRACT_REQUIRED_TOTALS_KEYS,
} from '../scripts/lib/gate-output-contract.mjs'

/**
 * stdout 输出契约 v1 的判据面（第 7b 步）。
 *
 * ⚠️ 这份文件只覆盖**判据逻辑**（I 类面）。契约的**接线** —— 收割器的选择面、退出码顺序、
 * 棘轮表的 stale 判定 —— 由 `tests/gate-output-harvest.test.ts` 的整脚本 fixture-root
 * harness 覆盖。两份缺一不可：本仓 2026-08-24 实测过「20 条单测全绿、摘掉 main() 的接线
 * 全仓 1993 测试无一转红」（`meta-rules` 触发器 R §违反检测 第 4 条）。
 */

const TS = '2026-08-24T00:00:00.000Z'

describe('buildGateOutput — 契约必填项 fail closed', () => {
  it('产出四个必填顶层键 + 两个必填 totals 子键', () => {
    const out = buildGateOutput({ auditId: 'x', findings: [], checkedUnits: 7, checkedAt: TS })
    for (const k of CONTRACT_REQUIRED_TOP_KEYS) expect(out).toHaveProperty(k)
    for (const k of CONTRACT_REQUIRED_TOTALS_KEYS) expect(out.totals).toHaveProperty(k)
    expect(out.totals.findings).toBe(0)
    expect(out.totals.checkedUnits).toBe(7)
  })

  it('`totals.findings` 由 findings.length 派生 —— 传不进去一个对不上的数', () => {
    const out = buildGateOutput({ auditId: 'x', findings: [1, 2, 3], checkedUnits: 9, totals: { findings: 999 } })
    expect(out.totals.findings).toBe(3)
  })

  it('⛔ 漏传 checkedUnits 当场抛（不许静默默认成 0 —— 那会把「忘了传」伪装成「真的扫了 0 个」）', () => {
    expect(() => buildGateOutput({ auditId: 'x', findings: [] })).toThrow(/checkedUnits/)
    expect(() => buildGateOutput({ auditId: 'x', findings: [], checkedUnits: null })).toThrow(/checkedUnits/)
    expect(() => buildGateOutput({ auditId: 'x', findings: [], checkedUnits: -1 })).toThrow(/checkedUnits/)
    expect(() => buildGateOutput({ auditId: 'x', findings: [], checkedUnits: 1.5 })).toThrow(/checkedUnits/)
  })

  it('⛔ 漏传 auditId / findings 当场抛（收割器靠 auditId 自证是哪条闸）', () => {
    expect(() => buildGateOutput({ findings: [], checkedUnits: 1 })).toThrow(/auditId/)
    expect(() => buildGateOutput({ auditId: '  ', findings: [], checkedUnits: 1 })).toThrow(/auditId/)
    expect(() => buildGateOutput({ auditId: 'x', checkedUnits: 1 })).toThrow(/findings/)
    expect(() => buildGateOutput({ auditId: 'x', findings: null, checkedUnits: 1 })).toThrow(/findings/)
  })

  it('`checkedUnits: 0` 是**合法**的（它就是要让空分母显形），不是被拒的输入', () => {
    const out = buildGateOutput({ auditId: 'x', findings: [], checkedUnits: 0 })
    expect(out.totals.checkedUnits).toBe(0)
  })

  it('extra / totals 是 additive —— 闸自有的键不被覆盖，契约两键不被闸覆盖', () => {
    const out = buildGateOutput({
      auditId: 'x', findings: [], checkedUnits: 5,
      totals: { filesWithFindings: 2, checkedUnits: 99 },
      extra: { perFile: { a: 1 }, summary: { s: 1 } },
    })
    expect(out.totals.filesWithFindings).toBe(2)
    expect(out.totals.checkedUnits).toBe(5) // 契约值胜出
    expect(out.perFile).toEqual({ a: 1 })
    expect(out.summary).toEqual({ s: 1 })
  })
})

describe('emitGateOutput — 发射形态', () => {
  it('写一块 pretty JSON + 尾部换行（可被 parseGateOutputTail 原路解回）', () => {
    let buf = ''
    const payload = buildGateOutput({ auditId: 'x', findings: [{ a: 1 }], checkedUnits: 3, checkedAt: TS })
    emitGateOutput(payload, { write: (s) => { buf += s } })
    expect(buf.endsWith('}\n')).toBe(true)
    expect(parseGateOutputTail(buf)!.value).toEqual(payload)
  })
})

describe('parseGateOutputTail — 从尾部解析（⛔ 不看第一个字符是不是 `{`）', () => {
  const payload = buildGateOutput({ auditId: 'g', findings: [], checkedUnits: 4, checkedAt: TS })
  const json = JSON.stringify(payload, null, 2) + '\n'

  it('散文前缀 + 末尾 JSON —— 这正是旧的 body.startsWith("{") 判据漏掉的形态', () => {
    const s = `saved → figma-data/x.json\n  → docs/y.json (skipped)\n${json}`
    const r = parseGateOutputTail(s)
    expect(r).not.toBeNull()
    expect(r!.value.auditId).toBe('g')
    expect(r!.trailing).toBe('')
  })

  it('阴性对照：只有散文、没有 JSON → null（⛔ 不许凑一个空对象出来）', () => {
    expect(parseGateOutputTail('OK M-INTEGRITY audit pass\n')).toBeNull()
    expect(parseGateOutputTail('')).toBeNull()
    expect(parseGateOutputTail(null)).toBeNull()
  })

  it('多个 JSON 块时取**最后**一个（契约规定末尾那块才是它的读数）', () => {
    const first = JSON.stringify({ auditId: 'stale', totals: {}, findings: [], checkedAt: TS }, null, 2)
    const r = parseGateOutputTail(`${first}\nsome prose\n${json}`)
    expect(r!.value.auditId).toBe('g')
  })

  it('finding 文本里带 `}` / 转义引号也能配平（不是靠数字符）', () => {
    const p = buildGateOutput({
      auditId: 'g', checkedUnits: 1, checkedAt: TS,
      findings: [{ msg: 'weird } text "quoted" and \\ backslash {nested}' }],
    })
    const s = 'prose\n' + JSON.stringify(p, null, 2) + '\n'
    expect(parseGateOutputTail(s)!.value.findings[0].msg).toBe(p.findings[0].msg)
  })

  it('JSON 后面还有散文 → 照样解出来，但 `trailing` 非空（= 违反发射规则 1，交调用方判）', () => {
    const r = parseGateOutputTail(`${json}\n❌ blocked: 3 findings\n`)
    expect(r!.value.auditId).toBe('g')
    expect(r!.trailing).toBe('❌ blocked: 3 findings')
  })

  it('顶层是数组的 JSON 不算契约块（契约要求对象）', () => {
    expect(parseGateOutputTail('[\n  1,\n  2\n]\n')).toBeNull()
  })
})

describe('validateGateOutput', () => {
  it('完整对象通过', () => {
    expect(validateGateOutput(buildGateOutput({ auditId: 'x', findings: [], checkedUnits: 1 })).ok).toBe(true)
  })

  it('逐项缺失各报一条（⛔ 不是「有一个错就笼统说不合格」）', () => {
    expect(validateGateOutput({}).errors).toEqual(
      expect.arrayContaining(['缺顶层键 `auditId`', '缺顶层键 `checkedAt`', '缺顶层键 `totals`', '缺顶层键 `findings`']),
    )
    expect(validateGateOutput({ auditId: 'x', checkedAt: TS, findings: [], totals: { findings: 0 } }).errors)
      .toContain('缺 `totals.checkedUnits`')
    expect(validateGateOutput({ auditId: 'x', checkedAt: TS, findings: [], totals: { findings: 0, checkedUnits: -3 } }).errors)
      .toContain('`totals.checkedUnits` 不是非负整数')
  })

  it('抓 `totals.findings` 与 `findings.length` 自相矛盾（两处读数互相验）', () => {
    const bad = { auditId: 'x', checkedAt: TS, findings: [1, 2], totals: { findings: 5, checkedUnits: 9 } }
    expect(validateGateOutput(bad).errors.some((e) => /不一致/.test(e))).toBe(true)
  })

  it('checkedAt 不可解析 → 报错（不是随便一个字符串都算时间戳）', () => {
    const bad = { auditId: 'x', checkedAt: 'yesterday', findings: [], totals: { findings: 0, checkedUnits: 1 } }
    expect(validateGateOutput(bad).errors).toContain('`checkedAt` 不是可解析的时间戳')
  })
})

describe('crossCheckExitVsTotals — 五种 exit×findings 组合各一例（处方 §3.2）', () => {
  const cases: Array<[string, { exitCode: number; findings: number; checkedUnits: number }, string, boolean]> = [
    ['≠0 / >0 → 正常阻断', { exitCode: 1, findings: 3, checkedUnits: 100 }, 'blocked', false],
    ['≠0 / 0  → ⚠️ 异常，必须报出', { exitCode: 1, findings: 0, checkedUnits: 100 }, 'blocked-without-findings', true],
    ['0 / >0  → 只报不拦（合法）', { exitCode: 0, findings: 19, checkedUnits: 74 }, 'reported-not-blocked', false],
    ['0 / 0 / 分母>0 → 真通过', { exitCode: 0, findings: 0, checkedUnits: 644 }, 'clean', false],
    ['0 / 0 / 分母=0 → ⚠️ 假绿，必须报出', { exitCode: 0, findings: 0, checkedUnits: 0 }, 'empty-denominator', true],
  ]

  for (const [name, input, verdict, anomaly] of cases) {
    it(name, () => {
      const r = crossCheckExitVsTotals(input)
      expect(r.verdict).toBe(verdict)
      expect(r.anomaly).toBe(anomaly)
      expect(r.why.length).toBeGreaterThan(10) // 归类必须带可读理由，⛔ 不许只给一个枚举值
    })
  }

  it('五种取值恰好覆盖 CROSS_CHECK_VERDICTS，没有第六种也没有漏的', () => {
    expect(new Set(cases.map(([, , v]) => v))).toEqual(new Set(CROSS_CHECK_VERDICTS))
  })

  it('两种异常恰好是 CROSS_CHECK_ANOMALIES（⛔ 别把 reported-not-blocked 也算异常）', () => {
    expect(cases.filter(([, , , a]) => a).map(([, , v]) => v).sort()).toEqual([...CROSS_CHECK_ANOMALIES].sort())
  })

  it('阴性对照：`exit≠0 且 findings>0` 与 `exit 0 且 findings>0` **不同判定**（判据不是「有没有 findings」）', () => {
    expect(crossCheckExitVsTotals({ exitCode: 1, findings: 3, checkedUnits: 1 }).verdict).not.toBe(
      crossCheckExitVsTotals({ exitCode: 0, findings: 3, checkedUnits: 1 }).verdict,
    )
  })

  it('exit 2（跑不起来）+ 分母>0 走 blocked 分支，不被当成通过', () => {
    const r = crossCheckExitVsTotals({ exitCode: 2, findings: 0, checkedUnits: 12 })
    expect(r.verdict).toBe('blocked-without-findings')
    expect(r.anomaly).toBe(true)
  })
})

/**
 * `checkedUnits === 0` 提到真值表最前面（2026-08-25，处方 =
 * ai-ds-lab `proposals/2026-08-25-crosscheck-denominator-gap.md`）。
 *
 * 旧表在 `exit≠0` 与 `findings>0` 三格把 `checkedUnits` 标 `—`（不看）⇒
 * **一条闸只要报出了 findings，它的分母就完全不受校验**。这条缺口
 * must-hit / must-not-hit 抓不到（收割器 6 条内建控制当时全绿）——
 * 它测的是「判据按真值表跑对了没有」，而缺的是**真值表本身少了一格**。
 */
describe('crossCheckExitVsTotals — checkedUnits=0 一律 empty-denominator（新表第一格）', () => {
  const zeroDenomCases: Array<[string, { exitCode: number; findings: number; checkedUnits: number }]> = [
    ['exit 0 · findings 0  （旧表已覆盖的那一格）', { exitCode: 0, findings: 0, checkedUnits: 0 }],
    ['exit 0 · findings >0 （旧表判 reported-not-blocked —— 就是漏掉的那一格）', { exitCode: 0, findings: 9, checkedUnits: 0 }],
    ['exit≠0 · findings 0  （旧表判 blocked-without-findings）', { exitCode: 1, findings: 0, checkedUnits: 0 }],
    ['exit≠0 · findings >0 （旧表判 blocked —— ⛔ 不许被 blocked 那一格吃掉）', { exitCode: 1, findings: 9, checkedUnits: 0 }],
  ]

  for (const [name, input] of zeroDenomCases) {
    it(name, () => {
      const r = crossCheckExitVsTotals(input)
      expect(r.verdict).toBe('empty-denominator')
      expect(r.anomaly).toBe(true)
    })
  }

  it('`findings>0 ∧ checkedUnits=0` 的 why 要点名「自相矛盾」（不可能在 0 个单位里找到问题）', () => {
    expect(crossCheckExitVsTotals({ exitCode: 0, findings: 9, checkedUnits: 0 }).why).toMatch(/自相矛盾/)
    // 阴性对照：findings=0 时不该扣这顶帽子（空分母不可解读 ≠ 自相矛盾）
    expect(crossCheckExitVsTotals({ exitCode: 0, findings: 0, checkedUnits: 0 }).why).not.toMatch(/自相矛盾/)
  })

  it('⛔ must-not-hit：checkedUnits **缺失 / 传坏了** 不得判成 empty-denominator，必须 fail closed 抛', () => {
    // 「没传」与「真的是 0」是两件事，⛔ 不许合并 —— 合并会把「作者忘了传」伪装成「真的扫了 0 个」
    expect(() => crossCheckExitVsTotals({ exitCode: 0, findings: 0 })).toThrow(/checkedUnits/)
    expect(() => crossCheckExitVsTotals({ exitCode: 0, findings: 0, checkedUnits: null })).toThrow(/checkedUnits/)
    expect(() => crossCheckExitVsTotals({ exitCode: 0, findings: 0, checkedUnits: '0' })).toThrow(/checkedUnits/)
    expect(() => crossCheckExitVsTotals({ exitCode: 0, findings: 0, checkedUnits: NaN })).toThrow(/checkedUnits/)
    expect(() => crossCheckExitVsTotals({ exitCode: 0, findings: 0, checkedUnits: -1 })).toThrow(/checkedUnits/)
    expect(() => crossCheckExitVsTotals({ exitCode: 0, findings: 0, checkedUnits: 1.5 })).toThrow(/checkedUnits/)
  })

  it('⛔ must-not-hit：分母 >0 时**不得**误报成 empty-denominator（四格照旧）', () => {
    for (const input of [
      { exitCode: 0, findings: 0, checkedUnits: 1 },
      { exitCode: 0, findings: 9, checkedUnits: 1 },
      { exitCode: 1, findings: 0, checkedUnits: 1 },
      { exitCode: 1, findings: 9, checkedUnits: 1 },
    ]) {
      expect(crossCheckExitVsTotals(input).verdict).not.toBe('empty-denominator')
    }
  })

  it('⛔ 两个导出不新增取值 —— `empty-denominator` 本来就在异常表里（收割器/豁免表不动结构）', () => {
    expect(CROSS_CHECK_VERDICTS).toHaveLength(5)
    expect(CROSS_CHECK_ANOMALIES).toContain('empty-denominator')
  })
})
