// tests/audit-expression-style-mirrors.test.ts
// -----------------------------------------------------------------------------
// `audit:expression-style-mirrors` 的**整脚本**回归面（E-fx）。
//
// 为什么走 fixture-root harness 而不是 import 判据函数：只 import 符号的测试**看不见接线**
// —— 摘掉 `main()` 的接线后闸对真违例照印 PASS，而全量 vitest 全绿（[[INFRA-F138]] 实证）。
// 本文件 spawn 整个脚本，被测对象一行不改。
//
// ⚠️ 镜像根走**环境变量** `TVU_EXPRESSION_MIRROR_ROOT`，不走 `runGate` 的 args ——
//    harness 明令「args 只许传 npm script 里真的传了的那些值」，而本闸的 npm key 不传 args。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterAll } from 'vitest'
import { execFileSync } from 'node:child_process'
import { join } from 'node:path'
import {
  createGateFixture, writeFixtureFile, cleanupGateFixtures, gitCleanEnv,
} from './lib/gate-fixture-root'

const GATE = 'scripts/audit-expression-style-mirrors.mjs'
const RECEIPT = 'docs/internal/_generated/expression-style-mirrors.status.json'

/** 最小真源：一个 `## AI 表达风格` H2 + 编号列表。条目故意用与真仓库不同的措辞 —— */
/** 探针值不得与仓库现有内容重合，否则「闸读到的是 fixture 还是真仓库」分不开。 */
const TITLES = ['甲项占位规则', '乙项占位规则', '丙项占位规则']

function agentsMd(titles: string[] = TITLES, heading = '## AI 表达风格（任何输出 mandatory）') {
  return [
    '# AI Agents 必读',
    '',
    '## 别的小节',
    '正文。',
    '',
    heading,
    '',
    '**三条**：',
    '',
    ...titles.map((t, i) => `${i + 1}. **${t}** —— 这是第 ${i + 1} 条的说明文字，三处有意不同。`),
    '',
    '## 后一个小节',
    '正文。',
  ].join('\n')
}

function mirrorMd(titles: string[] = TITLES) {
  return [
    '# Global Rules',
    '',
    '## 表达风格（任何输出 mandatory · 全局）',
    '',
    ...titles.map((t, i) => `${i + 1}. **${t}** —— 镜像侧说明措辞不同，这是有意的。`),
    '',
    '## 别的',
  ].join('\n')
}

/** 收据（由同步器写的那份的等价形态）。 */
function receipt(fingerprintHex: string, titles: string[] = TITLES) {
  return JSON.stringify({
    sourceFile: 'AGENTS.md',
    sourceFingerprint: fingerprintHex,
    titles,
    mirrors: [{ path: '~/.claude/CLAUDE.md', matched: true }],
    verifiedAt: '2026-09-10',
  }, null, 2)
}

/** 用闸自己导出的 fingerprint 算期望值 —— ⛔ 别在测试里手抄一个 sha256 常量。 */
async function fpOf(titles: string[]): Promise<string> {
  const mod = await import('../scripts/audit-expression-style-mirrors.mjs')
  return (mod as { fingerprint: (t: string[]) => string }).fingerprint(titles)
}

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

/** spawn 整脚本；镜像根经 env 注入。 */
function runGateWithMirrors(root: string, mirrorRoot: string | null): Run {
  const env = { ...gitCleanEnv() }
  if (mirrorRoot) env.TVU_EXPRESSION_MIRROR_ROOT = mirrorRoot
  else delete env.TVU_EXPRESSION_MIRROR_ROOT
  try {
    const stdout = execFileSync('node', [join(root, GATE)], { encoding: 'utf8', cwd: root, env })
    return { status: 0, stdout, stderr: '' }
  } catch (e) {
    const err = e as { status: number | null; stdout?: string; stderr?: string }
    return { status: err.status ?? -1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }
  }
}

async function makeFixture(opts: {
  sourceTitles?: string[]
  receiptTitles?: string[]
  heading?: string
  withReceipt?: boolean
} = {}) {
  const sourceTitles = opts.sourceTitles ?? TITLES
  const receiptTitles = opts.receiptTitles ?? sourceTitles
  const files: Record<string, string> = {
    'AGENTS.md': agentsMd(sourceTitles, opts.heading),
  }
  if (opts.withReceipt !== false) files[RECEIPT] = receipt(await fpOf(receiptTitles), receiptTitles)
  // ⛔ 不用 copyFiles 拷 `scripts/lib/is-cli-entry.mjs`：它落在 linkDirs 默认软链的
  //    `scripts/lib` 下（撞 EEXIST），且那批 lib 不自己算 REPO_ROOT ⇒ 软链是安全的。
  const root = createGateFixture({
    gate: GATE,
    prefix: 'expr-style-mirrors',
    files,
  })
  // 假 HOME：镜像放在 fixture 内，测试可控
  const fakeHome = join(root, 'fakehome')
  writeFixtureFile(root, 'fakehome/.claude/CLAUDE.md', mirrorMd())
  writeFixtureFile(root, 'fakehome/.claude/prompt_roles.md', mirrorMd())
  return { root, fakeHome }
}

