import { describe, it, expect, afterEach } from 'vitest'
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import {
  evaluate,
  loadExemptions,
  SCAN_EXCLUDES,
  MANAGED_DIR_PATHS,
  extractClaims,
  collectProseSuspects,
} from '../scripts/audit-claim-vs-livesource.mjs'

const emptyLive = { props: new Map(), exports: new Set<string>() }
const liveWith = (comp: string, props: string[]) => ({
  props: new Map([[comp, new Map(props.map((p) => [p, 'string']))]]),
  exports: new Set([comp]),
})

// S1 的两个分母（canonical props / barrel 导出）各判一条，⛔ 不是 `&&` 合成一条。
// spec §6:189 逐字「barrel 导出解析为 0 / `src/canonical` 空 → 红」，`/` 是**或**。
// 旧实现用 `&&`，任一侧单独塌掉都不红（探针实证：{props:{},exports:{Table,Tab}} 与
// {props:{Table},exports:{}} 两个方向 failures 都是空）—— 而 props 侧一塌，S2 的每条
// claim 都在 compProps === undefined 处 continue，report-only 也静默归零。
// 可证伪性：下面两条单侧用例若把判据改回 `&&`，failures 变空 ⇒ 两条同时红。
describe('S1 活源分母 fail-closed（两个分母各判一条）', () => {
  it('props 与 exports 都空 → 两条都红', () => {
    const { failures } = evaluate({ live: emptyLive, claims: [], exemptions: [] })
    expect(failures.some((f) => f.code === 'S1-no-canonical-props')).toBe(true)
    expect(failures.some((f) => f.code === 'S1-no-barrel-exports')).toBe(true)
  })

  it('只有 canonical 侧塌（props 空 / exports 非空）→ 红，且点名的是 canonical 那条', () => {
    const { failures } = evaluate({
      live: { props: new Map(), exports: new Set(['Table', 'Tab']) },
      claims: [],
      exemptions: [],
    })
    expect(failures.map((f) => f.code)).toContain('S1-no-canonical-props')
    // 只塌一侧就只报那一侧——报另一侧会是假话
    expect(failures.some((f) => f.code === 'S1-no-barrel-exports')).toBe(false)
  })

  it('只有 barrel 侧塌（exports 空 / props 非空）→ 红，且点名的是 barrel 那条', () => {
    const { failures } = evaluate({
      live: { props: new Map([['Table', new Map([['rowKey', 'string']])]]), exports: new Set() },
      claims: [],
      exemptions: [],
    })
    expect(failures.map((f) => f.code)).toContain('S1-no-barrel-exports')
    expect(failures.some((f) => f.code === 'S1-no-canonical-props')).toBe(false)
  })

  it('活源非空且无断言 → 绿', () => {
    const { failures } = evaluate({ live: liveWith('Table', ['rowKey']), claims: [], exemptions: [] })
    expect(failures).toHaveLength(0)
  })

  // 解析失败 ≠ 零 props：读不出某组件的 props 时若当成「零 props」放行，闸就会印出
  // 「活源实况：零 props（该组件只转发 attrs / 槽）」这句它自己编的活源断言。
  // 可证伪性：把 S1-unparsable-props 判据删掉（或把解析失败重新折叠成空 Map，
  // 使 unparsableProps 永远为空）⇒ failures 为空，本条红。
  it('有组件 props 读不出来（解析失败态）→ S1-unparsable-props 红并点名该组件', () => {
    const { failures } = evaluate({
      live: {
        props: new Map([['Table', new Map([['rowKey', 'string']])]]),
        exports: new Set(['Table']),
        unparsableProps: ['AliasProps'],
      },
      claims: [],
      exemptions: [],
    })
    const hit = failures.find((f) => f.code === 'S1-unparsable-props')
    expect(hit).toBeDefined()
    expect(hit!.message).toContain('AliasProps')
  })
})

