// INFRA-F104: render-verification 豁免表的加载 + 校验 + 匹配。
//
// 判据 = component + field + entryScope 三个**结构化**字段。`reason` 是给人读的注释，
// **不参与判据** —— 这正是本模块存在的理由：此前降级判据是对 entry 的散文 warning 做
// 正则（drift-compare-core.ts:413），一句 `harness limitation across all checks` 就能让
// 该 entry 的任何字段免疫，实测 188/936 entries = 20.1% 结构上不可能变红。
//
// fail closed 的五条（全部有单测钉住）：
//   · 文件缺失 / JSON 坏 / 顶层不是数组 → throw（不是「没有豁免所以放行」）
//   · field 不在 buildChecks 派生的闭集 → throw（拼错成另一个真字段名 = 静默扩大豁免面）
//   · addedAt / reviewBy 非 ISO、addedAt 在未来、reviewBy 早于 addedAt → throw
//   · 同一 (component, field, entryScope) 重复 → throw（重复行让 shrink-only 判据失真）
//   · **缺 ceReviewedAt / ceNote → throw**（INFRA-F100 收口，2026-08-17 新增，见下）
// entry 匹配不到任何行 → 空列表（最严），**绝不视为通配**。
//
// ── ceReviewedAt / ceNote：为什么是必填而不是「写一句就完事」（INFRA-F100 最后一条待办）
// 这张表的 37 行原本全部按 **Vue 链**写就，而同一张表也被 `audit:render-drift-gate-react`
// 用来免疫 **React / 自定义元素**链上的 fail check —— 两条链的宿主不同：实测 12 个涉及组件
// 里 11 个在 React 侧渲染进 **shadow root**（只有 `tvu-drop-down-list-select` 是
// light DOM，register.ts 标了 `shadowRoot:false`）。跨这道边界会变的是**全局 element 级
// 规则**（`variables.css` 里的 `button` / `input` / `table` / `th` / `td` / `select` / `[role=*]`
// 那一层进不了 shadow 树）；自定义属性与 color/font 这类继承属性照常穿过去。
// 2026-08-17 的逐行复核里，正是这条差异抓出唯一一行不成立的豁免（`Table | rootBorderHex`）。
// ⇒ 一次性复核过完就完，下一个人加行时会重演同样的盲区，所以把「这行在 CE 链上成不成立」
// 升成**加行时必答**的结构化字段：`ceReviewedAt`（ISO 日期，不得早于 addedAt、不得在未来）
// + `ceNote`（非空结论）。⛔ 别把它降级成可选 —— 可选 = 回到 L1 自律。
// 判据强度如实登记：本闸只保证「有人写了一个日期和一句话」，**不保证那句话是真的**；
// 真伪那一层的取证方式是两条链 report 的 actual 对拉（做法见 backlog 那次复核的留痕）。
//
// ⛔ 字段闭集由调用方从 buildChecks() 的产出传入，**不在本文件硬编码第二份清单**。
//
// cache 只信第一次调用时的输入：loadExcusedFields 是 classifyFailedCheck 的默认参数
// （默认参数逐次求值），一次 render-verification 有 12012 个 check 都会走这里 ——
// 不能去掉 cache 改成每次读盘+校验。但 cache 的键必须覆盖 loadExcusedFields 的**全部**
// 入参维度，少一个就是一个静默复用的洞：换一套 knownFields 或换一个 filePath 还复用旧
// 结果，等于让新输入失去校验，正是本模块自己要防的「静默扩大豁免面」。
// 因此 cache 同时记录首次调用的闭集指纹与 resolve 后的 filePath，之后每次调用都断言
// 两者都一致；任一不一致 → throw，不静默复用、也不静默按新输入重建 —— 同一进程内输入
// 理应恒定（同一次 buildChecks() 产出 + 同一张表路径），漂移本身就是调用方的 bug，该被
// 看见。⚠️ 给下一个想再给 loadExcusedFields 加参数的人：新参数必须同步进 cache 的键，
// 不能只加参数不加键 —— 这正是本文件上一轮修复自己踩过的坑（加了 filePath 却只比
// knownFields，被 review 抓到）。
//
// filePath 是 loadExcusedFields 的可选第二参数（默认 EXCUSE_TABLE_PATH，向后兼容
// Task 2 的单参调用），仅为了让「文件缺失 / JSON 解析失败」这两条 fail-closed 分支
// 能被单测用临时文件钉住，不改变生产调用路径。
// 判据真源：docs/superpowers/specs/2026-08-07-infra-f104-render-gate-field-level-excuse-design.md
import fs from 'node:fs'
import path from 'node:path'

