// cleanup-unpublished 的整脚本 fixture-root harness（INFRA-F145）。
//
// 为什么是「整脚本」而不是抽纯函数单测：本闸在本次之前**零回归面**
// （`grep -rln 'cleanup-unpublished' tests/` 只命中 IconRegistry.test.ts，那是引它当叙述）。
// 抽 `classify()` 出来测覆盖不到**接线** —— 判据换对了但没接上去，单测照样全绿。
// ⇒ 走 fixture root：造一棵最小 figma-data 树，用 `cwd` 指过去真跑脚本，
//    把「判据 + 读哪个文件 + fail-closed 分支 + 报告措辞」一起覆盖。
//
// 落地顺序（memory regression-pass-needs-fault-proof）：先写「证明故障存在」的那条并让它 PASS
// （commit 77f4cacc），再改活闸把它翻转成下面的【已修】。
//
// ⚠️ **本 harness 的覆盖边界（如实登记，不是待办）** —— 2026-09-03 四轮故障注入实测：
//   · 停用 fileKey 校验    → 只红【fail-closed ②】
//   · 停用 fetchedAt 校验  → 只红【fail-closed ③】
//   · 停用 process.exit(2) → 红【fail-closed ①②③④】
//   · **移除 `pageName === ICONS_PAGE` 过滤 → 一条都不红（10 passed）**
//     ⇒ 本 harness **看不见跨页混淆**：fixture 里所有快照 records 都在「— — Icons」页，
//       所以「把别的页的 nodeId 也算进已发布图标集」这个缺陷它抓不到。
//       要覆盖它得让 fixture 造多页快照 —— ⛔ 别把这条边界读成「已覆盖」。
import { describe, it, expect } from 'vitest'
import { execFileSync } from 'node:child_process'
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

const SCRIPT = resolve(dirname(fileURLToPath(import.meta.url)), '../figma-sync/cleanup-unpublished.mjs')

/**
 * 造一棵最小 figma-data 树。
 * @param icons          raw 里「— — Icons」页上的 nodeId 列表
 * @param publishedIcons **旧**输入面（published/icons/manifest.json = 本管线上一轮产物）认识的。
 *                       它**永远被写出来**，这样「有人把判据偷偷改回去」会被下面的测试抓到。
 * @param snapshot       **新**输入面（components-rest.json = Figma 侧真源快照）里 Icons 页的 nodeId。
 *                       `undefined` = 与 publishedIcons 同集合；`null` = 不写该文件（测 fail-closed）。
 */
function makeFixture({
  icons,
  publishedIcons,
  snapshot = undefined,
  fetchedAt = '2026-09-03T00:00:00.000Z',
  extractedAt = '2026-07-29T09:10:16.245Z',
  fileKey = 'TESTKEY',
}: {
  icons: string[]
  publishedIcons: string[]
  snapshot?: string[] | null
  fetchedAt?: string
  extractedAt?: string
  fileKey?: string
}) {
  const root = mkdtempSync(join(tmpdir(), 'f145-'))
  mkdirSync(join(root, 'figma-data/raw/components'), { recursive: true })
  mkdirSync(join(root, 'figma-data/published/icons'), { recursive: true })

  const components = icons.map((id) => ({
    figmaName: `icon/test/${id}`,
    nodeId: id,
    type: 'COMPONENT',
    pageName: '— — Icons',
    filename: `icon-${id.replace(':', '_')}.json`,
    variants: 0,
  }))
  for (const c of components) writeFileSync(join(root, 'figma-data/raw/components', c.filename), '{}')
  writeFileSync(
    join(root, 'figma-data/raw/components.index.json'),
    JSON.stringify({ extractedAt, figmaFileKey: fileKey, count: components.length, components }, null, 2),
  )
  // 旧输入面照旧写出来 —— 它必须**留在原地却不再被读**
  writeFileSync(
    join(root, 'figma-data/published/icons/manifest.json'),
    JSON.stringify(
      {
        generatedAt: '2026-06-09T00:00:00.000Z',
        count: publishedIcons.length,
        records: publishedIcons.map((id) => ({ nodeId: id })),
      },
      null,
      2,
    ),
  )
  if (snapshot !== null) {
    const ids = snapshot ?? publishedIcons
    writeFileSync(
      join(root, 'figma-data/published/components-rest.json'),
      JSON.stringify(
        {
          fetchedAt,
          figmaFileKey: fileKey,
          source: 'rest:/v1/files/:key/components',
          count: ids.length,
          byPage: { '— — Icons': ids.length },
          records: ids.map((id) => ({ nodeId: id, figmaName: `icon/test/${id}`, pageName: '— — Icons' })),
        },
        null,
        2,
      ),
    )
  }
  return root
}

function runCleanupIn(root: string, args: string[] = []) {
  let code = 0
  let stdout = ''
  try {
    stdout = execFileSync('node', [SCRIPT, ...args], { cwd: root, encoding: 'utf8' })
  } catch (e: any) {
    code = e.status ?? 1
    stdout = String(e.stdout ?? '') + String(e.stderr ?? '')
  }
  const reportPath = join(root, 'docs/internal/cleanup-unpublished-report.md')
  return { code, stdout, report: existsSync(reportPath) ? readFileSync(reportPath, 'utf8') : '' }
}