describe('S3 扫描面 fail-closed', () => {
  it('worktrees 与 _archive 在排除名单里', () => {
    expect(SCAN_EXCLUDES).toContain('.claude/worktrees')
    expect(SCAN_EXCLUDES).toContain('docs/_archive')
  })

  it('受管目录一个都不存在 → 红（扫描面全塌）', () => {
    const { failures } = evaluate({
      live: liveWith('Table', ['rowKey']),
      claims: [],
      exemptions: [],
      scan: { existingDirCount: 0, missingDirs: [...MANAGED_DIR_PATHS], fileCount: 0 },
    })
    expect(failures.some((f) => f.code === 'S3-no-managed-dir')).toBe(true)
  })

  // 只给计数、不给名单的老式调用仍要红（兜底档不能因为拿不到名单就整条失效）
  it('只给 existingDirCount === 0（无 missingDirs 名单）→ 仍红（兜底档）', () => {
    const { failures } = evaluate({
      live: liveWith('Table', ['rowKey']),
      claims: [],
      exemptions: [],
      scan: { existingDirCount: 0, fileCount: 0 },
    })
    expect(failures.some((f) => f.code === 'S3-no-managed-dir')).toBe(true)
  })

  // 少一个受管目录 = 少四分之一扫描面，spec §6 的 S3 逐字是「受管目录不存在 → 红」。
  // 可证伪性：把判据改回只认 `existingDirCount === 0`（旧实现）⇒ 四缺一时 failures 为空，
  // 本条的两个 expect 同时红（第二个还锁住报文必须点名是哪个目录，而不是只报个数）。
  it('四个受管目录缺一个 → 红，且报文点名缺的那个（不是只报个数）', () => {
    const { failures } = evaluate({
      live: liveWith('Table', ['rowKey']),
      claims: [],
      exemptions: [],
      scan: { existingDirCount: 3, missingDirs: ['docs/internal/_plans'], fileCount: 0 },
    })
    const hit = failures.find((f) => f.code === 'S3-no-managed-dir')
    expect(hit).toBeDefined()
    expect(hit!.message).toContain('docs/internal/_plans')
  })

  it('受管目录存在但本次无判定面 → 不红（增量模式的常态，⛔ 不许改成红）', () => {
    const { failures } = evaluate({
      live: liveWith('Table', ['rowKey']),
      claims: [],
      exemptions: [],
      scan: { existingDirCount: 4, missingDirs: [], fileCount: 0 },
    })
    expect(failures).toHaveLength(0)
  })
})

// loadExemptions 三条路径直接锁死（用临时 repoRoot，⛔ 不碰仓库里真实的
// figma-data/audit-allowlist/claim-exempt.json）—— 这是 brief Interfaces
// 明确点名的契约：坏 JSON 与「文件不存在」必须返回不同的值（null vs []），
// 否则「豁免表坏了要红」这条判据会被静默改写成「文件不存在也当空表放行」。
describe('loadExemptions 契约：null（坏了） vs []（没有豁免）不能混同', () => {
  let root: string | null = null

  afterEach(() => {
    if (root) rmSync(root, { recursive: true, force: true })
    root = null
  })

  it('坏 JSON → null', () => {
    root = mkdtempSync(join(tmpdir(), 'f133-exempt-'))
    mkdirSync(join(root, 'figma-data/audit-allowlist'), { recursive: true })
    writeFileSync(join(root, 'figma-data/audit-allowlist/claim-exempt.json'), '{ this is not json')
    expect(loadExemptions(root)).toBeNull()
  })

  it('文件不存在 → []（不是 null —— 「没有豁免」与「豁免表坏了」语义不同）', () => {
    root = mkdtempSync(join(tmpdir(), 'f133-exempt-'))
    const result = loadExemptions(root)
    expect(result).not.toBeNull()
    expect(result).toEqual([])
  })

  it('合法数组 → 原样返回', () => {
    root = mkdtempSync(join(tmpdir(), 'f133-exempt-'))
    mkdirSync(join(root, 'figma-data/audit-allowlist'), { recursive: true })
    const entry = { comp: 'Table', ident: 'rowKey', reason: 'x', added: '2026-08-20' }
    writeFileSync(
      join(root, 'figma-data/audit-allowlist/claim-exempt.json'),
      JSON.stringify([entry]),
    )
    const result = loadExemptions(root)
    expect(result).toHaveLength(1)
    expect(result[0]).toEqual(entry)
  })
})

