import { describe, it, expect } from 'vitest'
import { readFileSync, readdirSync } from 'node:fs'
import { resolve, join } from 'node:path'
// @ts-expect-error — .mjs 无类型声明，判据真源在脚本头注释
import { stripComment, collectCalls, computeParity } from '../scripts/audit-gate-ci-parity.mjs'

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

/** ⚠️ 与脚本里的 `L4_HOOKS` 同一份名单（2026-08-24 起含 `commit-msg`，INFRA-F123 ①）。 */
const L4_HOOKS = ['pre-commit', 'commit-msg']

function realInputs() {
  const hookTexts = L4_HOOKS.map((h) => readFileSync(join(REPO, '.husky', h), 'utf8'))
  const pkgScripts = JSON.parse(readFileSync(join(REPO, 'package.json'), 'utf8')).scripts
  const workflowTexts: string[] = []
  for (const dir of ['.gitea/workflows', '.github/workflows']) {
    for (const f of readdirSync(join(REPO, dir)).filter((x) => /\.ya?ml$/.test(x))) {
      workflowTexts.push(readFileSync(join(REPO, dir, f), 'utf8'))
    }
  }
  return { hookTexts, pkgScripts, workflowTexts }
}

/**
 * ⚠️ 本文件的第一条是**对真仓库现状**下的断言（不是内联 fixture）—— 它是这条闸在 vitest
 * 侧的非空过钉：pre-commit 上新增一条没接 CI 的闸时，这里会跟着红。
 * 其余用例是判据本身的单元测试，跑的是 fixture。
 */
describe('audit:gate-ci-parity — 真仓库现状', () => {
  it('husky 钩子上没有 CI 跑不到的闸（差集 = 0）', () => {
    const { diff } = computeParity(realInputs())
    expect(diff).toEqual([])
  })

  it('本闸自己在 A 里（自钉：A 的解析坏掉时差集会静默变 0）', () => {
    const { a } = computeParity(realInputs())
    expect(a).toContain('scripts/audit-gate-ci-parity.mjs')
  })
})

describe('stripComment', () => {
  it('词首的 # 起注释', () => {
    expect(stripComment('  # node scripts/audit-x.mjs').trim()).toBe('')
    expect(stripComment('node scripts/audit-x.mjs # 尾注释').trim()).toBe('node scripts/audit-x.mjs')
  })

  it('单引号里的 # 不算注释（grep 模式会带 #）', () => {
    const line = "if git diff | grep -qE '^a#b'; then node scripts/audit-x.mjs; fi"
    expect(stripComment(line)).toBe(line)
  })

  it('词中的 # 不算注释（如 anchor）', () => {
    expect(stripComment('run: node scripts/a.mjs --tag=v1#2')).toBe('run: node scripts/a.mjs --tag=v1#2')
  })
})

describe('collectCalls — 判据是调用位置，不是「提到过」', () => {
  const pkg = {
    'audit:foo': 'node scripts/audit-foo.mjs',
    'audit:chain': 'pnpm run audit:foo && node scripts/audit-bar.mjs',
    'audit:selfloop': 'pnpm run audit:selfloop',
    test: 'vitest run',
  }
  const call = (text: string) => {
    const out = new Set<string>()
    collectCalls(text, pkg, out, new Set())
    return [...out].sort()
  }

  it('直接 node 调用进集合', () => {
    expect(call('node scripts/audit-foo.mjs --all')).toEqual(['scripts/audit-foo.mjs'])
  })

  it('pnpm 别名按 package.json 递归展开（含嵌套一层）', () => {
    expect(call('pnpm run audit:chain')).toEqual(['scripts/audit-bar.mjs', 'scripts/audit-foo.mjs'])
    expect(call('pnpm audit:foo')).toEqual(['scripts/audit-foo.mjs'])
  })

  it('自引用的 npm script 不死循环', () => {
    expect(call('pnpm run audit:selfloop')).toEqual([])
  })

  // ⚠️ 这两条必须成对：只写下面那条转义形态的话，挡住匹配的是那个 `\`（不在路径字符类里）
  // 而**不是**「缺 node 前缀」—— 两种原因观测结果一样，判据被放宽成「文件里提到过」时这条
  // 照样绿（2026-08-17 故障注入实测：只有转义那条时，注入「去掉 node 前缀要求」16/16 全绿）。
  // 上面这条用**不转义**的路径，让「缺 node 前缀」成为唯一挡住它的理由。
  it('⛔ 触发面 grep 模式里的脚本路径不算调用 —— 不转义形态（唯一挡住它的是缺 node 前缀）', () => {
    expect(call("if git diff --cached | grep -qE '(docs/x.md|scripts/audit-foo.mjs)'; then")).toEqual([])
  })

  it('⛔ 同上，转义形态（本仓 pre-commit 触发面的实际写法）', () => {
    expect(call("git diff --cached | grep -qE 'scripts/audit-foo\\.mjs'")).toEqual([])
  })

  it('不解析到别的 npm script（pnpm exec / install 不是脚本名）', () => {
    expect(call('pnpm exec vue-tsc --noEmit')).toEqual([])
    expect(call('pnpm install --frozen-lockfile')).toEqual([])
    expect(call('pnpm test')).toEqual([])
  })

  it('figma-sync/ 下的闸同样算调用', () => {
    expect(call('node figma-sync/audit-design-system.mjs')).toEqual(['figma-sync/audit-design-system.mjs'])
  })
})

