/**
 * 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 改文案模板不会引发误刷)。
 */

import { canonicalJson as canonical } from '../../../common/canonical-json';

/** 稳定投影:剔除随时间必变、不代表语义变化的字段 */
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;
}

/**
 * 打分明细的稳定投影 —— 剔掉**随时间自行漂移**的两项。
 *
 *   freshness  诊断过了黄金窗后按天线性衰减,天天变
 *   raw        = base × freshness × 置信度,跟着 freshness 天天变
 *
 * 其余(urgency / value / willingness / base / rfmSegment / lifecycleStage …)只有患者
 * 情况真变了才变 —— 尤其 urgency 跨 30/90 天线是**档位变了**,该刷。
 * 不剔这两项 = 每日重算把全部 plan_reasons 重写一遍,正是本模块要避免的事。
 */
function stableBreakdown(breakdown: unknown): unknown {
  if (breakdown == null || typeof breakdown !== 'object') return null;
  const b = breakdown as Record<string, unknown>;
  const priority = b.priority;
  if (priority == null || typeof priority !== 'object') return b;
  const { freshness: _f, raw: _r, ...restPriority } = priority as Record<string, unknown>;
  return { ...b, priority: restPriority };
}

export interface ReasonContent {
  signals: unknown;
  evidence: unknown;
  /// 打分明细(详情页 hover 讲"为什么这个分")。可选:老调用方不传即不参与判定。
  breakdown?: unknown;
}

/**
 * 该 reason 行是否需要就地刷新。
 * true = 语义内容变了(signals / 证据集 / 打分明细)→ 整行重写(含天数等易变字段,反正要写)。
 * false = 只是天数漂移 → 不写。
 *
 * ⚠️ breakdown 也要比:hover 靠它把分数讲成人话,而"算法给 breakdown 加了字段"或
 * "急迫档位跨了 90 天线"都不会动 signals —— 只比 signals 的话,存量 plan 永远拿不到
 * (同 2026-07 高龄缺牙那次:signals 变了但集合没变,于是 3,170 条永不生效)。
 */
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)) ||
    canonical(stableBreakdown(oldRow.breakdown)) !== canonical(stableBreakdown(nextHit.breakdown))
  );
}