describe('extractClaims 只收结构化区域', () => {
  it('表格同行的 组件+prop 被收', () => {
    const md = ['| 组件 | prop |', '|---|---|', '| `Table` | `rowKey` |'].join('\n')
    const claims = extractClaims(md)
    expect(claims).toEqual([
      expect.objectContaining({ comp: 'Table', ident: 'rowKey', region: 'table', line: 3 }),
    ])
  })

  it('散文行不被收（S2 不判散文）', () => {
    const md = '我们打算给 `Table` 增加一个 `magicFlag` 开关。'
    expect(extractClaims(md)).toHaveLength(0)
  })

  it('fenced code block 内不被收', () => {
    const md = ['```json', '{ "Table": { "ghostProp": 1 } }', '```'].join('\n')
    expect(extractClaims(md)).toHaveLength(0)
  })

  // review finding：字段块只在空行处终止时，字段块后紧跟标题（无空行分隔）——
  // markdown 里完全合法且常见——会让标题后的列表项被错当成字段块内容继续收 claim。
  // 用非 deflist 形态的列表项（`- 组件 \`GhostComponent\` …`，backtick 不紧跟在
  // `- ` 之后）隔离验证路径：只有 inFieldBlock 未被正确终止时才会判成 'field' 区域。
  it('字段块后紧跟标题（无空行分隔）必须终止字段块 —— 标题后的列表项不产生 claim', () => {
    const md = [
      '**Files:**',
      '- Create: `scripts/foo.mjs`',
      '### 下一节标题',
      '- 组件 `GhostComponent` 有 `ghostFlag`',
    ].join('\n')
    const claims = extractClaims(md)
    expect(claims.some((c) => c.comp === 'GhostComponent')).toBe(false)
  })
})

describe('S2 —— 2026-07-30 三条历史真实错断言必须逐条抓到（report-only，Task 5 退档后）', () => {
  const live = {
    props: new Map([
      ['Breadcrumb', new Map()],
      ['Table', new Map([['rowKey', 'string'], ['selectedKeys', 'string[]'], ['loading', 'boolean']])],
      ['Tab', new Map([['modelValue', 'string'], ['fill', 'string'], ['color', 'string']])],
      ['TabList', new Map([['items', 'unknown[]'], ['modelValue', 'string'], ['fill', 'string']])],
    ]),
    exports: new Set(['Breadcrumb', 'Table', 'Tab', 'TabList', 'BreadcrumbItem', 'TabItem']),
  }

  // spec §9 的退档只免了「必须红」的一半，「逐条点名」这一半没被免——三条测试
  // 仍然必须证明闸把每条历史错断言单独列出来，只是落地的通道从 failures 换成 reports。
  // 可证伪性：若 S2 被悄悄改回写 failures（或被整段删掉不判），这三条会先变红——
  // ①②改回 failures 断言会看到 reports 里缺这一条（因为 evaluate 不再往 reports 塞），
  // ③改回 failures 断言会看到 failures 非空（S2 命中真 prop 时不该进任何通道）。
  it('① Breadcrumb 被说成有层级 props → report-only，不阻塞', () => {
    const claims = extractClaims('| 组件 | prop |\n|---|---|\n| `Breadcrumb` | `level` |')
    const { failures, reports } = evaluate({ live, claims, exemptions: [] })
    expect(failures).toHaveLength(0) // S2 退档：不再进 failures
    const hit = reports.find((f) => f.code === 'S2-prop-not-in-live')
    expect(hit).toBeDefined()
    expect(hit!.message).toContain('Breadcrumb')
    expect(hit!.message).toContain('零 props')
  })

  it('② Tab 被说成有 items（Tab/TabList 二选一）→ report-only，不阻塞', () => {
    const claims = extractClaims('| 组件 | prop |\n|---|---|\n| `Tab` | `items` |')
    const { failures, reports } = evaluate({ live, claims, exemptions: [] })
    expect(failures).toHaveLength(0)
    expect(reports.some((f) => f.code === 'S2-prop-not-in-live' && f.message.includes('Tab'))).toBe(true)
  })

  it('③ Table 的真 props 不得误报（防假阳性）—— 既不进 failures 也不进 reports', () => {
    const claims = extractClaims(
      '| 组件 | prop |\n|---|---|\n| `Table` | `rowKey` |\n| `Table` | `selectedKeys` |'
    )
    const { failures, reports } = evaluate({ live, claims, exemptions: [] })
    expect(failures).toHaveLength(0)
    expect(reports.some((r) => r.message.includes('Table'))).toBe(false)
  })

  // ⚠️ 这条**不**证明「S4 靠 usedExemptions 判 shrink-only」（原名如此，是句假话）：
  // S4 的判定完全来自活源（nowValid / compProps === undefined），与 usedExemptions 无关；
  // 那个 `usedExemptions.has(key)` 短路是可证明的死代码（详见 evaluate 里 S4 段的注释）。
  // 本条实际证明的是两件事：豁免命中后该断言不进 reports（放行），以及该 key 被记进
  // usedExemptions 这个对外返回值。可证伪性：删掉 `usedExemptions.add` ⇒ 第三个 expect 红
  //（而 S4 的行为一条都不会变——这正是原测试名误导的地方）。
  it('豁免命中 → 该断言不进 reports，且该 key 记进 usedExemptions 返回值', () => {
    const claims = extractClaims('| 组件 | prop |\n|---|---|\n| `Breadcrumb` | `level` |')
    const exemptions = [
      { comp: 'Breadcrumb', ident: 'level', reason: '测试用', added: '2026-08-20' },
    ]
    const { failures, reports, usedExemptions } = evaluate({ live, claims, exemptions })
    expect(failures).toHaveLength(0)
    expect(reports).toHaveLength(0)
    expect(usedExemptions.has('Breadcrumb::level')).toBe(true)
  })
})

