import { describe, it, expect } from 'vitest'
import { execFileSync } from 'node:child_process'
import { mkdtempSync, mkdirSync, writeFileSync, copyFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { resolve, join } from 'node:path'

/**
 * 收割器 `scripts/gate-output-harvest.mjs` 的回归面 —— **整脚本 fixture-root harness**。
 *
 * ⚠️ 为什么不是「import 纯函数再单测」：本仓 2026-08-24 已量化过，`import` 形态的测试
 * **覆盖不到接线** —— 摘掉一条 L4+L5 阻塞闸的 `main()` 接线，全仓 1993 条测试无一转红
 * （`meta-rules.md` 触发器 R §违反检测 第 4 条）。而收割器最值钱的判据恰恰全在接线上：
 * 选择面钉的是 npm key、退出码要 fail-closed、棘轮表要 shrink-only。⇒ 走整脚本。
 *
 * 四个前置条件（复盘 `2026-08-24-testing-a-live-gate-without-refactoring-it.md`）都满足：
 *   P1 repo root 取自 `import.meta.url`（不是 `process.cwd()`）⇒ 拷到别处就读别处 ✓
 *   P2 输入面 = 文件读 + spawn node（无 git / 无网络 / 无凭据）✓
 *   P3 输入面可整体伪造（一份小 package.json + 几个几行的假闸脚本）✓
 *   P4 契约 lib 一并拷进去（不软链回真仓库，避免并行线改它时污染 fixture）✓
 *
 * ⛔ 每条断言都钉**会变的量**（退出码 / 具体条数 / 具体判定），不写「字段存在」这类
 *    结论相反时也照样通过的话。
 */

const REPO_ROOT = resolve(__dirname, '..')

type Run = { status: number; stdout: string; stderr: string }

function run(root: string, args: string[] = []): Run {
  try {
    const stdout = execFileSync(process.execPath, [join(root, 'scripts/gate-output-harvest.mjs'), ...args], {
      encoding: 'utf8',
      cwd: root,
    })
    return { status: 0, stdout, stderr: '' }
  } catch (e) {
    const err = e as { status: number; stdout?: string; stderr?: string }
    return { status: err.status, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }
  }
}

/** 一个假闸脚本：按契约发射，可控 exit / findings / checkedUnits / 前后散文。 */
function gateSource(o: {
  auditId: string
  findings: number
  checkedUnits: number
  exitCode: number
  prosePrefix?: boolean
  proseSuffix?: boolean
  emit?: boolean
}) {
  return [
    `import { buildGateOutput, emitGateOutput } from '../scripts/lib/gate-output-contract.mjs'`,
    o.prosePrefix ? `console.log('saved → some/where.json')` : '',
    o.emit === false
      ? `console.log('plain prose only, no contract block')`
      : `emitGateOutput(buildGateOutput({ auditId: ${JSON.stringify(o.auditId)}, findings: Array.from({length: ${o.findings}}, (_, i) => ({ i })), checkedUnits: ${o.checkedUnits}, checkedUnit: 'unit' }))`,
    o.proseSuffix ? `console.log('❌ trailing prose after the JSON')` : '',
    `process.exit(${o.exitCode})`,
  ].filter(Boolean).join('\n')
}

type GateSpec = Parameters<typeof gateSource>[0] & { npmKey: string; dir: 'figma-sync' | 'scripts' }

/**
 * 造一个 fixture repo root：真收割器 + 真契约 lib + 假 package.json + 假闸脚本。
 * @param ratchet NOT_YET_ON_CONTRACT 要替换成的内容（null = 不替换，用真表）
 */
function makeRoot(gates: GateSpec[], opts: { ratchet?: string[]; anomalies?: string } = {}) {
  const root = mkdtempSync(join(tmpdir(), 'harvest-fixture-'))
  mkdirSync(join(root, 'scripts/lib'), { recursive: true })
  mkdirSync(join(root, 'figma-sync'), { recursive: true })

  copyFileSync(resolve(REPO_ROOT, 'scripts/lib/gate-output-contract.mjs'), join(root, 'scripts/lib/gate-output-contract.mjs'))
  // 2026-08-25（第 9 步）：收割器现在从 `scripts/lib/gate-chain-steps.mjs` 拿步骤面解析与闸判据
  copyFileSync(resolve(REPO_ROOT, 'scripts/lib/gate-chain-steps.mjs'), join(root, 'scripts/lib/gate-chain-steps.mjs'))

  let src = execFileSync('cat', [resolve(REPO_ROOT, 'scripts/gate-output-harvest.mjs')], { encoding: 'utf8' })

  /**
   * 🔴 **改写必须命中，命中不到就抛**（2026-08-26 补，付过一次代价才加的）。
   *
   * 这两条正则要求被改写的常量是**多行数组**（`[` 换行 `]`）。那天 `KNOWN_ANOMALIES` 缩到
   * 空表后被压成单行 `= []`，于是 `[\s\S]*?\n\]` **贪婪地匹配到了后面另一个数组的 `\n]`**，
   * 把中间整段代码（含 `CONTROLS` 与 `importsContractLib` 的定义）一起替换掉 ——
   * fixture 里的收割器当场 ReferenceError，**16 条用例连崩**，而崩的位置离病因十万八千里
   * （报的是 `Unexpected end of JSON input`：收割器崩了 ⇒ stdout 空 ⇒ JSON.parse 失败）。
   * ⇒ 静默的部分匹配比匹配失败危险得多。这里改成 fail-closed：改写不命中就当场抛。
   */
  const rewrite = (from: RegExp, to: string, what: string) => {
    const next = src.replace(from, to)
    if (next === src) {
      throw new Error(
        `makeRoot 改写 ${what} 未命中 —— scripts/gate-output-harvest.mjs 里该常量的形态变了？` +
        '（它必须是**多行**数组：`[` 换行 `]`）。先修这里的正则或那边的形态，⛔ 别让它静默跳过。'
      )
    }
    src = next
  }

  if (opts.ratchet) {
    rewrite(
      /export const NOT_YET_ON_CONTRACT = \[[\s\S]*?\n\]/,
      `export const NOT_YET_ON_CONTRACT = [\n${opts.ratchet.map((r) => `  '${r}',`).join('\n')}\n]`,
      'NOT_YET_ON_CONTRACT',
    )
  }
  if (opts.anomalies !== undefined) {
    rewrite(
      /export const KNOWN_ANOMALIES = \[[\s\S]*?\n\]/,
      `export const KNOWN_ANOMALIES = [${opts.anomalies}]`,
      'KNOWN_ANOMALIES',
    )
  }
  // fixture 里的假闸都是「一条也不该被内建控制 must-hit 命中」的，所以把那三条
  // 依赖真仓库具体闸名/条数的控制摘掉 —— 它们在真仓库跑时才有意义。
  // ⚠️ 摘的是**控制**，不是判据：选择面、交叉校验、棘轮、退出码全部原样。
  src = src.replace(/^\s*\{\s*\n\s*id: 'must-hit:(tokenized-diff-parsed|translation-completeness-parsed|v1-face-is-11)',[\s\S]*?\n\s*\},\n/gm, '')
  writeFileSync(join(root, 'scripts/gate-output-harvest.mjs'), src)

  const scripts: Record<string, string> = {}
  const chain: string[] = []
  for (const g of gates) {
    const file = `${g.dir}/audit-${g.auditId}.mjs`
    writeFileSync(join(root, file), gateSource(g))
    scripts[g.npmKey] = `node ${file}`
    chain.push(`pnpm run ${g.npmKey}`)
  }
  // 步骤面真源 = `gate-chain`（2026-08-25 起）；`prepublishOnly` 是跑它的 runner 入口。
  scripts['gate-chain'] = chain.join(' && ')
  scripts.prepublishOnly = 'node scripts/run-gate-chain.mjs'
  writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'fixture', scripts }, null, 2))
  return root
}