describe('cleanup-unpublished 判据输入面（INFRA-F145）', () => {
  it('【已修】Figma 侧真源说它还发布着 ⇒ 不删（即便本管线上一轮产物不认识它）', () => {
    // 这条就是 commit 77f4cacc 里那条「故障存在性」的翻转：旧判据在这里报「删除 1」
    const root = makeFixture({ icons: ['1:1', '1:2', '9:9'], publishedIcons: ['1:1', '1:2'], snapshot: ['1:1', '1:2', '9:9'] })
    const { code, report } = runCleanupIn(root)
    expect(code).toBe(0)
    expect(report).toContain('| 删除 | 0 |')
  })

  it('【真废弃仍被删】Figma 侧真源里没有 ⇒ 照删，且措辞不再断言「已被取消发布或废弃」', () => {
    const root = makeFixture({ icons: ['1:1', '8:8'], publishedIcons: ['1:1', '8:8'], snapshot: ['1:1'] })
    const { report } = runCleanupIn(root)
    expect(report).toContain('| 删除 | 1 |')
    expect(report).toContain('8:8')
    // 旧措辞是**断言性**的，而判据其实只知道「不在我这份输入面里」
    expect(report).not.toContain('已被取消发布或废弃')
    expect(report).toContain('不在 Figma 已发布组件集')
  })

  it('【旧输入面不再被读】上一轮产物认识它、Figma 侧真源不认识 ⇒ 仍然删', () => {
    // 反向锁：防止有人把 PUBLISHED_SNAPSHOT_PATH 偷偷改回 published/icons/manifest.json
    const root = makeFixture({ icons: ['1:1', '5:5'], publishedIcons: ['1:1', '5:5'], snapshot: ['1:1'] })
    const { report } = runCleanupIn(root)
    expect(report).toContain('| 删除 | 1 |')
    expect(report).toContain('5:5')
  })

  it('【阴性对照】两个输入面都认识全部图标时，一个都不删（证明 harness 不是恒报「删」）', () => {
    const root = makeFixture({ icons: ['1:1', '1:2'], publishedIcons: ['1:1', '1:2'] })
    const { code, report } = runCleanupIn(root)
    expect(code).toBe(0)
    expect(report).toContain('| 删除 | 0 |')
  })

  it('【阴性对照】非生产页的条目照旧被删（证明 harness 看得见「删」这一侧）', () => {
    const root = makeFixture({ icons: ['1:1'], publishedIcons: ['1:1'] })
    const idx = join(root, 'figma-data/raw/components.index.json')
    const j = JSON.parse(readFileSync(idx, 'utf8'))
    j.components.push({
      figmaName: 'draft/x',
      nodeId: '7:7',
      type: 'COMPONENT',
      pageName: 'Research',
      filename: 'draft-x.json',
      variants: 0,
    })
    j.count = j.components.length
    writeFileSync(idx, JSON.stringify(j, null, 2))
    const { report } = runCleanupIn(root)
    expect(report).toContain('| 删除 | 1 |')
    expect(report).toContain('非生产页')
  })

  it('【fail-closed ①】快照缺失 + --apply ⇒ 非 0 退出且一个文件都不删', () => {
    const root = makeFixture({ icons: ['1:1', '9:9'], publishedIcons: ['1:1'], snapshot: null })
    const { code, stdout } = runCleanupIn(root, ['--apply'])
    expect(code).not.toBe(0)
    expect(stdout).toContain('判据输入面不可用')
    expect(existsSync(join(root, 'figma-data/raw/components/icon-9_9.json'))).toBe(true)
  })

  it('【fail-closed ②】快照的 fileKey 与 raw 不一致 + --apply ⇒ 非 0 退出', () => {
    const root = makeFixture({ icons: ['1:1'], publishedIcons: ['1:1'], snapshot: ['1:1'] })
    const p = join(root, 'figma-data/published/components-rest.json')
    const j = JSON.parse(readFileSync(p, 'utf8'))
    j.figmaFileKey = 'OTHERKEY'
    writeFileSync(p, JSON.stringify(j, null, 2))
    const { code, stdout } = runCleanupIn(root, ['--apply'])
    expect(code).not.toBe(0)
    expect(stdout).toContain('fileKey')
  })

  it('【fail-closed ③】快照比 raw 旧 + --apply ⇒ 非 0 退出（旧快照看不见 raw 里的新组件）', () => {
    const root = makeFixture({
      icons: ['1:1'],
      publishedIcons: ['1:1'],
      snapshot: ['1:1'],
      fetchedAt: '2026-07-01T00:00:00.000Z',
      extractedAt: '2026-07-29T09:10:16.245Z',
    })
    const { code, stdout } = runCleanupIn(root, ['--apply'])
    expect(code).not.toBe(0)
    expect(stdout).toContain('快照比 raw 旧')
  })

  it('【fail-closed ④】快照 records 为空 + --apply ⇒ 非 0 退出（空集合会把整个库判成未发布）', () => {
    const root = makeFixture({ icons: ['1:1'], publishedIcons: ['1:1'], snapshot: [] })
    const { code, stdout } = runCleanupIn(root, ['--apply'])
    expect(code).not.toBe(0)
    expect(stdout).toContain('判据输入面不可用')
  })

  it('【dry-run 不 fail-closed，但必须显式警告】快照缺失时只报不删', () => {
    const root = makeFixture({ icons: ['1:1'], publishedIcons: ['1:1'], snapshot: null })
    const { code, stdout } = runCleanupIn(root)
    expect(code).toBe(0)
    expect(stdout).toContain('判据输入面不可用')
  })
})
