import { describe, it, expect, vi, beforeEach } from 'vitest'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import {
  globToRegExp,
  isExcused,
  validateExcuseRows,
  EXCUSE_TABLE_PATH,
  type ExcuseRow,
} from './visual-verify/lib/render-excused-fields'
import { CHECK_FIELDS } from './visual-verify/lib/drift-compare-core'

const KNOWN = ['rootWidth', 'rootHeight', 'rootFillHex', 'rootPadding.left'] as const

function row(over: Partial<ExcuseRow> = {}): ExcuseRow {
  return {
    component: 'Fixture',
    field: 'rootFillHex',
    entryScope: '*',
    reason: 'fixture reason',
    fixDirection: 'harness-verifier',
    addedAt: '2026-08-07',
    reviewBy: '2027-02-03',
    ceReviewedAt: '2026-08-17',
    ceNote: 'fixture ce note',
    ...over,
  } as ExcuseRow
}

describe('globToRegExp', () => {
  it('* 匹配任意跨度，其余字符字面匹配', () => {
    expect(globToRegExp('*').test('anything')).toBe(true)
    expect(globToRegExp('a--*--b').test('a--xy--b')).toBe(true)
    expect(globToRegExp('a--*--b').test('a--b')).toBe(false)
  })

  it('正则元字符被转义，不当模式解释', () => {
    // '.' 必须只匹配真的点，不匹配任意字符
    expect(globToRegExp('rootPadding.left').test('rootPaddingXleft')).toBe(false)
    expect(globToRegExp('rootPadding.left').test('rootPadding.left')).toBe(true)
  })

  it('锚定首尾 —— 子串不算命中', () => {
    expect(globToRegExp('abc').test('xabcx')).toBe(false)
  })
})

describe('validateExcuseRows — fail closed', () => {
  it('合法表通过并原样返回', () => {
    expect(validateExcuseRows([row()], KNOWN)).toHaveLength(1)
  })

  it('顶层不是数组 → throw', () => {
    expect(() => validateExcuseRows({}, KNOWN)).toThrow(/数组/)
  })

  it('field 不在闭集 → throw（拼错成另一个真字段名同样被这条挡住的前提是闭集完整）', () => {
    expect(() => validateExcuseRows([row({ field: 'rootPaddingLeft' })], KNOWN)).toThrow(/字段闭集/)
  })

  it('缺 reason → throw', () => {
    expect(() => validateExcuseRows([row({ reason: '' })], KNOWN)).toThrow(/reason/)
  })

  it('fixDirection 不在枚举 → throw', () => {
    expect(() => validateExcuseRows([row({ fixDirection: 'whatever' as never })], KNOWN)).toThrow(/fixDirection/)
  })

  it('addedAt 非 ISO 日期 → throw', () => {
    expect(() => validateExcuseRows([row({ addedAt: '2026/08/07' })], KNOWN)).toThrow(/addedAt/)
  })

  it('addedAt 在未来 → throw', () => {
    expect(() => validateExcuseRows([row({ addedAt: '2099-01-01' })], KNOWN)).toThrow(/未来/)
  })

  it('reviewBy 非 ISO 日期 → throw', () => {
    expect(() => validateExcuseRows([row({ reviewBy: 'someday' })], KNOWN)).toThrow(/reviewBy/)
  })

  it('reviewBy 早于 addedAt → throw', () => {
    expect(() => validateExcuseRows([row({ reviewBy: '2026-01-01' })], KNOWN)).toThrow(/reviewBy/)
  })

  it('同一 (component, field, entryScope) 重复 → throw（重复行会让 shrink-only 判据失真）', () => {
    expect(() => validateExcuseRows([row(), row()], KNOWN)).toThrow(/重复/)
  })
})