function withRoot(gates: GateSpec[], opts: Parameters<typeof makeRoot>[1] = {}, fn: (root: string) => void) {
  const root = makeRoot(gates, opts)
  try {
    fn(root)
  } finally {
    rmSync(root, { recursive: true, force: true })
  }
}

const CLEAN: GateSpec = { npmKey: 'audit:alpha', dir: 'figma-sync', auditId: 'alpha', findings: 0, checkedUnits: 10, exitCode: 0 }

describe('选择面钉 npm key（⛔ 不钉文件特征）', () => {
  it('只收 prepublishOnly 里、且文件名带闸前缀的 —— 非闸 step 不进分母', () => {
    withRoot([CLEAN], { ratchet: [], anomalies: '' }, (root) => {
      // 往链里插一个 `report:` 前缀的 JSON 发射器（= 真仓库里 gate-regression-face 那一类）
      const pkgPath = join(root, 'package.json')
      const pkg = JSON.parse(execFileSync('cat', [pkgPath], { encoding: 'utf8' }))
      writeFileSync(join(root, 'figma-sync/report-face-inventory.mjs'), `console.log(JSON.stringify({ generatedFrom: 'x', rows: [] }))`)
      pkg.scripts['report:face'] = 'node figma-sync/report-face-inventory.mjs'
      pkg.scripts['gate-chain'] = `${pkg.scripts['gate-chain']} && pnpm run report:face`
      writeFileSync(pkgPath, JSON.stringify(pkg, null, 2))

      const r = run(root, ['--json'])
      const j = JSON.parse(r.stdout)
      expect(j.prepublishOnlySteps).toBe(2)
      // must-not-hit：那个 report: 发射器**不在**分母里
      expect(j.inChainGates).toBe(1)
      expect(j.v1FaceSize).toBe(1)
      expect(JSON.stringify(j.rows)).not.toContain('report-face-inventory')
      expect(r.status).toBe(0)
    })
  })

  it('两个 npm key 共用一个脚本时只算一条闸（真仓库 icon-naming-rules 就是这形态）', () => {
    withRoot([CLEAN], { ratchet: [], anomalies: '' }, (root) => {
      const pkgPath = join(root, 'package.json')
      const pkg = JSON.parse(execFileSync('cat', [pkgPath], { encoding: 'utf8' }))
      pkg.scripts['audit:alpha-again'] = 'node figma-sync/audit-alpha.mjs'
      pkg.scripts['gate-chain'] = `${pkg.scripts['gate-chain']} && pnpm run audit:alpha-again`
      writeFileSync(pkgPath, JSON.stringify(pkg, null, 2))
      const j = JSON.parse(run(root, ['--json']).stdout)
      expect(j.prepublishOnlySteps).toBe(2)
      expect(j.inChainGates).toBe(1)
    })
  })

  it('fail-closed：prepublishOnly 解析不出任何闸 ⇒ exit≠0（不是「没有闸」，是判据形态变了）', () => {
    withRoot([], { ratchet: [], anomalies: '' }, (root) => {
      const r = run(root, ['--json'])
      expect(r.status).toBe(1)
      const j = JSON.parse(r.stdout)
      expect(j.inChainGates).toBe(0)
      expect(j.controls.find((c: any) => c.id === 'fail-closed:in-chain-not-empty').pass).toBe(false)
    })
  })
})