// 退档的核心不变式：S2 命中一条 mismatch 时，failures 必须仍是空的（不阻塞），
// 但 reports 必须非空（不能因为退档就连"记下来"都不做）。
// 可证伪性：若 S2 被悄悄改回写进 failures，第一个 expect 变红；
// 若 S2 被整段删掉不再判，第二个 expect（reports 非空）变红。
describe('S2 退档核心不变式：不阻塞但要报告', () => {
  it('S2 mismatch → failures.length === 0 且 reports 非空', () => {
    const live = liveWith('Table', ['rowKey'])
    const claims = extractClaims('| 组件 | prop |\n|---|---|\n| `Table` | `ghostProp` |')
    const { failures, reports } = evaluate({ live, claims, exemptions: [] })
    expect(failures).toHaveLength(0)
    expect(reports.length).toBeGreaterThan(0)
    expect(reports[0].code).toBe('S2-prop-not-in-live')
  })
})

describe('S4 豁免表 shrink-only', () => {
  it('豁免条目已不再命中 → 红，要求删行', () => {
    const live = { props: new Map([['Table', new Map([['rowKey', 'string']])]]), exports: new Set(['Table']) }
    const exemptions = [{ comp: 'Table', ident: 'rowKey', reason: '早已修好', added: '2026-08-01' }]
    const { failures } = evaluate({ live, claims: [], exemptions })
    expect(failures.some((f) => f.code === 'S4-stale-exemption')).toBe(true)
  })
})

// 守 main() 里 `targets.length === 0` 提前 exit 的位置：这个 early-exit 必须放在
// failures 判定之后，否则「本次没有 .md 要判」会把 S1/S3/S4 这三条 fail-closed
// 判据一起消音——而这三条存在的意义正是"判定面为零时也不能被静默放行"
// （防 canonical 被删空 / 受管目录被搬走 / 豁免表坏掉时，恰好赶上无 .md staged
// 就假绿过关）。这里在 evaluate() 层面锁死：即使 scan 的判定面是 0 文件、
// 受管目录都还在，只要 live source 是空的，S1 必须照样红——不能被 fileCount
// 或 scan 的存在与否绕过。
describe('判定面为零不得消音 fail-closed 判据（守 main() early-exit 的相对位置）', () => {
  it('live source 全空 + scan.fileCount === 0 → S1 两条仍然红（零判定面不豁免 S1）', () => {
    const { failures } = evaluate({
      live: { props: new Map(), exports: new Set() },
      claims: [],
      exemptions: [],
      scan: { existingDirCount: 4, fileCount: 0 },
    })
    expect(failures.some((f) => f.code === 'S1-no-canonical-props')).toBe(true)
    expect(failures.some((f) => f.code === 'S1-no-barrel-exports')).toBe(true)
  })
})

