import { describe, it, expect, afterEach } from 'vitest'
import { execFileSync } from 'node:child_process'
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { sliceActiveBody, countOpenEntries } from '../scripts/lib/backlog-open-entries.mjs'

const REPO_ROOT = resolve(__dirname, '..')
const realBacklog = () => readFileSync(resolve(REPO_ROOT, 'docs/internal/backlog.md'), 'utf-8')
const realStatus = () => readFileSync(resolve(REPO_ROOT, 'docs/STATUS.md'), 'utf-8')

describe('sliceActiveBody', () => {
  it('取 "## Active" 到下一个 H2 之前', () => {
    const body = sliceActiveBody(['# t', '## Other', '### X: a', '', '## Active', '### A: a', '', '## Resolved', '### B: b'].join('\n'))
    expect(body).toContain('### A: a')
    expect(body).not.toContain('### B: b')
    expect(body).not.toContain('### X: a')
  })

  it('找不到 "## Active" → null（fail-closed 交给调用方判）', () => {
    expect(sliceActiveBody('# t\n### A: a')).toBeNull()
  })
})

describe('countOpenEntries — C4 闭合约定 = 完成即删档（owner 2026-08-03 裁定）', () => {
  it('删除线 heading 不再被当作合法闭合形态，照样算 open', () => {
    const body = '### A: x\n### ~~B: y~~ → ✅ 已完成\n'
    expect(countOpenEntries(body)).toBe(2)
  })

  it('只数 "### "，不数 H2 / H4 / 正文里的 ###', () => {
    const body = '## Active\n### A: x\n#### A.1 子标题\n正文里写 ### 不算\n### B: y\n'
    expect(countOpenEntries(body)).toBe(2)
  })

  it('真仓库 ## Active 段内不得再有 "### ~~" heading（闭合 = 删档，不是划线）', () => {
    const body = sliceActiveBody(realBacklog())!
    const struck = body.split('\n').filter((l) => l.startsWith('### ~~'))
    expect(struck).toEqual([])
  })

  it('真仓库：countOpenEntries 与 STATUS 声明的计数镜像一致（C4 同判据回归钉）', () => {
    const open = countOpenEntries(sliceActiveBody(realBacklog())!)
    const declared = Number(realStatus().match(/Backlog \(active\)\s*\((\d+)\)/)![1])
    expect(open).toBe(declared)
  })
})

// ─────────────────────────────────────────────────────────────────────────────
// C4 / C5 / C6 的**整脚本**回归面：把闸拷进一个临时 fixture repo root 里跑。
//
// 为什么不是「先把两臂抽进 scripts/lib 再单测」（tracker 2026-08-21 那条 ⏳ 登记的原路线）：
//   ① 那条路要改一个 L4+L5 活闸的结构，收益只到「判据逻辑」这一层；
//   ② 它**结构上测不到接线** —— 把 C5 整块从脚本里删掉，纯函数单测照样全绿。
//      `meta-rules.md` 触发器 R §违反检测 逐字列的就是这一条：「单测只直接调纯函数、
//      从不经过 main() 的提前 return，却据此宣称出口顺序正确」= ❌。
//   本 harness 反过来：**零改动活闸**，跑的是脚本本体（每次运行时从仓库拷过来的同一份字节），
//   判据逻辑与接线一起覆盖。判据面 = 闸自己读的那 5 个文件，全部由 fixture 提供。
//
// 它为什么能跑：闸的 REPO_ROOT 取自 `fileURLToPath(import.meta.url)` 的父目录，⛔ 不是 cwd
//   ⇒ 拷到 <tmp>/scripts/ 之后它读的就是 <tmp> 下的 5 个文件。`scripts/lib` 软链回真仓库，
//   所以 lib 侧的改动照样会被这套用例看见（⛔ 别改成拷贝，那会留一份会漂的副本）。
//   绿档用例断言的是 fixture 自己的数（Active 3 / yes 2 / 计数处 2）—— 若哪天 harness 退化成
//   跑真仓库，那几个断言当场红（真仓库是两位数），所以这一条同时是「非空过」的钉子。
//
// ⚠️ 它测不到的（如实登记，⛔ 别当已覆盖）：
//   · **挂载层**（L4 pre-commit 触发面 / L5 pr-checks 接线）—— 判据面在
//     `audit:gate-ci-parity` + `audit:gate-mount-declaration`，不在本文件；
//   · **C1 / C1b / C2 / C3 四臂本节没造用例** —— 本轮登记的缺口是 C5/C6，C4 只补一条接线钉；
//   · **真仓库当下是否通过** —— 那是闸自己在 pre-commit / CI 里跑的事，⛔ 不该在这里再抄一份判据。
// ⚠️ 另一处如实登记的不对称：C6 的判据面是 `QUEUE.slice(indexOf('\n## Active'))`，**不截到下一个 H2**
//   （C4 那侧的 sliceActiveBody 会截）。方向是安全的（将来 queue 长出 `## Resolved` 且条目没标记 ⇒ C6a 红，
//   不是静默变绿），本节不动它。
const GATE = resolve(REPO_ROOT, 'scripts/audit-status-consistency.mjs')
const FENCE = '```'