describe('从尾部解析 + 交叉校验（接线，不只是判据）', () => {
  it('散文前缀不影响解析 —— 这是旧 startsWith 判据漏掉的那一类', () => {
    withRoot([{ ...CLEAN, prosePrefix: true }], { ratchet: [], anomalies: '' }, (root) => {
      const r = run(root, ['--json'])
      const j = JSON.parse(r.stdout)
      expect(j.parsedCount).toBe(1)
      expect(j.rows[0].verdict).toBe('clean')
      expect(r.status).toBe(0)
    })
  })

  it('JSON 后面还有散文 ⇒ 判 `json-not-last` 并 exit 1（契约规定它必须是最后一条）', () => {
    withRoot([{ ...CLEAN, proseSuffix: true }], { ratchet: [], anomalies: '' }, (root) => {
      const r = run(root, ['--json'])
      expect(JSON.parse(r.stdout).rows[0].verdict).toBe('json-not-last')
      expect(r.status).toBe(1)
    })
  })

  it('闸完全不发契约块 ⇒ 判 `unparseable` 并 exit 1（⛔ 不静默当它通过）', () => {
    withRoot([{ ...CLEAN, emit: false }], { ratchet: [], anomalies: '' }, (root) => {
      const r = run(root, ['--json'])
      const j = JSON.parse(r.stdout)
      expect(j.rows[0].verdict).toBe('unparseable')
      expect(j.parsedCount).toBe(0)
      expect(r.status).toBe(1)
    })
  })

  it('两种异常都**报出来而不是吞掉**，且都让 exit≠0', () => {
    const specs: GateSpec[] = [
      { npmKey: 'audit:a', dir: 'figma-sync', auditId: 'a', findings: 0, checkedUnits: 0, exitCode: 0 },  // empty-denominator
      { npmKey: 'audit:b', dir: 'figma-sync', auditId: 'b', findings: 0, checkedUnits: 5, exitCode: 1 },  // blocked-without-findings
    ]
    withRoot(specs, { ratchet: [], anomalies: '' }, (root) => {
      const r = run(root, ['--json'])
      const j = JSON.parse(r.stdout)
      expect(j.anomalies.map((a: any) => a.verdict).sort()).toEqual(['blocked-without-findings', 'empty-denominator'])
      expect(r.status).toBe(1)
    })
  })

  it('合法的「只报不拦」不算异常 ⇒ exit 0（判据不是「有没有 findings」）', () => {
    withRoot([{ ...CLEAN, findings: 19, exitCode: 0 }], { ratchet: [], anomalies: '' }, (root) => {
      const r = run(root, ['--json'])
      expect(JSON.parse(r.stdout).rows[0].verdict).toBe('reported-not-blocked')
      expect(r.status).toBe(0)
    })
  })
})

