/**
 * unchanged 分支的「reason 就地刷新」判定。
 *
 * ── 解决什么问题 ──
 * 引擎的变化判定只比 `(scenario, subKey)` 集合;集合没变就走 unchanged 分支,**plan_reasons 一个字都不改**。
 * 于是"只改了 signals 内容"的算法升级(如 2026-07 缺牙按年龄排治疗:signals 新增
 * focusCategory / patientAge)对**存量 plan 永远不生效** —— 生产实测 3,170 条高龄缺牙
 * 仍显示「目标·种植」。要修只能删库重算(丢认领、丢版本号)或写一次性 SQL 回填(逻辑重复)。
 * 本模块让 unchanged 分支能就地刷新:**不升版本、不动认领/状态/抑制窗**。
 *
 * ── 为什么必须"只在语义变化时才写" ──
 * reason 文案与 signals 里都内嵌**天数**(`${days} 天前` / `signals.daysSince`),每天都在变。
 * 无条件刷新 = 每日重算把全部 plan_reasons 重写一遍(生产 23 万+ plan),纯废写 + WAL 膨胀。
 *
 * ── 判据:比 signals(去掉 daysSince)+ evidence,**不解析文案** ──
 * reason 文案完全由 signals 派生 —— 诊断名 ← triggers[].code、牙位 ← toothPosition、
 * 治疗类目 ← expectedCategories + patientAge(年龄适配后)。
 * 所以「signals 去掉 daysSince 后相同」⇒「文案只可能差在天数上」。
 * 据此判定既充分,又不依赖文案格式(将来 scenario 改文案模板不会引发误刷)。
 */

/**
 * 规范化序列化:**递归按键名排序**后再 stringify。
 *
 * ⚠️ 必须这么做 —— Postgres `jsonb` **不保留键序**(按键长度+字节序重排存储),
 * 而代码里构造 signals 的顺序是源码顺序。直接 JSON.stringify 比较,库里读出来的
 * 和新算出来的键序必然不同 → 每次都判"变了" → 每日重算把全部 plan_reasons 重写一遍,
 * 恰好是本模块要避免的事(本地实测:不加这层,连跑三次每次都刷新)。
 */
function canonical(v: unknown): string {
  const norm = (x: unknown): unknown => {
    if (Array.isArray(x)) return x.map(norm);
    if (x !== null && typeof x === 'object') {
      const o = x as Record<string, unknown>;
      return Object.keys(o)
        .sort()
        .reduce<Record<string, unknown>>((acc, k) => {
          if (o[k] !== undefined) acc[k] = norm(o[k]);
          return acc;
        }, {});
    }
    return x;
  };
  return JSON.stringify(norm(v));
}

/** 稳定投影:剔除随时间必变、不代表语义变化的字段 */
function stableSignals(signals: unknown): unknown {
  if (signals == null || typeof signals !== 'object') return signals ?? null;
  // daysSince 是生成那刻的快照,展示层用 signalOccurredAt 实时重算(见 applyLiveDays),
  // 它变化不代表召回语义变了 → 不作为刷新依据。
  const { daysSince: _drop, ...rest } = signals as Record<string, unknown>;
  return rest;
}

/** evidence.factIds 顺序在不同批次可能不同,排序后再比,避免顺序抖动触发误刷 */
function stableEvidence(evidence: unknown): unknown {
  if (evidence == null || typeof evidence !== 'object') return null;
  const ids = (evidence as { factIds?: unknown }).factIds;
  return Array.isArray(ids) ? [...ids].map(String).sort() : null;
}

export interface ReasonContent {
  signals: unknown;
  evidence: unknown;
}

/**
 * 该 reason 行是否需要就地刷新。
 * true = 语义内容变了(signals 或证据集变了)→ 整行重写(含天数等易变字段,反正要写)。
 * false = 只是天数漂移 → 不写。
 */
export function reasonNeedsRefresh(oldRow: ReasonContent, nextHit: ReasonContent): boolean {
  return (
    canonical(stableSignals(oldRow.signals)) !== canonical(stableSignals(nextHit.signals)) ||
    canonical(stableEvidence(oldRow.evidence)) !== canonical(stableEvidence(nextHit.evidence))
  );
}