type QueueEntry = { title: string; marks: string[] }
type Fixture = {
  version?: string
  changelogVersion?: string
  backlogEntries?: string[]
  queueEntries?: QueueEntry[]
  /** 传 false ⇒ queue 里根本没有 `## Active` 段（判据面塌） */
  queueHasActive?: boolean
  /** 传 null ⇒ STATUS 顶部不写 `> **Last updated**` 那一行 */
  summaryLine?: string | null
  backlogMirror?: number
  /** 传 null ⇒ Active 块里不写 `design-review-queue (N)` 那一行 */
  queueMirror?: number | null
  /** 散文面 `design-review-queue N 条` 逐处的数；[] = 零处 */
  proseCounts?: number[]
  /** 额外塞进 STATUS 正文的行（做锚点阴性对照用） */
  extraStatusBody?: string[]
}

const roots: string[] = []
afterEach(() => {
  while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
})

function buildFixture(f: Fixture = {}): string {
  const version = f.version ?? '1.2.0'
  const changelogVersion = f.changelogVersion ?? version
  const backlogEntries = f.backlogEntries ?? ['INFRA-A: a', 'INFRA-B: b', 'INFRA-C: c']
  const queueEntries =
    f.queueEntries ?? [
      { title: '1. off-scale px', marks: ['yes'] },
      { title: '2. padding outlier', marks: ['yes'] },
      { title: '3. 基础设施缺口', marks: ['no'] },
    ]
  const summaryLine =
    f.summaryLine === undefined
      ? '> **Last updated**: 2026-08-24 —— 本轮摘要（**Active 2 → 3**）。'
      : f.summaryLine
  const backlogMirror = f.backlogMirror ?? 3
  const queueMirror = f.queueMirror === undefined ? 2 : f.queueMirror
  const proseCounts = f.proseCounts ?? [2, 2]

  const root = mkdtempSync(join(tmpdir(), 'status-consistency-fx-'))
  roots.push(root)
  mkdirSync(join(root, 'scripts'))
  mkdirSync(join(root, 'docs/internal'), { recursive: true })
  copyFileSync(GATE, join(root, 'scripts/audit-status-consistency.mjs'))
  symlinkSync(resolve(REPO_ROOT, 'scripts/lib'), join(root, 'scripts/lib'))

  writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'fx', version }))
  writeFileSync(join(root, 'CHANGELOG.md'), `# Changelog\n\n## ${changelogVersion}\n\n- x\n`)

  writeFileSync(
    join(root, 'docs/internal/backlog.md'),
    ['# Backlog', '', '## Active', '', ...backlogEntries.flatMap((t) => [`### ${t}`, '正文', '']), '## Resolved', '', '### 早就删档的: x', ''].join('\n'),
  )

  writeFileSync(
    join(root, 'docs/internal/design-review-queue.md'),
    [
      '# Design review queue',
      '',
      '> 说明行里提到 `designer-action: yes|no` 不算标记（在 ## Active 之前）。',
      '',
      ...(f.queueHasActive === false ? ['## 观察（没有 Active 段）'] : ['## Active 观察']),
      '',
      ...queueEntries.flatMap((e) => [`### ${e.title}`, ...e.marks.map((m) => `\`designer-action: ${m}\``), '']),
    ].join('\n'),
  )

  writeFileSync(
    join(root, 'docs/STATUS.md'),
    [
      '# TVU Design System — Status（fixture）',
      '',
      ...(summaryLine === null ? [] : [summaryLine]),
      '',
      `## 当前版本：v${version}（✅ published & verified 2026-07-31）`,
      '',
      '## Active 后续工作',
      '',
      FENCE,
      `Backlog (active)  (${backlogMirror}):  INFRA- A·B·C`,
      ...(queueMirror === null ? [] : [`design-review-queue (${queueMirror}):  设计侧待 designer 动手`]),
      FENCE,
      '',
      ...proseCounts.map((n) => `- 散文镜像：design-review-queue ${n} 条待设计师动手。`),
      ...(f.extraStatusBody ?? []),
      '',
    ].join('\n'),
  )

  return root
}