afterAll(() => cleanupGateFixtures())

describe('audit:expression-style-mirrors — 整脚本回归面', () => {
  it('对照臂：三方一致 + 收据匹配 ⇒ 绿（证明量具不是恒红）', async () => {
    const { root, fakeHome } = await makeFixture()
    const run = runGateWithMirrors(root, fakeHome)
    expect(run.status).toBe(0)
    expect(run.stdout).toContain('PASS')
    // 钉一个「只有真跑过才有」的读数：⛔ exit 0 + 空输出不算绿
    expect(run.stdout).toContain('镜像 2/2 已校')
  })

  it('S4：镜像与真源分叉 ⇒ 红并点名是第几条', async () => {
    const { root, fakeHome } = await makeFixture()
    writeFixtureFile(root, 'fakehome/.claude/CLAUDE.md',
      mirrorMd([TITLES[0], '乙项占位规则被人改过了', TITLES[2]]))
    const run = runGateWithMirrors(root, fakeHome)
    expect(run.status).toBe(1)
    expect(run.stderr).toContain('S4')
    expect(run.stderr).toContain('第2条')
    expect(run.stderr).toContain('乙项占位规则被人改过了')
  })

  it('S4：镜像整节被删 ⇒ 红（不是当作「不可达」静默跳过）', async () => {
    const { root, fakeHome } = await makeFixture()
    writeFixtureFile(root, 'fakehome/.claude/CLAUDE.md', '# Global Rules\n\n## 别的\n正文。\n')
    const run = runGateWithMirrors(root, fakeHome)
    expect(run.status).toBe(1)
    expect(run.stderr).toContain('S4')
  })

  it('S3：真源改了、收据没跟 ⇒ 红（镜像可达时）', async () => {
    const { root, fakeHome } = await makeFixture()
    // 只动真源，收据留在旧指纹上
    writeFixtureFile(root, 'AGENTS.md', agentsMd([...TITLES, '丁项新加的第四条']))
    const run = runGateWithMirrors(root, fakeHome)
    expect(run.status).toBe(1)
    expect(run.stderr).toContain('S3')
    expect(run.stderr).toContain('sync:expression-style-mirrors')
  })

  it('★ 承重点 —— CI 形态（镜像全不可达）下，真源改了收据没跟仍然红', async () => {
    const { root } = await makeFixture()
    writeFixtureFile(root, 'AGENTS.md', agentsMd([...TITLES, '丁项新加的第四条']))
    // 指向一个不存在的 HOME ⇒ 两个镜像都不可达，复刻 Gitea/GitHub runner 上的形态
    const run = runGateWithMirrors(root, join(root, 'no-such-home'))
    expect(run.status).toBe(1)
    expect(run.stderr).toContain('S3')
    // 且必须显式印降级，⛔ 不许静默（本仓登记过的假绿形态）
    expect(run.stdout).toContain('跳过')
    expect(run.stdout).toContain('没有')
  })

  it('S5：镜像全不可达但真源与收据一致 ⇒ 绿，但必须显式说明只跑了 S1–S3', async () => {
    const { root } = await makeFixture()
    const run = runGateWithMirrors(root, join(root, 'no-such-home'))
    expect(run.status).toBe(0)
    expect(run.stdout).toContain('镜像 0/2 已校')
    expect(run.stdout).toContain('S1–S3')
  })

  it('S2：收据缺失 ⇒ 红（不是「没有就跳过」）', async () => {
    const { root, fakeHome } = await makeFixture({ withReceipt: false })
    const run = runGateWithMirrors(root, fakeHome)
    expect(run.status).toBe(1)
    expect(run.stderr).toContain('S2')
  })

  it('S1 fail-closed：真源整节不存在 ⇒ 红且不静默 exit 0', async () => {
    const { root, fakeHome } = await makeFixture()
    writeFixtureFile(root, 'AGENTS.md', '# AI Agents 必读\n\n## 别的小节\n正文。\n')
    const run = runGateWithMirrors(root, fakeHome)
    expect(run.status).toBe(1)
    expect(run.stderr).toContain('S1')
  })

  it('S1 fail-closed：出现两个同名 H2（歧义）⇒ 红，不许挑一个继续', async () => {
    const { root, fakeHome } = await makeFixture()
    writeFixtureFile(root, 'AGENTS.md',
      agentsMd() + '\n\n## 表达风格（重复的第二个）\n\n1. **甲项占位规则** —— 重复节。\n')
    const run = runGateWithMirrors(root, fakeHome)
    expect(run.status).toBe(1)
    expect(run.stderr).toContain('S1')
  })

  it('接线在场：闸真的读的是 fixture 而不是真仓库（探针词只存在于 fixture）', async () => {
    const { root, fakeHome } = await makeFixture()
    const run = runGateWithMirrors(root, fakeHome)
    expect(run.status).toBe(0)
    // 真仓库的真源是七条，fixture 是三条 —— 读错对象这条会失败
    expect(run.stdout).toContain('3 条')
  })
})