// INFRA-F100 收口（2026-08-17）：这张表被 Vue 链与 React/CE 链共用，而 37 行原本全按 Vue
// 侧写就。把「这条理由在 CE 链上成不成立」升成加行时必答的结构化字段，fail closed。
describe('validateExcuseRows — CE/React 复核痕迹必填（INFRA-F100）', () => {
  it('缺 ceReviewedAt → throw', () => {
    const bad = row()
    delete (bad as Record<string, unknown>).ceReviewedAt
    expect(() => validateExcuseRows([bad], KNOWN)).toThrow(/ceReviewedAt/)
  })

  it('缺 ceNote → throw', () => {
    const bad = row()
    delete (bad as Record<string, unknown>).ceNote
    expect(() => validateExcuseRows([bad], KNOWN)).toThrow(/ceNote/)
  })

  it('ceNote 是空串 / 全空白 → throw（占位不算复核）', () => {
    expect(() => validateExcuseRows([row({ ceNote: '' })], KNOWN)).toThrow(/ceNote/)
    expect(() => validateExcuseRows([row({ ceNote: '   ' })], KNOWN)).toThrow(/ceNote/)
  })

  it('ceReviewedAt 非 ISO 日期 → throw', () => {
    expect(() => validateExcuseRows([row({ ceReviewedAt: '2026/08/17' })], KNOWN)).toThrow(/ceReviewedAt/)
  })

  it('ceReviewedAt 在未来 → throw（未来 = 还没真做）', () => {
    expect(() => validateExcuseRows([row({ ceReviewedAt: '2099-01-01' })], KNOWN)).toThrow(/未来/)
  })

  it('ceReviewedAt 早于 addedAt → throw（复核不可能发生在这行存在之前）', () => {
    expect(() => validateExcuseRows([row({ addedAt: '2026-08-07', ceReviewedAt: '2026-08-01' })], KNOWN)).toThrow(
      /早于 addedAt/,
    )
  })

  // 阴性对照：上面每条的「合法版本」必须照样通过，否则这组断言可能只是被别的规则挡住的。
  it('阴性对照 —— 合法的 ceReviewedAt/ceNote 组合通过', () => {
    expect(validateExcuseRows([row({ addedAt: '2026-08-07', ceReviewedAt: '2026-08-07' })], KNOWN)).toHaveLength(1)
    expect(validateExcuseRows([row({ ceNote: '成立：两链同值。' })], KNOWN)).toHaveLength(1)
  })
})

// I1（review fix）：reviewBy 此前无上界，`reviewBy: "2999-12-31"` 完全合法 —— S7 因此可被
// 一次编辑永久静音。窗口按 fixDirection 分档：code-fix-deferred ≤ 90 天，其余四类 ≤ 180 天。
// row() 默认 addedAt=2026-08-07 / reviewBy=2027-02-03（正好 180 天），与生产表的存量行同款。
describe('validateExcuseRows — reviewBy 窗口上限（I1）', () => {
  it('code-fix-deferred 超过 90 天 → throw', () => {
    expect(() =>
      validateExcuseRows([row({ fixDirection: 'code-fix-deferred', reviewBy: '2026-11-06' })], KNOWN),
    ).toThrow(/90/)
  })

  it('code-fix-deferred 正好 90 天 → 通过（用 <= 不是 <）', () => {
    expect(
      validateExcuseRows([row({ fixDirection: 'code-fix-deferred', reviewBy: '2026-11-05' })], KNOWN),
    ).toHaveLength(1)
  })

  it('其余四类超过 180 天 → throw', () => {
    expect(() =>
      validateExcuseRows([row({ fixDirection: 'harness-verifier', reviewBy: '2027-02-04' })], KNOWN),
    ).toThrow(/180/)
  })

  it('其余四类正好 180 天 → 通过（用 <= 不是 <）', () => {
    expect(
      validateExcuseRows([row({ fixDirection: 'harness-verifier', reviewBy: '2027-02-03' })], KNOWN),
    ).toHaveLength(1)
  })

  it('把 code-fix-deferred 改标成其它类别就能把闹钟从 90 天换成 180 天 —— 这条路必须被挡住', () => {
    // 同一个 reviewBy（181 天窗口）：标 harness-verifier 超 180 天该 throw；
    // 若曾经"改标成任意类别就能免检"，这里也会 throw，说明分档窗口对 fixDirection 真的敏感。
    expect(() =>
      validateExcuseRows([row({ fixDirection: 'harness-verifier', reviewBy: '2027-02-04' })], KNOWN),
    ).toThrow(/180/)
    // 而 91 天窗口标成 code-fix-deferred 必须 throw（不能靠换标签逃过短闹钟）
    expect(() =>
      validateExcuseRows([row({ fixDirection: 'code-fix-deferred', reviewBy: '2026-11-06' })], KNOWN),
    ).toThrow(/90/)
  })
})

// I2a：生产表此前没有便宜的校验点 —— pnpm vitest run / pre-commit / PR pipeline 都不校验它，
// 只被 playwright 那次跑加载。直接对**真文件**跑 validateExcuseRows（不经 loadExcusedFields，
// 避免与本文件其它用例共享 module 级 cache）。
describe('validateExcuseRows — 生产表本体（I2a：便宜的校验点）', () => {
  it('真实豁免表通过校验且行数 > 0', () => {
    const raw = JSON.parse(fs.readFileSync(EXCUSE_TABLE_PATH, 'utf8'))
    const rows = validateExcuseRows(raw, CHECK_FIELDS)
    expect(rows.length).toBeGreaterThan(0)
  })
})