describe('KNOWN_ANOMALIES 具名豁免（shrink-only）', () => {
  const anomalous: GateSpec = { npmKey: 'audit:z', dir: 'figma-sync', auditId: 'z', findings: 0, checkedUnits: 0, exitCode: 0 }

  it('豁免命中 ⇒ 不阻断，但**照样打印**（no silent cap）', () => {
    const table = `{ npmKey: 'audit:z', verdict: 'empty-denominator', since: '2026-08-24', fact: 'f', fixDirection: 'd' }`
    withRoot([anomalous], { ratchet: [], anomalies: table }, (root) => {
      const r = run(root)
      expect(r.status).toBe(0)
      expect(r.stdout).toContain('具名豁免')
      expect(r.stdout).toContain('audit:z')
    })
  })

  it('豁免不再命中 ⇒ 判 stale 并 exit 1（表只许缩，由本工具宣布缩到哪了）', () => {
    const table = `{ npmKey: 'audit:alpha', verdict: 'empty-denominator', since: '2026-08-24', fact: 'f', fixDirection: 'd' }`
    withRoot([CLEAN], { ratchet: [], anomalies: table }, (root) => {
      const r = run(root, ['--json'])
      expect(JSON.parse(r.stdout).staleAnomalyExemptions).toEqual([{ npmKey: 'audit:alpha', verdict: 'empty-denominator' }])
      expect(r.status).toBe(1)
    })
  })

  it('npmKey 对上但 verdict 变了 ⇒ **不**放行（否则同一条闸换一种异常形态就静默通过）', () => {
    const table = `{ npmKey: 'audit:z', verdict: 'blocked-without-findings', since: '2026-08-24', fact: 'f', fixDirection: 'd' }`
    withRoot([anomalous], { ratchet: [], anomalies: table }, (root) => {
      const r = run(root, ['--json'])
      const j = JSON.parse(r.stdout)
      expect(j.anomalies.map((a: any) => a.verdict)).toEqual(['empty-denominator'])
      expect(r.status).toBe(1)
    })
  })
})

describe('NOT_YET_ON_CONTRACT v2 棘轮（shrink-only）', () => {
  const v1: GateSpec = CLEAN
  const v2: GateSpec = { npmKey: 'audit:beta', dir: 'scripts', auditId: 'beta', findings: 0, checkedUnits: 3, exitCode: 0 }

  it('在链、v1 面外、已具名豁免 ⇒ 不阻断（且不被跑）', () => {
    // 假的 v2 闸其实 import 了契约 lib（fixture 的 gateSource 都 import），所以这里
    // 要用一个**不 import** 的版本才算「尚未上契约」。
    withRoot([v1], { ratchet: ['scripts/audit-beta.mjs'], anomalies: '' }, (root) => {
      writeFileSync(join(root, 'scripts/audit-beta.mjs'), `console.log('prose only')\nprocess.exit(0)`)
      const pkgPath = join(root, 'package.json')
      const pkg = JSON.parse(execFileSync('cat', [pkgPath], { encoding: 'utf8' }))
      pkg.scripts['audit:beta'] = 'node scripts/audit-beta.mjs'
      pkg.scripts['gate-chain'] = `${pkg.scripts['gate-chain']} && pnpm run audit:beta`
      writeFileSync(pkgPath, JSON.stringify(pkg, null, 2))

      const r = run(root, ['--json'])
      const j = JSON.parse(r.stdout)
      expect(j.inChainGates).toBe(2)
      expect(j.v1FaceSize).toBe(1)           // v2 那条不进收割面
      expect(j.ratchet.unexcused).toEqual([])
      expect(r.status).toBe(0)
    })
  })

  it('在链、v1 面外、**不在**豁免表 ⇒ exit 1（新增在链闸不上契约就得说清楚）', () => {
    withRoot([v1, v2], { ratchet: [], anomalies: '' }, (root) => {
      writeFileSync(join(root, 'scripts/audit-beta.mjs'), `console.log('prose only')\nprocess.exit(0)`)
      const r = run(root, ['--json'])
      expect(JSON.parse(r.stdout).ratchet.unexcused).toEqual(['scripts/audit-beta.mjs'])
      expect(r.status).toBe(1)
    })
  })

  it('豁免表里那条已 import 契约 lib ⇒ 判 stale 要求删行并 exit 1', () => {
    withRoot([v1, v2], { ratchet: ['scripts/audit-beta.mjs'], anomalies: '' }, (root) => {
      const r = run(root, ['--json'])   // v2 用的是 gateSource，它 import 了契约 lib
      expect(JSON.parse(r.stdout).ratchet.stale).toEqual(['scripts/audit-beta.mjs'])
      expect(r.status).toBe(1)
    })
  })

  it('豁免表里那条已不在链上 ⇒ 判 orphan 要求删行并 exit 1', () => {
    withRoot([v1], { ratchet: ['scripts/audit-long-gone.mjs'], anomalies: '' }, (root) => {
      const r = run(root, ['--json'])
      expect(JSON.parse(r.stdout).ratchet.orphan).toEqual(['scripts/audit-long-gone.mjs'])
      expect(r.status).toBe(1)
    })
  })
})

