import { describe, it, expect } from 'vitest'
import { readFileSync, readdirSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

// INFRA-F123 ② —— `templates/consumer-product/.githooks/` 里的闸是 DS 侧真源的**自包含零依赖镜像**
// （形态刻意不同：消费仓无 npm/husky 生态，镜像不能 import 兄弟脚本）。
// ⚠️ 2026-08-14 实测：此前**镜像与真源之间没有任何闸**，既有那对已实际分叉（字节数与 md5 都不同），
//    而两份头注释都写着「改逻辑先改真源再同步这里」—— 那是一条纯 L1 纪律，无人守。
// 本测试钉的不是**字节相等**（那会否定「自包含镜像」这个设计），而是**判定结果一致**：
//    同一组 fixture 喂两边，逐条断言分类相同。改任一边而不改另一边 → 这里红。
const __dirname = dirname(fileURLToPath(import.meta.url))
const DS = (f: string) => resolve(__dirname, '..', 'scripts', f)
const MIRROR = (f: string) => resolve(__dirname, '..', 'templates', 'consumer-product', '.githooks', f)

const MARKER = '<!-- mockup-handoff -->'

describe('consumer .githooks 镜像 ↔ DS 真源：行为一致性', () => {
  it('镜像目录里每个 .mjs 都在头注释里声明了真源（否则下一个人不知道改哪边）', () => {
    const files = readdirSync(MIRROR('.')).filter(f => f.endsWith('.mjs'))
    expect(files.length).toBeGreaterThan(0)
    for (const f of files) {
      const head = readFileSync(MIRROR(f), 'utf8').split('\n').slice(0, 30).join('\n')
      expect(head, `${f} 头注释缺「真源」声明`).toMatch(/真源/)
      expect(head, `${f} 头注释缺 $TVU/scripts 指针`).toMatch(/scripts\//)
    }
  })

  describe('audit-handoff-deliverable-sections（INFRA-F123）', () => {
    const TRACE = 'Affordance: directional-vertical (intent: 展开)\nVocabulary scan: [Arrow/Down]\nChosen: Arrow/Down — 同几何\n'
    const cases: Array<[string, string, string]> = [
      ['四段齐全', `${MARKER}\nReference probe\nclone source probe table\nCRUD self-check\n${TRACE}`, 'anywhere.md'],
      ['四段全缺', `${MARKER}\n## 1. 现状\n`, 'anywhere.md'],
      ['缺 CRUD', `${MARKER}\nReference probe\nclone source probe table\n${TRACE}`, 'anywhere.md'],
      ['围栏内不算', `${MARKER}\n\`\`\`\nReference probe\nclone source probe table\nCRUD self-check\n${TRACE}\`\`\`\n`, 'anywhere.md'],
      ['N/A 算存在', `${MARKER}\nReference probe\nclone source probe table: N/A — 无 clone\nCRUD self-check: N/A — view-only\nAffordance: N/A — 无 affordance 元素\n`, 'x.md'],
      // ↓ M35 第二层（3 行 trace）：这三条是本轮新增判据，若镜像只跟了短语没跟逻辑，这里会红
      ['M35 缺 Vocabulary scan', `${MARKER}\nReference probe\nclone source probe table\nCRUD self-check\nAffordance: x\nChosen: y\n`, 'anywhere.md'],
      ['M35 缺 Chosen', `${MARKER}\nReference probe\nclone source probe table\nCRUD self-check\nAffordance: x\nVocabulary scan: [a]\n`, 'anywhere.md'],
      ['M35 N/A 在围栏里不豁免', `${MARKER}\nReference probe\nclone source probe table\nCRUD self-check\nAffordance: x\n\`\`\`\nAffordance: N/A — 说明\n\`\`\`\n`, 'anywhere.md'],
      ['非交付物', `# 普通文档\nReference probe\n`, 'README.md'],
      ['路径入 scope', `# x\n`, 'docs/handoffs/2026-08-14-x-handoff.md'],
    ]

    it('两边对同一组 fixture 的判定逐条一致', async () => {
      const ds = await import(DS('audit-handoff-deliverable-sections.mjs'))
      const mirror = await import(MIRROR('audit-handoff-deliverable-sections.mjs'))
      const dsScope = await import(DS('audit-deliverable-open-questions.mjs'))

      for (const [label, md, path] of cases) {
        expect(mirror.isDeliverable(md, path), `${label}: isDeliverable 分叉`)
          .toBe(dsScope.isDeliverable(md, path))
        const a = ds.findMissingSections(md).map((m: { id: string }) => m.id)
        const b = mirror.findMissingSections(md).map((m: { id: string }) => m.id)
        expect(b, `${label}: 缺失段落判定分叉`).toEqual(a)
      }
    })

    it('判据字符串两边逐字相同（改了短语就是改了判据）', async () => {
      const ds = await import(DS('audit-handoff-deliverable-sections.mjs'))
      const mirror = await import(MIRROR('audit-handoff-deliverable-sections.mjs'))
      expect(mirror.SECTIONS.map((s: { phrase: string }) => s.phrase))
        .toEqual(ds.SECTIONS.map((s: { phrase: string }) => s.phrase))
    })

    // ⚠️ 上一条只比 `phrase`。M35 那行的判据还有第二层（traceLines / naRe），
    //    只比 phrase 的话，一边改了第二层、另一边没改，上一条照样绿 ⇒ 这里把第二层也钉上。
    it('第二层判据（traceLines / naRe）两边也逐字相同', async () => {
      const ds = await import(DS('audit-handoff-deliverable-sections.mjs'))
      const mirror = await import(MIRROR('audit-handoff-deliverable-sections.mjs'))
      const shape = (m: { SECTIONS: Array<Record<string, unknown>> }) =>
        m.SECTIONS.map(s => [s.id, s.traceLines ?? null, s.naRe ? String(s.naRe) : null])
      expect(shape(mirror)).toEqual(shape(ds))
      // 致败探针：这一条必须真的在比「非空」的东西，否则两边都是 null 也会相等
      expect(shape(ds).some(([, lines]) => Array.isArray(lines) && lines.length > 0)).toBe(true)
      expect(shape(ds).some(([, , na]) => typeof na === 'string' && na.length > 0)).toBe(true)
    })

    it('阴性对照：故意改一个短语后两边会不一致 —— 证明上面两条不是空过', async () => {
      const ds = await import(DS('audit-handoff-deliverable-sections.mjs'))
      const tampered = ds.SECTIONS.map((s: { phrase: string }) => s.phrase).slice()
      tampered[0] = 'Reference probeX'
      const mirror = await import(MIRROR('audit-handoff-deliverable-sections.mjs'))
      expect(tampered).not.toEqual(mirror.SECTIONS.map((s: { phrase: string }) => s.phrase))
    })
  })

  describe('audit-mockup-handoff-evidence（F62-b，本轮之前零闸的那一对）', () => {
    // 实测返回形态（2026-08-14）：{isHandoff, reportPath, reportFound, reportPassed, fresh, ok}
    // DS 版多一个 `live` 字段（它会做 live 校验，镜像刻意不做）—— 那是**设计上的差异**，不比。
    // ⚠️ 本断言的第一版拿 `problems ?? violations ?? []` 比长度，而两边都没有这两个键 ⇒ 恒 0===0，
    //    是空过。已改为比真实存在的判定字段，并配下方致败探针。
    const DECISION_FIELDS = ['isHandoff', 'reportPath', 'reportFound', 'reportPassed', 'fresh', 'ok'] as const
    const pick = (o: Record<string, unknown>) =>
      Object.fromEntries(DECISION_FIELDS.map(k => [k, o?.[k]]))

    const fixtures = [
      `${MARKER}\n没有引用任何 report。\n`,
      `${MARKER}\nConformance report: docs/handoffs/.conformance/does-not-exist.json\n`,
      `${MARKER}\nIntegrity audit: I1=pass\n`,
      `# 不是 handoff\n`,
    ]

    // ⚠️ 下面这批（NOW / TS / REF / cases / readReport）原本定义在「场景 2」子块里。
    //    2026-09-11 上移到本层，**内容一字未动** —— 因为最底下那个「结构解」元断言块要把
    //    两批 fixture 合起来算覆盖面，而它们原先分属两个作用域彼此看不见。
    const NOW = Date.parse('2026-08-31T12:00:00Z')
    const TS = '2026-08-31T11:00:00Z' // 1h 前 ⇒ 落在 24h 窗口内，fresh 不干扰判定
    const REF = 'Conformance report: ./r.json\n'
    type Case = [label: string, md: string, report: Record<string, unknown>]
    const cases: Case[] = [
      [
        '严档全绿 → 放行',
        `${MARKER}\n${REF}`,
        { timestamp: TS, subAudits: [{ key: 'I1', exitCode: 0 }, { key: 'I3', exitCode: 0 }] },
      ],
      [
        '严档有 exitCode=1 → 拦',
        `${MARKER}\n${REF}`,
        { timestamp: TS, subAudits: [{ key: 'I1', exitCode: 0 }, { key: 'I3', exitCode: 1 }] },
      ],
      [
        // 👇 这一条是本块存在的理由：真源放行、镜像（无披露档）拦 ⇒ 分叉在此暴露
        '非阻塞档 + 未过子闸已在正文点名 → 真源放行',
        `${MARKER}\n${REF}\n未过的子审计：I3 —— pre-DS 存量，按 R6 可接受。\n`,
        { timestamp: TS, nonBlocking: true, subAudits: [{ key: 'I1', exitCode: 0 }, { key: 'I3', exitCode: 1 }] },
      ],
      [
        '非阻塞档 + 一个字都没提 → 拦（--non-blocking 不是绕闸开关）',
        `${MARKER}\n${REF}`,
        { timestamp: TS, nonBlocking: true, subAudits: [{ key: 'I3', exitCode: 1 }] },
      ],
      [
        '非阻塞档 + exitCode=2（没有结论）→ 点名也拦',
        `${MARKER}\n${REF}\n未过的子审计：I7 —— 缓存过期。\n`,
        { timestamp: TS, nonBlocking: true, subAudits: [{ key: 'I7', exitCode: 2 }] },
      ],
    ]

    const readReport = (report: Record<string, unknown>) => () => report

    it('两边对同一组 handoff 文本的判定字段逐条一致', async () => {
      const ds = await import(DS('audit-mockup-handoff-evidence.mjs'))
      const mirror = await import(MIRROR('audit-mockup-handoff-evidence.mjs'))
      for (const md of fixtures) {
        expect(pick(mirror.checkHandoffEvidence(md)), `分叉：${md.slice(0, 30)}`)
          .toEqual(pick(ds.checkHandoffEvidence(md)))
      }
    })

    it('阴性对照：判定字段确实带信息（不同 fixture 给出不同结论）—— 证明上一条非空过', async () => {
      const ds = await import(DS('audit-mockup-handoff-evidence.mjs'))
      const results = fixtures.map(md => JSON.stringify(pick(ds.checkHandoffEvidence(md))))
      expect(new Set(results).size).toBeGreaterThan(1)
      // 且必须真的有 true / false 两种结论，否则「一致」可能只是「两边都恒 true」
      const oks = fixtures.map(md => ds.checkHandoffEvidence(md).ok)
      expect(new Set(oks)).toEqual(new Set([true, false]))
    })

    // ---------------------------------------------------------------------
    // 🔴 2026-08-31 新增：**上面那 4 条 fixture 全部落在 `reportFound:false`**
    //    （它们都不注入 `readReport` ⇒ 默认 `() => null`）⇒ 一条都走不到 `subAudits`
    //    判定层，于是**镜像落后真源一个完整档位、而这份测试是绿的**：
    //    2026-08-14 落地在真源里的「场景 2（`--non-blocking`）披露档」，镜像 `nonBlocking`
    //    grep **0 命中**，7 passed 照样全绿。
    //    ⇒ 本块把 fixture 补到能读到真 report 那一层。判定字段多比一个 `mode`
    //      —— 档位本身就是判定的一部分，只比 `ok` 会漏掉「两边都 false、但一个是
    //      『子闸没过』另一个是『没披露』」这种同值不同因。
    //    ⛔ `live` / `figmaLastModified` 仍然不比：那是测试注释上方已声明的**设计差异**
    //      （镜像刻意不做活体校验），与本块治的分叉不是一回事。
    //    ⚠️ **本块治的是 `nonBlocking` 这一档，不是「同型分叉再也不会发生」** —— fixture 是
    //      **按当前真源的判定分支手写枚举**的。真源下次再加一个档位（新字段 / 新分支）时，
    //      若没有人同时往这里补一条走到那个分支的 fixture，**同型盲区会原样复发**，而这份
    //      测试照样全绿 —— 这正是 2026-08-14 到 08-31 那 17 天发生过的事。
    //      ⛔ 别把本块读成结构性根治。
    //    ✅ **2026-09-11 订正上一行原来的结尾**：它写着「真正的结构解…**尚未做，未立项**」，
    //      而结构解当日已经做了 —— 就在本文件最下方的
    //      `describe('结构解：判据面随真源自动扩张…')`（两条元断言 M1 / M2）。
    //      ⇒ 上面那段「本块治的只是 nonBlocking 一档、手写枚举会复发」**仍然全对**，
    //      它描述的是**本块**的边界；变的只是「那个病没人治」这半句。
    //      ⚠️ 留下这段订正而不是直接删，是因为「手写枚举为什么不够」正是下面那两条元断言
    //      存在的理由 —— 删了它，下一个人会看不懂 M1/M2 在防什么。
    // ---------------------------------------------------------------------
    describe('场景 2（--non-blocking）披露档：够得到 subAudits 判定层的 fixture', () => {
      const DECISION_FIELDS_2 = [
        'isHandoff', 'reportPath', 'reportFound', 'reportPassed', 'fresh', 'ok', 'mode',
      ] as const
      const pick2 = (o: Record<string, unknown>) =>
        Object.fromEntries(DECISION_FIELDS_2.map(k => [k, o?.[k]]))

      it('两边对同一组 report 的判定字段逐条一致（含档位 mode）', async () => {
        const ds = await import(DS('audit-mockup-handoff-evidence.mjs'))
        const mirror = await import(MIRROR('audit-mockup-handoff-evidence.mjs'))
        for (const [label, md, report] of cases) {
          const opts = { readReport: readReport(report), now: NOW }
          expect(pick2(mirror.checkHandoffEvidence(md, opts)), `分叉：${label}`)
            .toEqual(pick2(ds.checkHandoffEvidence(md, opts)))
        }
      })

      it('致败探针：这批 fixture 真的读到了 report（否则又是一层看不见的空过）', async () => {
        const ds = await import(DS('audit-mockup-handoff-evidence.mjs'))
        const rs = cases.map(([, md, report]) =>
          ds.checkHandoffEvidence(md, { readReport: readReport(report), now: NOW }))
        // ① 每条都必须越过 reportFound:false 那道坎——这正是旧 4 条 fixture 卡住的地方
        expect(rs.every(r => r.reportFound === true)).toBe(true)
        // ② 必须真的有 subAudits 被读进来（空数组会让 reportPassed 恒 false，等于没测判据）
        expect(rs.every(r => r.fresh === true)).toBe(true)
        // ③ 两档都要出现，否则「一致」可能只是「都走严档」
        expect(new Set(rs.map(r => r.mode))).toEqual(new Set(['blocking', 'non-blocking']))
        // ④ 结论必须有 true / false 两种
        expect(new Set(rs.map(r => r.ok))).toEqual(new Set([true, false]))
      })
    })

    // =====================================================================
    // 结构解（2026-09-11）：把「下次真源加档位时会不会有人记得补 fixture」
    // 从**靠人记得**变成**判据自己会红**。
    //
    // 上一块的 ⚠️ 自陈逐字写着：fixture 是按当前真源判定分支**手写枚举**的，真源下次
    // 加一个档位而没人同时补 fixture 时，同型盲区会原样复发、测试照样全绿 ——
    // 2026-08-14 到 08-31 那 17 天就是这么过来的。下面两条治的正是它。
    //
    // ⛔ **关键设计：两条判据的输入面都从真源现取，没有任何手写的分支清单 / 基线常量。**
    //    手写一份「真源有哪些分支」的清单，和手写 fixture 是同一个病 —— 那只是把会漂的
    //    东西从一处挪到另一处。
    //
    // 两条各治一半，⛔ 别合并、⛔ 别只留一条：
    //   **M1 判定字段面对齐** —— 真源新增判定字段而镜像没跟 ⇒ 红。
    //     ⭐ 它抓 2026-08-14 那次**不需要任何人先想到去补 fixture**：那次真源多出
    //       `mode` / `undisclosed` / `erroredKeys` 三个字段，M1 当场就不等了。
    //   **M2 每个判定字段都被 fixture 走到过 ≥2 种取值** ⇒ 真源新增的字段自动进入判据，
    //     没有 fixture 让它分叉就红，且红的信息直接是「补一条走到它的 fixture」。
    //
    // ⚠️ **诚实边界 —— ⛔ 别把这两条读成「同型分叉再也不会发生」**：真源若新增一个
    //    **既不新增返回字段、又不改变任何现有字段取值多样性**的分支（例如再加一个让 `ok`
    //    变 false 的原因，而 `ok` 早已有 true/false 两种），M1 与 M2 都不会红。
    //    那一层要靠源码结构棘轮，本轮**刻意没做**：它需要一个人工维护的基线常量，重构即
    //    误红，而本仓对「不可满足的闸 = 没有闸」有逐字记载的代价。⇒ 这是**已知且接受的
    //    覆盖边界**，不是待补的 TODO。
    // =====================================================================
    describe('结构解：判据面随真源自动扩张（不靠人记得补 fixture）', () => {
      /** 刻意不比的字段 —— **具名**豁免，⛔ 不是宽泛白名单。`live` 的设计差异见本文件 :90。 */
      const BY_DESIGN_DS_ONLY = ['live'] as const
      const isExempt = (k: string) => (BY_DESIGN_DS_ONLY as readonly string[]).includes(k)

      /** 两批 fixture 合起来喂真源，拿回真源自己产出的全部判定结果。 */
      const allResults = async () => {
        const ds = await import(DS('audit-mockup-handoff-evidence.mjs'))
        return [
          ...fixtures.map(md => ds.checkHandoffEvidence(md)),
          ...cases.map(([, md, report]) =>
            ds.checkHandoffEvidence(md, { readReport: readReport(report), now: NOW })),
        ]
      }

      it('M1 · 判定字段面对齐：真源新增判定字段而镜像没跟 → 这里红', async () => {
        const ds = await import(DS('audit-mockup-handoff-evidence.mjs'))
        const mirror = await import(MIRROR('audit-mockup-handoff-evidence.mjs'))
        const opts = { readReport: readReport(cases[0][2]), now: NOW }
        const dsKeys = Object.keys(ds.checkHandoffEvidence(cases[0][1], opts)).sort()
        const mirrorKeys = Object.keys(mirror.checkHandoffEvidence(cases[0][1], opts)).sort()
        const dsOnly = dsKeys.filter(k => !mirrorKeys.includes(k))

        expect(mirrorKeys, '真源与镜像的判定字段面分叉。真源加了判定字段就得同步镜像，'
          + '或把它具名加进 BY_DESIGN_DS_ONLY 并写明为什么刻意不镜像。'
          + `真源独有：${dsOnly.join(', ') || '(无)'}`)
          .toEqual(dsKeys.filter(k => !isExempt(k)))

        // 致败探针：豁免表必须真的在豁免东西，否则 M1 退化成「两边恒等」的空断言
        expect(BY_DESIGN_DS_ONLY.length, '豁免表空了 ⇒ M1 失去致败能力').toBeGreaterThan(0)
        expect(dsOnly, '豁免表与实际分叉对不上 —— 要么镜像跟上了、该缩表，要么真源又多了字段')
          .toEqual([...BY_DESIGN_DS_ONLY])
      })

      it('M2 · 每个判定字段都被 fixture 走到过 ≥2 种取值（新字段没 fixture 覆盖 → 红）', async () => {
        const results = await allResults()
        // 字段面取**所有结果的并集**（早退分支与完整分支返回的字段集可能不同，取并集才不漏）
        const fields = [...new Set(results.flatMap(r => Object.keys(r)))].filter(k => !isExempt(k))
        expect(fields.length, '一个判定字段都没取到 ⇒ fixture 或真源出了问题').toBeGreaterThan(0)

        // ⛔ 先滤掉 `undefined` 再数取值种数。否则真源把新字段**只加在部分 return 点**时
        //    （早退分支漏掉），其余结果里的 `undefined` 会冒充「第二种取值」让本条假绿
        //    —— 这是写故障注入臂 2 时当场撞出来的，不是推演。
        const thin = fields.filter(k => new Set(
          results.map(r => r[k]).filter(v => v !== undefined).map(v => JSON.stringify(v)),
        ).size < 2)
        expect(thin, '这些判定字段在全部 fixture 里只出现过一种取值 ⇒ 它们的分支没被测到。'
          + `补一条让它分叉的 fixture，⛔ 别改判据去迁就现状：${thin.join(', ')}`)
          .toEqual([])
      })
    })
  })
})
