// tests/audit-deliverable-open-questions.test.ts
//
// 触发器 P 机械化（INFRA-F68, 2026-07-20）：交付物（handoff/PRD/UX 卡）禁止残留
// open-question 占位（TBD / 待 confirm / 待…拍板 / TODO / (?) / Open questions / 需进一步讨论）。
// 纯函数 findOpenQuestionViolations + isDeliverable，无 fs 副作用，便于 planted-drift 单测。
import { describe, it, expect } from 'vitest'
import { readFileSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { execFileSync, spawnSync } from 'node:child_process'
import {
  findOpenQuestionViolations,
  isDeliverable,
  listDeliverables,
  resolveDeliverableTargets,
} from '../scripts/audit-deliverable-open-questions.mjs'

describe('isDeliverable — 扫描 scope', () => {
  it('含独立成行 handoff 标记 → 是交付物', () => {
    expect(isDeliverable('<!-- mockup-handoff -->\n# x', 'foo.md')).toBe(true)
  })
  it('路径在 docs/handoffs/ 下 → 是交付物', () => {
    expect(isDeliverable('# x', 'docs/handoffs/rec.md')).toBe(true)
  })
  it('普通 plan/spec（无标记、非 handoffs 路径）→ 不是交付物', () => {
    expect(isDeliverable('# plan\nTBD everywhere', 'docs/internal/_plans/p.md')).toBe(false)
  })
  it('反引号内嵌的标记提及 不算 handoff（需独立成行）', () => {
    expect(isDeliverable('这里说 `<!-- mockup-handoff -->` 只是举例', 'x.md')).toBe(false)
  })
})

describe('findOpenQuestionViolations — 必须命中', () => {
  it('正文 TBD（V4-1865 回归）', () => {
    const v = findOpenQuestionViolations('# h\n\nToast wording TBD with PM.')
    expect(v).toHaveLength(1)
    expect(v[0].pattern).toBe('TBD')
  })
  it('待…拍板 / (?) / 需进一步讨论 / Open questions 标题', () => {
    const md = ['待 PM 拍板 the layout.', 'Color (?).', '需进一步讨论。', '## Open questions'].join('\n')
    const pats = findOpenQuestionViolations(md).map((x) => x.pattern)
    expect(pats).toContain('待…拍板')
    expect(pats).toContain('(?) 占位')
    expect(pats).toContain('需进一步讨论')
    expect(pats).toContain('Open questions 段')
  })
  it('整行单字 ? 占位', () => {
    expect(findOpenQuestionViolations('?').map((x) => x.pattern)).toContain('单字 ?')
  })
})

describe('findOpenQuestionViolations — 不可误报', () => {
  it('## Open scope items 段内的 TBD/TODO 豁免（§750 逃生口）', () => {
    const md = '## Spec\nresolved\n\n## Open scope items\n- Toast wording TBD with PM.\n- Bonding TODO.'
    expect(findOpenQuestionViolations(md)).toHaveLength(0)
  })
  it('Open scope items 段内的子标题不提前退出豁免', () => {
    const md = '## Open scope items\n### 子项\n- TBD here.'
    expect(findOpenQuestionViolations(md)).toHaveLength(0)
  })
  it('fenced code block 内的 TODO/TBD 豁免', () => {
    const md = '# h\n\n```js\n// TODO example\nconst x = 1 // TBD\n```\nclean.'
    expect(findOpenQuestionViolations(md)).toHaveLength(0)
  })
  it('inline code span 内的 TBD 豁免', () => {
    expect(findOpenQuestionViolations('gate blocks `TBD` in prose')).toHaveLength(0)
  })
  it('正常疑问句（句末 ?）不误报', () => {
    expect(findOpenQuestionViolations('Does the user tap twice? Yes.')).toHaveLength(0)
  })
  it('整文件白名单注释豁免', () => {
    const md = '<!-- audit-open-questions: allow file -->\nToast wording TBD with PM.'
    expect(findOpenQuestionViolations(md)).toHaveLength(0)
  })
  it('可能要（描述性散文）刻意不收，不误报', () => {
    expect(findOpenQuestionViolations('用户可能要点两次才能触发。')).toHaveLength(0)
  })
})

// ── `--all` 全仓扫描模式（INFRA-F116 第 4 批前置，2026-08-17）─────────────────────
//
// 为什么这组必须有：本闸在 pre-commit 是 `node <script> $staged_md` 形态，**无参时 files=[]、
// 恒 exit 0**。把它裸接进 CI 会得到一个永远绿的 step —— 比它要治的「L4-only」更糟。
// `--all` 就是为拆掉那个前提而加的，下面钉的是它的**分母与 fail-closed 语义**，不是它的判据
// （判据由上面那组纯函数测试守）。
describe('resolveDeliverableTargets / listDeliverables — --all 扫描面', () => {
  it('不传 --all 时只吃显式 .md 参数（pre-commit 语义不变）', () => {
    expect(resolveDeliverableTargets(['a.md', 'b.txt'])).toEqual({ files: ['a.md'], all: false })
  })
  it('不传 --all 且无参 → files=[]（这正是「裸接 CI 会假绿」的那个形态）', () => {
    expect(resolveDeliverableTargets([])).toEqual({ files: [], all: false })
  })
  it('--all 与显式文件同时给 → 显式优先，不去全仓枚举', () => {
    expect(resolveDeliverableTargets(['--all', 'x.md'])).toEqual({ files: ['x.md'], all: true })
  })

  it('--all 无显式文件 → 走全仓枚举，且**分母非空**（空 = 判定面塌了，不是干净）', () => {
    const hits = listDeliverables()
    expect(Array.isArray(hits)).toBe(true)
    expect(hits.length).toBeGreaterThan(0)
    // 真仓库非空过钉：枚举出来的每一份都必须真的通过 isDeliverable —— 防止哪天枚举退化成
    // 「所有 tracked .md」而测试仍然绿（那样分母会从 1 跳到几百，闸的语义整个变掉）。
    for (const f of hits) {
      expect(isDeliverable(readFileSync(f, 'utf8'), f), `${f} 不该在交付物集里`).toBe(true)
    }
    // 阴性对照：全仓 .md 远多于交付物 ⇒ 枚举确实在过滤，不是把 ls-files 原样返回。
    const allMd = execFileSync('git', ['ls-files', '-z', '*.md'], { encoding: 'utf8' })
      .split('\0')
      .filter(Boolean)
    expect(allMd.length).toBeGreaterThan(hits.length)
  })

  // 用子进程跑（vitest worker 里 process.chdir 不可用），cwd 设在一个非 git 目录。
  // ⛔ **必须把 GIT_* 环境变量剥掉**：本套单测会在 `.husky/pre-commit` 里被跑到，而 git 钩子
  //    会往环境里注 `GIT_DIR` / `GIT_INDEX_FILE` 等 —— 子进程继承后，`git ls-files` 在**任何**
  //    cwd 下都能成功（只是相对 cwd 的 `*.md` pathspec 匹配不到东西），于是「非 git 目录」这个
  //    前提当场不成立，测试会落进另一条 fail-closed 分支。2026-08-17 实测：单跑 `pnpm test` 绿、
  //    走 pre-commit 就红，差别只有这组环境变量。
  it('git 不可用时 fail closed（exit 1），⛔ 不静默返回空数组当「干净」', () => {
    const tmp = mkdtempSync(join(tmpdir(), 'f116-notgit-'))
    const script = join(process.cwd(), 'scripts', 'audit-deliverable-open-questions.mjs')
    const cleanEnv = Object.fromEntries(
      Object.entries(process.env).filter(([k]) => !k.startsWith('GIT_')),
    )
    try {
      const r = spawnSync(process.execPath, [script, '--all'], { cwd: tmp, encoding: 'utf8', env: cleanEnv })
      expect(r.status).toBe(1)
      // ⛔ 只断 exit 1 会**空过**（实测：把 listDeliverables 改成 git 失败时静默 `return []`，
      //    CLI 照样 exit 1 —— 因为它落进了「命中 0 份」那条 fail-closed，测试全绿）。
      //    ⇒ 必须断到**区分得开两条路径**的信号上：这里要的是 git 自己的报错，
      //    而**不是**「命中 0 份」那条。
      expect(r.stderr).toMatch(/not a git repository|fatal:/)
      expect(r.stderr + r.stdout).not.toMatch(/命中 0 份/)
      // 阴性对照：同一条命令在**真仓库**里跑必须 exit 0 —— 否则上面那个 1 可能来自别的错误，
      // 「fail closed」就成了空过的断言。
      const ok = spawnSync(process.execPath, [script, '--all'], { cwd: process.cwd(), encoding: 'utf8', env: cleanEnv })
      expect(ok.status).toBe(0)
    } finally {
      rmSync(tmp, { recursive: true, force: true })
    }
  })
})