export const EXCUSE_TABLE_PATH = 'figma-data/audit-allowlist/render-excused-fields.json'

export const FIX_DIRECTIONS = [
  'extract-pipeline',
  'harness-verifier',
  'code-fix-deferred',
  'code-first-divergence',
  'figma-schema-gap',
] as const

export type FixDirection = (typeof FIX_DIRECTIONS)[number]

export type ExcuseRow = {
  component: string
  field: string
  entryScope: string
  reason: string
  fixDirection: FixDirection
  divergenceRef?: string
  addedAt: string
  reviewBy: string
  /** 这行豁免按 React / CE 链（多数组件在 shadow root 里）复核过的日期。见文件头。 */
  ceReviewedAt: string
  /** 复核结论：成立 / 不成立 / 作用面差异。非空。 */
  ceNote: string
}

const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/

function isRealIsoDate(value: unknown): value is string {
  if (typeof value !== 'string' || !ISO_DATE.test(value)) return false
  const parsed = new Date(`${value}T00:00:00Z`)
  return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value
}

function daysBetween(fromIso: string, toIso: string): number {
  const from = Date.parse(`${fromIso}T00:00:00Z`)
  const to = Date.parse(`${toIso}T00:00:00Z`)
  return Math.round((to - from) / 86_400_000)
}

// spec §4c：reviewBy 的到期窗口按 fixDirection 分两档，**无上界会让 S7 被一次编辑永久静音**
// （`reviewBy: "2999-12-31"` 此前完全合法）。code-fix-deferred 是「自认的债」，短闹钟；
// 其余四类是「限制」，闹钟可以长一点。⚠️ 用 `>` 判超限（即 `<=` 合法）——现有 37 行里
// code-fix-deferred 的 5 行 reviewBy 正好卡在 +90 天、其余正好卡在 +180 天，`<` 会把
// 合规的存量行也判违规。
const MAX_REVIEW_WINDOW_DAYS: Record<FixDirection, number> = {
  'code-fix-deferred': 90,
  'harness-verifier': 180,
  'code-first-divergence': 180,
  'figma-schema-gap': 180,
  'extract-pipeline': 180,
}

/** glob → 锚定首尾的 RegExp。`*` 是唯一通配符，其余字符字面匹配（正则元字符全部转义）。 */
export function globToRegExp(glob: string): RegExp {
  const escaped = glob
    .split('*')
    .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
    .join('.*')
  return new RegExp(`^${escaped}$`)
}