describe('isExcused', () => {
  const rows = [
    row({ component: 'switch', field: 'rootFillHex', entryScope: 'switch--*--enable-no--*' }),
    row({ component: 'Chart', field: 'rootWidth', entryScope: '*' }),
  ]

  it('三个字段全中才豁免', () => {
    expect(isExcused(rows, 'switch', 'switch--darktheme-on--status-on--enable-no--loading-no--x', 'rootFillHex')).toBe(true)
  })

  it('组件不同 → 不豁免', () => {
    expect(isExcused(rows, 'Slider', 'switch--darktheme-on--status-on--enable-no--loading-no--x', 'rootFillHex')).toBe(false)
  })

  it('字段不同 → 不豁免（entry 级免疫已消失）', () => {
    expect(isExcused(rows, 'switch', 'switch--darktheme-on--status-on--enable-no--loading-no--x', 'rootWidth')).toBe(false)
  })

  it('manifestId 落在 entryScope 外 → 不豁免', () => {
    expect(isExcused(rows, 'switch', 'switch--darktheme-on--status-on--enable-yes--loading-no--x', 'rootFillHex')).toBe(false)
  })

  it('空表 → 一律不豁免（最严，不是通配）', () => {
    expect(isExcused([], 'Chart', 'chart--type-bar--x', 'rootWidth')).toBe(false)
  })
})

// loadExcusedFields 有自己的模块级 cache（避免每次 render-verification 的 12012 个 check
// 都重复读盘+校验）。这里每个 test 都用 vi.resetModules() + 动态 import 拿一个全新的模块
// 实例（全新的 cache = null），避免测试之间通过共享 cache 互相污染 —— 与文件顶部的静态
// import 是两个独立实例，互不影响。
describe('loadExcusedFields — fail closed on 文件访问 / JSON 解析', () => {
  beforeEach(() => {
    vi.resetModules()
  })

  it('文件不存在 → throw', async () => {
    const { loadExcusedFields } = await import('./visual-verify/lib/render-excused-fields')
    const missing = path.join(os.tmpdir(), `render-excused-fields-missing-${Date.now()}-${Math.random()}.json`)
    expect(() => loadExcusedFields(KNOWN, missing)).toThrow(/找不到/)
  })

  it('JSON 解析失败 → throw 且错误信息带得上原始解析错误', async () => {
    const { loadExcusedFields } = await import('./visual-verify/lib/render-excused-fields')
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'render-excused-fields-'))
    const file = path.join(dir, 'bad.json')
    fs.writeFileSync(file, '{ not valid json')
    try {
      expect(() => loadExcusedFields(KNOWN, file)).toThrow(/JSON 解析失败/)
    } finally {
      fs.rmSync(dir, { recursive: true, force: true })
    }
  })

  it('顶层不是数组（{}） → throw', async () => {
    const { loadExcusedFields } = await import('./visual-verify/lib/render-excused-fields')
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'render-excused-fields-'))
    const file = path.join(dir, 'not-array.json')
    fs.writeFileSync(file, '{}')
    try {
      expect(() => loadExcusedFields(KNOWN, file)).toThrow(/数组/)
    } finally {
      fs.rmSync(dir, { recursive: true, force: true })
    }
  })
})

describe('loadExcusedFields — cache 对 knownFields 敏感', () => {
  beforeEach(() => {
    vi.resetModules()
  })

  it('换一组 knownFields 再调用 → throw，不静默复用旧结果', async () => {
    const { loadExcusedFields } = await import('./visual-verify/lib/render-excused-fields')
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'render-excused-fields-'))
    const file = path.join(dir, 'valid.json')
    fs.writeFileSync(file, JSON.stringify([row()]))
    try {
      expect(loadExcusedFields(KNOWN, file)).toHaveLength(1)
      const otherFields = ['somethingElse']
      expect(() => loadExcusedFields(otherFields, file)).toThrow(/闭集/)
    } finally {
      fs.rmSync(dir, { recursive: true, force: true })
    }
  })
})

describe('loadExcusedFields — cache 对 filePath 敏感', () => {
  beforeEach(() => {
    vi.resetModules()
  })

  it('同 knownFields、换一个 filePath 再调用 → throw，不静默返回第一次文件的内容', async () => {
    const { loadExcusedFields } = await import('./visual-verify/lib/render-excused-fields')
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'render-excused-fields-'))
    const fileA = path.join(dir, 'a.json')
    const fileB = path.join(dir, 'b.json')
    fs.writeFileSync(fileA, JSON.stringify([row({ component: 'A' })]))
    fs.writeFileSync(fileB, JSON.stringify([row({ component: 'B' })]))
    try {
      expect(loadExcusedFields(KNOWN, fileA)).toHaveLength(1)
      // 换了 filePath，若 cache 只按 knownFields 建键就会静默返回 fileA 的内容而不报错 ——
      // 这里断言的是「必须 throw」这个具体行为，不是笼统的 not.toBe。
      expect(() => loadExcusedFields(KNOWN, fileB)).toThrow(/filePath/)
    } finally {
      fs.rmSync(dir, { recursive: true, force: true })
    }
  })
})