describe('CLI 故障注入', () => {
  const script = resolve(__dirname, '../scripts/audit-claim-vs-livesource.mjs')
  const exitCodeOf = (args: string[]) => {
    try {
      execFileSync('node', [script, ...args], { stdio: 'pipe' })
      return 0
    } catch (e) {
      return (e as { status: number }).status
    }
  }

  it('未知参数 → exit 2（不静默放行）', () => {
    expect(exitCodeOf(['--bogus-flag'])).toBe(2)
  })

  it('无 staged 文件 → exit 0 且印跳过原因', () => {
    const out = execFileSync('node', [script, '--files', ''], { encoding: 'utf8' })
    expect(out).toMatch(/没有需要判定的文件|skipped/)
  })

  // review ruling：`--all` 不实现（spec §7.2 明确本闸只判增量文件），也不能留一个
  // 只解析不落地的死 flag（那样调用者以为跑了全量扫描，实际判了 0 份文件假绿放行）。
  // 删掉之后它就落进「未知参数」分支，fail-closed 而不是假绿——这条锁住这个行为。
  it('--all → exit 2（已删除，不留死 flag，落进未知参数分支 fail-closed）', () => {
    expect(exitCodeOf(['--all'])).toBe(2)
  })

  // 退档核心不变式在 CLI 层再锁一遍：已知有 S2 mismatch 的真实历史文件
  // （docs/internal/_handoffs/2026-07-24-ds-merge-followup.md:40 断言 TopBar 有
  // prop `logo`/`menu`，活源实际只有 tag/title/showMenu/showSearchBox——见
  // calibration-raw.txt）跑一遍，必须 exit 0，且输出要把 S2 与 S5 分成两个
  // 独立标签的小节、并点名 TopBar。可证伪性：若 main() 把 reports 塞回 failures
  // 判据（S2 被悄悄重新阻塞），execFileSync 会抛出非零 status，这条测试直接红；
  // 若 main() 把 S2 report 小节删掉不打印，`toMatch(/S2 report-only/)` 变红。
  it('已知 S2 mismatch 的真实文件 → exit 0，且输出分两个标签小节点名该断言', () => {
    const out = execFileSync(
      'node',
      [script, '--files', 'docs/internal/_handoffs/2026-07-24-ds-merge-followup.md'],
      { encoding: 'utf8' },
    )
    expect(out).toMatch(/S2 report-only/)
    expect(out).toMatch(/S5 report-only/)
    expect(out).toContain('TopBar')
    expect(out).toMatch(/无阻塞违例/)
  })
})

// review finding：collectProseSuspects（S5）此前零测试覆盖，Finding 1 的缺陷
// （field 块与散文重叠判定）正是一条单测就能拦住的。这里直测该函数本身，
// 不经过 CLI/main()，四个用例分别锁住「散文该收」与「三档结构化区域都不该收」。
describe('collectProseSuspects（S5 散文疑似断言）', () => {
  const live = liveWith('DropDownListSelect', ['darkTheme', 'type', 'items', 'listboxId'])

  it('真散文行里的错误断言 → 被收（S5 必须真的干活，不能收 0）', () => {
    // 文本以说明性文字开头、backtick 直到句中才出现——不是 deflist（deflist 要求
    // 行首 `- ` 后紧跟 backtick），不是 table（行首无 `|`），也不在任何 field 块内。
    const md = '我们打算给 `DropDownListSelect` 增加一个 `id` 属性方便定位元素。'
    const suspects = collectProseSuspects(md, live)
    expect(suspects).toEqual([
      expect.objectContaining({ comp: 'DropDownListSelect', ident: 'id' }),
    ])
  })

  it('field 块内的列表项 → 不被收（Finding 1 的回归守卫，归 S2 不归 S5）', () => {
    // 关键：探针必须是「`- ` 之后先有说明文字、backtick 在句中才出现」的形态——
    // 如果 backtick 紧跟在 `- ` 之后，那一行本身就是 deflist（已被排除），
    // 即使 Finding 1 的 field-block 状态机完全没修，这条测试也会"假通过"，
    // 证明不了任何东西。用 `- 组件 \`Comp\` 有 \`ident\`` 这种「`- ` 后先有文字」
    // 的写法，隔离出只有 field 状态机才能拦住的路径（上一轮 review 已指出过
    // 这个坑：`- 组件 \`GhostComponent\` …` 是正确的探针形状，照抄）。
    const md = [
      '**Files:**',
      '- 组件 `DropDownListSelect` 支持 `id` 属性用于定位',
    ].join('\n')
    const suspects = collectProseSuspects(md, live)
    expect(suspects).toHaveLength(0)
  })

  it('fenced code block 内 → 不被收', () => {
    const md = [
      '```',
      '我们打算给 `DropDownListSelect` 增加一个 `id` 属性。',
      '```',
    ].join('\n')
    expect(collectProseSuspects(md, live)).toHaveLength(0)
  })

  it('table 行与 deflist 行 → 不被收（各自归 S2，不重复进 S5）', () => {
    const tableMd = '| `DropDownListSelect` | `id` |'
    const deflistMd = '- `DropDownListSelect` 有 `id`'
    expect(collectProseSuspects(tableMd, live)).toHaveLength(0)
    expect(collectProseSuspects(deflistMd, live)).toHaveLength(0)
  })
})