export function validateExcuseRows(raw: unknown, knownFields: readonly string[]): ExcuseRow[] {
  if (!Array.isArray(raw)) {
    throw new Error(`[render-excused-fields] 顶层必须是数组，实际 ${Object.prototype.toString.call(raw)}`)
  }
  const today = new Date().toISOString().slice(0, 10)
  const seen = new Set<string>()
  return raw.map((row, i) => {
    const at = `[render-excused-fields] 第 ${i} 行`
    if (typeof row !== 'object' || row === null) throw new Error(`${at}不是对象`)
    const r = row as Record<string, unknown>
    for (const key of ['component', 'field', 'entryScope', 'reason', 'fixDirection', 'addedAt', 'reviewBy', 'ceReviewedAt', 'ceNote']) {
      if (typeof r[key] !== 'string' || (r[key] as string).trim() === '') {
        throw new Error(`${at}缺 ${key} 或不是非空字符串`)
      }
    }
    if (!knownFields.includes(r.field as string)) {
      throw new Error(
        `${at} field="${r.field}" 不在 buildChecks 的字段闭集里（${knownFields.join(' / ')}）。` +
          ' 拼错成另一个真字段名会静默扩大豁免面 —— fail closed。',
      )
    }
    if (!FIX_DIRECTIONS.includes(r.fixDirection as FixDirection)) {
      throw new Error(`${at} fixDirection="${r.fixDirection}" 不在枚举（${FIX_DIRECTIONS.join(' / ')}）`)
    }
    if (!isRealIsoDate(r.addedAt)) throw new Error(`${at} addedAt="${r.addedAt}" 不是 ISO 日期（YYYY-MM-DD）`)
    if (!isRealIsoDate(r.reviewBy)) throw new Error(`${at} reviewBy="${r.reviewBy}" 不是 ISO 日期（YYYY-MM-DD）`)
    if ((r.addedAt as string) > today) throw new Error(`${at} addedAt="${r.addedAt}" 在未来`)
    if ((r.reviewBy as string) <= (r.addedAt as string)) {
      throw new Error(`${at} reviewBy="${r.reviewBy}" 必须晚于 addedAt="${r.addedAt}"`)
    }
    // I1（review fix）：上面只保证 reviewBy > addedAt，没有上界 —— `reviewBy: "2999-12-31"`
    // 曾经完全合法，S7 因此可被一次编辑永久静音。窗口按 fixDirection 分档（见 MAX_REVIEW_WINDOW_DAYS）。
    const windowDays = daysBetween(r.addedAt as string, r.reviewBy as string)
    const maxWindowDays = MAX_REVIEW_WINDOW_DAYS[r.fixDirection as FixDirection]
    if (windowDays > maxWindowDays) {
      throw new Error(
        `${at} reviewBy="${r.reviewBy}" 距 addedAt="${r.addedAt}" 有 ${windowDays} 天，` +
          ` 超过 fixDirection="${r.fixDirection}" 的上限 ${maxWindowDays} 天。` +
          ' 续期请把 addedAt 推到今天，那是一次可见的带日期编辑。',
      )
    }
    // INFRA-F100 收口：CE / React 链复核痕迹。上面的必填循环已保证两者是非空字符串，
    // 这里只管日期的语义 —— 复核不能早于这行存在，也不能在未来（未来 = 没真做）。
    if (!isRealIsoDate(r.ceReviewedAt)) {
      throw new Error(
        `${at} ceReviewedAt="${r.ceReviewedAt}" 不是 ISO 日期（YYYY-MM-DD）。` +
          ' 这张表同时被 Vue 链与 React/CE 链使用，加行时必须回答「这条理由在 CE 链上成不成立」。',
      )
    }
    if ((r.ceReviewedAt as string) > today) throw new Error(`${at} ceReviewedAt="${r.ceReviewedAt}" 在未来`)
    if ((r.ceReviewedAt as string) < (r.addedAt as string)) {
      throw new Error(
        `${at} ceReviewedAt="${r.ceReviewedAt}" 早于 addedAt="${r.addedAt}" —— 复核不可能发生在这行存在之前。`,
      )
    }
    if (r.divergenceRef !== undefined && typeof r.divergenceRef !== 'string') {
      throw new Error(`${at} divergenceRef 存在时必须是字符串`)
    }
    const key = `${r.component}::${r.field}::${r.entryScope}`
    if (seen.has(key)) throw new Error(`${at}与前面的行重复：(${r.component}, ${r.field}, ${r.entryScope})`)
    seen.add(key)
    return r as unknown as ExcuseRow
  })
}

type ExcuseCache = { rows: ExcuseRow[]; knownFieldsKey: string; filePath: string }
let cache: ExcuseCache | null = null

export function loadExcusedFields(
  knownFields: readonly string[],
  filePath: string = EXCUSE_TABLE_PATH,
): ExcuseRow[] {
  const knownFieldsKey = knownFields.join('::')
  const resolvedFilePath = path.resolve(filePath)
  if (cache) {
    if (cache.knownFieldsKey !== knownFieldsKey || cache.filePath !== resolvedFilePath) {
      throw new Error(
        `[render-excused-fields] loadExcusedFields 被以不同的输入重复调用 —— cache 的键必须同时覆盖` +
          ' knownFields（字段闭集）与 filePath 两个维度，才不会静默返回不属于本次输入的结果。' +
          ` 首次 filePath="${cache.filePath}" knownFields=(${cache.knownFieldsKey})，` +
          ` 本次 filePath="${resolvedFilePath}" knownFields=(${knownFieldsKey})。` +
          ' 一次进程内两者都应恒定（同一次 buildChecks() 产出 + 同一张表路径）；漂移是调用方状态错误，' +
          ' fail closed，不静默复用旧结果、也不静默按新输入重建缓存。',
      )
    }
    return cache.rows
  }
  if (!fs.existsSync(resolvedFilePath)) {
    throw new Error(
      `[render-excused-fields] 找不到 ${filePath} —— fail closed。` +
        ' 表缺失不等于「没有豁免所以放行」，也不等于「读不到所以跳过」。',
    )
  }
  let parsed: unknown
  try {
    parsed = JSON.parse(fs.readFileSync(resolvedFilePath, 'utf8'))
  } catch (error) {
    throw new Error(`[render-excused-fields] ${filePath} JSON 解析失败：${(error as Error).message}`)
  }
  cache = { rows: validateExcuseRows(parsed, knownFields), knownFieldsKey, filePath: resolvedFilePath }
  return cache.rows
}

export function isExcused(
  rows: readonly ExcuseRow[],
  component: string,
  manifestId: string,
  field: string,
): boolean {
  return rows.some(
    (row) => row.component === component && row.field === field && globToRegExp(row.entryScope).test(manifestId),
  )
}