type Run = { status: number; stdout: string; stderr: string }
function runGate(root: string): Run {
  try {
    const stdout = execFileSync('node', [join(root, 'scripts/audit-status-consistency.mjs')], { encoding: 'utf8' })
    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 ?? '' }
  }
}

/** 断言闸红**且点名的是这几条 check**（⛔ 不只看退出码 —— ENOENT 崩溃也是非零，不能当判据开火） */
function expectRed(run: Run, ...checks: string[]) {
  expect(run.status).toBe(1)
  expect(run.stderr).toContain('STATUS.md consistency audit failed')
  for (const c of checks) expect(run.stderr).toContain(`[${c}]`)
}

describe('整脚本 fixture 回归：绿档 + 自印非空过', () => {
  it('五个文件都自洽 → exit 0，且自印的是 fixture 自己的数（≠ 真仓库 ⇒ 非空过）', () => {
    const run = runGate(buildFixture())
    expect(run.status).toBe(0)
    expect(run.stdout).toContain('✓ STATUS.md consistency')
    // 这一行同时钉住四个判据面的活源读数；跑错了树这四个数不可能同时对上
    expect(run.stdout).toContain('backlog Active 3 · queue 条目 3（designer-action: yes 2）')
    expect(run.stdout).toContain('STATUS 散文面 design-review-queue 计数处 2')
    expect(run.stdout).toContain('顶部摘要 Active N → M 1 处')
  })
})

describe('C4 接线钉（lib 有单测，调用点此前无任何测试）', () => {
  it('Active 块声明 2 而 backlog 有 3 条 → C4 报漂移', () => {
    expectRed(runGate(buildFixture({ backlogMirror: 2 })), 'C4-backlog-count-drift')
  })
})

// C5 = INFRA-F135。四个分支逐个单侧打；⛔ 不许只打「全都坏」那一种（触发器 R #3）。
describe('C5：顶部摘要 `Active N → M` 的终值 vs 活源', () => {
  it('缺 `> **Last updated**` 摘要行 → fail closed', () => {
    expectRed(runGate(buildFixture({ summaryLine: null })), 'C5-summary-line-missing')
  })

  it('摘要行在、但一处 `Active N → M` 都没有 → fail closed（这是本臂专门要堵的逃逸口）', () => {
    expectRed(
      runGate(buildFixture({ summaryLine: '> **Last updated**: 2026-08-24 —— 今天不写这个数。' })),
      'C5-summary-active-count-missing',
    )
  })

  it('摘要行出现 2 处 `Active N → M` → 判红而不是挑一个（哪个是本轮无法机械确定）', () => {
    expectRed(
      runGate(
        buildFixture({
          summaryLine: '> **Last updated**: 2026-08-24 —— 上半场 Active 2 → 3，下半场 Active 3 → 3。',
        }),
      ),
      'C5-summary-active-count-ambiguous',
    )
  })

  it('终值 != 活源条目数 → 报漂移', () => {
    expectRed(
      runGate(buildFixture({ summaryLine: '> **Last updated**: 2026-08-24 —— 本轮（Active 2 → 9）。' })),
      'C5-summary-active-count-drift',
    )
  })

  // 锚点：本臂只读摘要那一行。下面两条是一对（阳性 + 阴性），拆掉锚点会各自从一侧红。
  it('锚点阳性：`Active N → M` 只出现在正文、不在摘要行 → 仍报 missing', () => {
    expectRed(
      runGate(
        buildFixture({
          summaryLine: '> **Last updated**: 2026-08-24 —— 本轮摘要。',
          extraStatusBody: ['正文里提了一句 Active 2 → 3，但那不是摘要行。'],
        }),
      ),
      'C5-summary-active-count-missing',
    )
  })

  it('锚点阴性：摘要行正确 + 正文另有一处 stale `Active 9 → 9` → 仍然绿（⛔ 不许扩到全文扫）', () => {
    const run = runGate(buildFixture({ extraStatusBody: ['历史叙述：那一轮 Active 9 → 9。'] }))
    expect(run.status).toBe(0)
    expect(run.stdout).toContain('顶部摘要 Active N → M 1 处')
  })
})