describe('真仓库上的实况（钉会变的量，⛔ 别写死会随新增闸漂的数）', () => {
  const j = (() => {
    const r = run(REPO_ROOT, ['--json'])
    return { status: r.status, report: JSON.parse(r.stdout) }
  })()

  it('v1 收割面 = 11 条，且**全部**解析出契约块、**全部**读出 findings 计数', () => {
    expect(j.report.v1FaceSize).toBe(11)
    expect(j.report.parsedCount).toBe(11)
    expect(j.report.findingsReadableCount).toBe(11)
  })

  it('在链闸数量在数十条量级，且 v1 面 ⊂ 在链集', () => {
    expect(j.report.inChainGates).toBeGreaterThan(20)
    expect(j.report.v1FaceSize).toBeLessThan(j.report.inChainGates)
  })

  it('两条**旧判据漏掉的**闸确实被解析出来（must-hit，不是「都吐 JSON」）', () => {
    const rows = j.report.rows as Array<{ script: string; parsed: boolean; findings: number | null }>
    for (const name of ['audit-tokenized-diff', 'audit-translation-completeness']) {
      const row = rows.find((r) => r.script.includes(name))
      expect(row, name).toBeTruthy()
      expect(row!.parsed, name).toBe(true)
      expect(Number.isInteger(row!.findings), name).toBe(true)
    }
  })

  it('`report:gate-regression-face` 与收割器自己都不在分母里（must-not-hit）', () => {
    const s = JSON.stringify(j.report.rows)
    expect(s).not.toContain('gate-regression-face-inventory')
    expect(s).not.toContain('gate-output-harvest')
    for (const id of ['must-not-hit:report-gate-regression-face', 'must-not-hit:self']) {
      expect(j.report.controls.find((c: any) => c.id === id)?.pass, id).toBe(true)
    }
  })

  it('内建控制全过；v2 棘轮既无 stale 也无 orphan 也无 unexcused', () => {
    expect(j.report.controls.filter((c: any) => !c.pass)).toEqual([])
    expect(j.report.ratchet.stale).toEqual([])
    expect(j.report.ratchet.orphan).toEqual([])
    expect(j.report.ratchet.unexcused).toEqual([])
  })

  it('exit 0，且异常为空 —— ✅ 具名豁免表 2026-08-26 起已清空（终态，不是待办）', () => {
    expect(j.report.anomalies).toEqual([])
    // 🔴 **本断言 2026-08-26 从 `.toBeGreaterThan(0)` 翻面**，因为那张表被**闸自己**清空了：
    //   唯一那条豁免（`audit:figma-conformance` / `empty-denominator`，since 2026-08-24）
    //   在 [[INFRA-F139]] 把该闸 ① 的扫描面从空的 `src/canonical/generated/` 改指向
    //   `src/canonical/`（分母 0 → 39、verdict 转 clean）之后**不再命中** ⇒ 收割器判它 stale
    //   并 FAIL 要求删行 ⇒ 删掉后表空。
    //   ⇒ 这正是 shrink-only 设计要的结局：**不是人记得回来清的，是闸自己宣布的。**
    // ⛔ 别把它读成「豁免机制没用了」：`KNOWN_ANOMALIES` 与它上面那段注释刻意留着，
    //   下一条存量异常还要用。表空 = 终态。
    expect(j.report.excusedAnomalies).toEqual([])
    // 仍要钉住「新异常一条都没有」这个更强的事实：两者同时为空，才说明 exit 0 不是被豁免撑出来的。
    expect(j.status).toBe(0)
  })
})