describe('computeParity — 差集与三条 fail-closed', () => {
  // 2026-08-25（第 9 步）：主链字符串搬到 `gate-chain`，`prepublishOnly` 变成 runner 入口。
  // 两个都给 —— 闸对「入口被改回 `&&` 串」是 fail-closed 的，少给入口会撞那条而不是本条断言。
  const pkgScripts = {
    'gate-chain': 'node scripts/audit-b.mjs',
    prepublishOnly: 'node scripts/run-gate-chain.mjs',
  }

  it('只挂钩子、CI 跑不到的闸落进差集', () => {
    const r = computeParity({
      hookTexts: ['node scripts/audit-a.mjs\nnode scripts/audit-b.mjs\n'],
      pkgScripts,
      workflowTexts: ['run: node scripts/audit-b.mjs\n'],
    })
    expect(r.diff).toEqual(['scripts/audit-a.mjs'])
  })

  it('⛔ workflow **注释**里出现闸名不算接线（差集不因此变 0）', () => {
    const r = computeParity({
      hookTexts: ['node scripts/audit-a.mjs\n'],
      pkgScripts,
      workflowTexts: ['        # 叙述性提及：node scripts/audit-a.mjs\n'],
    })
    expect(r.diff).toEqual(['scripts/audit-a.mjs'])
  })

  it('fail closed ①：钩子里解析不出闸 → 抛错，不是「干净」', () => {
    expect(() =>
      computeParity({ hookTexts: ['echo hi\n'], pkgScripts, workflowTexts: ['run: node scripts/audit-b.mjs'] }),
    ).toThrow(/判定面塌了/)
  })

  it('fail closed ②：步骤面真源 `gate-chain` 缺失 → 抛错（与 ① 的错误串区分得开）', () => {
    expect(() =>
      computeParity({
        hookTexts: ['node scripts/audit-a.mjs\n'],
        pkgScripts: { prepublishOnly: 'node scripts/run-gate-chain.mjs' },
        workflowTexts: ['run: node scripts/audit-a.mjs'],
      }),
    ).toThrow(/gate-chain/)
  })

  it('fail closed ②b：入口被写回 `&&` 串（退回 fail-fast）→ 抛错', () => {
    // 这一条是第 9 步新增的逃逸口：`gate-chain` 还在、算得也对，但入口不再跑 runner
    // ⇒「每条闸都上场」的性质悄悄没了，而差集照样是 0。⛔ 必须有东西响。
    expect(() =>
      computeParity({
        hookTexts: ['node scripts/audit-a.mjs\n'],
        pkgScripts: { 'gate-chain': 'node scripts/audit-a.mjs', prepublishOnly: 'pnpm run audit:a && pnpm run audit:b' },
        workflowTexts: ['run: node scripts/audit-a.mjs'],
      }),
    ).toThrow(/fail-fast/)
  })

  it('fail closed ③：一份 workflow 都没读到 → 抛错（与 ①② 区分得开）', () => {
    expect(() =>
      computeParity({ hookTexts: ['node scripts/audit-a.mjs\n'], pkgScripts, workflowTexts: [] }),
    ).toThrow(/workflow/)
  })
})

/**
 * A 侧的钩子家族（2026-08-24，INFRA-F123 ①）。
 * 扩面的理由：`commit-msg` 上的闸原本对本元闸**天然隐形** —— 它可以只有 L4 而本闸照样绿，
 * 那正是 INFRA-F116 治过的「永久逃逸」换了个钩子名重演。
 */
describe('computeParity — A 侧是钩子家族不只 pre-commit', () => {
  // 2026-08-25（第 9 步）：主链字符串搬到 `gate-chain`，`prepublishOnly` 变成 runner 入口。
  // 两个都给 —— 闸对「入口被改回 `&&` 串」是 fail-closed 的，少给入口会撞那条而不是本条断言。
  const pkgScripts = {
    'gate-chain': 'node scripts/audit-b.mjs',
    prepublishOnly: 'node scripts/run-gate-chain.mjs',
  }

  it('第二个钩子上的闸也进 A（必中对照）', () => {
    const { a } = computeParity({
      hookTexts: ['node scripts/audit-b.mjs\n', 'node scripts/audit-commit-affordance-trace.mjs\n'],
      pkgScripts,
      workflowTexts: ['run: node scripts/audit-b.mjs\nrun: node scripts/audit-commit-affordance-trace.mjs\n'],
    })
    expect(a).toContain('scripts/audit-commit-affordance-trace.mjs')
  })

  it('第二个钩子上的闸没接 CI ⇒ 进差集（必不中对照：这才是扩面要抓的那件事）', () => {
    const { diff } = computeParity({
      hookTexts: ['node scripts/audit-b.mjs\n', 'node scripts/only-on-commit-msg.mjs\n'],
      pkgScripts,
      workflowTexts: ['run: node scripts/audit-b.mjs\n'],
    })
    expect(diff).toEqual(['scripts/only-on-commit-msg.mjs'])
  })

  it('hookTexts 为空数组 ⇒ 抛错（A 侧输入面塌了，不是「钩子上没闸」）', () => {
    expect(() =>
      computeParity({ hookTexts: [], pkgScripts, workflowTexts: ['run: node scripts/audit-b.mjs'] }),
    ).toThrow(/A 侧的输入面塌了/)
  })

  it('真仓库：commit-msg 上那条闸确实在 A 里（非空过钉）', () => {
    const { a } = computeParity(realInputs())
    expect(a).toContain('scripts/audit-commit-affordance-trace.mjs')
  })
})