// C6 = INFRA-F136。判据面 = queue 里 `designer-action: yes` 的条数，⛔ 不是裸数 `^### `。
describe('C6：design-review-queue 三处计数镜像 vs `designer-action` 标记', () => {
  it('C6a 缺标记的条目 → 红（否则判据面会静默缩小 = 假绿方向）', () => {
    expectRed(
      runGate(
        buildFixture({
          queueEntries: [
            { title: '1. 有标记', marks: ['yes'] },
            { title: '2. 忘了标记', marks: [] },
          ],
        }),
      ),
      'C6a-queue-entry-marker',
    )
  })

  it('C6a 一个条目两个标记 → 红（`marks.length !== 1`，不是 `> 1`）', () => {
    expectRed(
      runGate(
        buildFixture({
          queueEntries: [
            { title: '1. 双标记', marks: ['yes', 'no'] },
            { title: '2. 正常', marks: ['yes'] },
          ],
        }),
      ),
      'C6a-queue-entry-marker',
    )
  })

  it('C6b Active 块里没有 `design-review-queue (N)` 行 → fail closed', () => {
    expectRed(runGate(buildFixture({ queueMirror: null })), 'C6b-status-queue-line-extract')
  })

  it('C6b 镜像数 != yes 条数 → 报漂移', () => {
    expectRed(runGate(buildFixture({ queueMirror: 3 })), 'C6b-status-queue-count-drift')
  })

  it('C6c 散文面零处 `design-review-queue N 条` → fail closed（判据面塌了不是「干净」）', () => {
    expectRed(runGate(buildFixture({ proseCounts: [] })), 'C6c-status-queue-prose-extract')
  })

  // F136 那条 entry 只枚举了两处镜像、实测三处，所以判据逐字是「吃所有出现处」。
  // 这一条把「只核第一处」这种退化钉死：第一处对、第二处 stale。
  it('C6c 第一处对、第二处 stale → 仍报漂移（⛔ 不是只核第一处）', () => {
    const run = runGate(buildFixture({ proseCounts: [2, 3] }))
    expectRed(run, 'C6c-status-queue-prose-drift')
    expect(run.stderr).toContain('"design-review-queue 3 条"')
    // 只该报第二处那一条，报两条说明第一处被误判
    expect(run.stderr.match(/C6c-status-queue-prose-drift/g)).toHaveLength(1)
  })

  it('queue 缺 `## Active` 段 → fail closed（判据面塌了）', () => {
    expectRed(runGate(buildFixture({ queueHasActive: false })), 'C6-queue-active-section-extract')
  })

  it('`designer-action: no` 不计入：把一条 no 改成 yes 而镜像没跟 → C6b 与 C6c 同时红', () => {
    const run = runGate(
      buildFixture({
        queueEntries: [
          { title: '1. a', marks: ['yes'] },
          { title: '2. b', marks: ['yes'] },
          { title: '3. 原本是 no', marks: ['yes'] },
        ],
      }),
    )
    expectRed(run, 'C6b-status-queue-count-drift', 'C6c-status-queue-prose-drift')
  })
})
