import { canonicalJson } from '../../common/canonical-json';
import type { PersonaFeatureDraft } from './features/feature.interface';

/**
 * 画像写入判定 —— 「算完之后要不要写、写成新版本还是就地刷新」。
 *
 * ── 为什么需要 ──
 * 原逻辑是「闸没拦住 → 无条件写新版本 + 全套 feature 行」。闸(水位)因此被迫收得很紧,
 * 而收紧的代价是 **2026-07-21 reparse 后全量重算被 noop 掉 325,979 人**(生产实测):
 * reparse 只改 patient_facts、不动 patient_transactions,而水位盯的是 transaction。
 * 结果 74% 的存量画像把「累计消费」永久停在旧口径(应收),中位高估 91%,
 * 且这个数会去和实时算出的 M 分位阈值比大小 → mScore 系统性偏高 → 召回优先级被抬。
 *
 * 修法是把水位对齐到 fact 层(见 PersonaService.readWatermarks)。闸放宽之后,
 * 「算了但内容没变」的情况会变多 —— 本模块就是那道**写入侧的闸**:内容没变就不写,
 * 只有易变数值变了就地刷新,真变了才升版本。省下的是版本行 + 3.1 GB persona_features 的增长。
 *
 * ── 判据:比 data(去掉易变键)+ evidence.factIds,**不比 description** ──
 * description 完全由 data 派生(`${zh} · 末诊${lastDays}天 · 就诊${totalVisits}次`),
 * 所以比 data 就够;不解析文案 —— 将来改文案模板不会引发误判。同 reason-refresh 的思路。
 */

export type PersonaWriteVerdict =
  /** 内容一字未变 → 不写 feature 行,只推水位 */
  | 'unchanged'
  /** 只有易变数值变(纯时间流逝导致)→ 就地刷新 description/data,不升版本 */
  | 'volatile'
  /** 语义变了(标签/取值/证据集)→ 升版本,supersede 旧版 */
  | 'semantic';

/** 库里读出来的 persona_features 行(比较用的最小投影) */
export interface StoredPersonaFeature {
  key: string;
  description: string;
  score: number | null;
  data: unknown;
  evidence: unknown;
}

/**
 * 易变键 —— **判据:拿同一批事实、换个时刻重算,值就会变**。
 *
 * 这类键的变化不代表患者变了,只代表"时间过去了",不该升版本。分三类:
 *   ① 纯时钟:距今天数 / 周岁
 *   ② 时间做分母:年均就诊次数
 *   ③ 滑动窗口聚合:近 1 年消费额、近 1 年履约/迟到率、近 2 年预约时段占比
 *
 * ⚠️ 加新的时间派生 data 键时必须登记到这里,否则每次重算都会白升一个版本。
 *    对应回归见 tests/persona-diff.spec.ts「易变键清单」。
 *
 * ⚠️ 反过来也要小心:**不要**把 monetaryCents / totalVisits / types 这类"事实变才变"的键
 *    塞进来 —— 那正是 E 事故要修的东西,漏判就等于修了个寂寞。
 */
export const VOLATILE_DATA_KEYS: ReadonlySet<string> = new Set([
  // ① 纯时钟
  'recencyDays', // rfm / lifecycle_stage:距末诊天数
  'firstSeenDays', // rfm / lifecycle_stage:距首诊天数
  'lastVisitDays', // urgency_level:距末诊天数
  'daysSince', // potential_treatment.detail[]:诊断距今天数
  'ageYears', // age_bracket:周岁(跨档会改 bracket,那才是语义变化)
  // ② 时间做分母
  'visitsPerYear', // lifecycle_stage:年均就诊
  // ③ 滑动窗口聚合
  'netThisCents', // lifecycle_stage:近 1 年净消费
  'fulfillRate', // special_attention:近 1 年履约率
  'apptDecided', // special_attention:近 1 年已决预约数
  'lateRate', // special_attention:近 1 年迟到率
  'arrivalRecs', // special_attention:近 1 年到店记录数
  'recordCount', // time_preference:近 2 年预约数
  'weekdayPct', // time_preference:近 2 年工作日占比
  'weekendPct',
  'morningPct',
  'afternoonPct',
  'eveningPct',
]);

/** 递归剔除易变键(嵌套对象 / 数组元素同样处理,如 potential_treatment.detail[].daysSince) */
function stripVolatile(v: unknown): unknown {
  if (Array.isArray(v)) return v.map(stripVolatile);
  if (v !== null && typeof v === 'object') {
    const o = v as Record<string, unknown>;
    const out: Record<string, unknown> = {};
    for (const k of Object.keys(o)) {
      if (VOLATILE_DATA_KEYS.has(k)) continue;
      out[k] = stripVolatile(o[k]);
    }
    return out;
  }
  return v;
}

/** evidence.factIds 排序后取出;批次间顺序会抖,不排序会误判成变化 */
function factIdsOf(evidence: unknown): string[] {
  if (evidence == null || typeof evidence !== 'object') return [];
  const ids = (evidence as { factIds?: unknown }).factIds;
  return Array.isArray(ids) ? [...ids].map(String).sort() : [];
}

/** 语义指纹:key + score + data(去易变)+ 证据集。description 不参与(派生物)。 */
function semanticKey(f: StoredPersonaFeature | PersonaFeatureDraft): string {
  return canonicalJson({
    key: f.key,
    score: f.score ?? null,
    data: stripVolatile(f.data ?? null),
    factIds: factIdsOf(f.evidence),
  });
}

/** 全量指纹:语义指纹 + 易变值 + 文案。用来区分"完全没变"和"只有天数变了"。 */
function fullKey(f: StoredPersonaFeature | PersonaFeatureDraft): string {
  return canonicalJson({
    key: f.key,
    description: f.description,
    score: f.score ?? null,
    data: f.data ?? null,
    factIds: factIdsOf(f.evidence),
  });
}

/** 按 key 排序后逐条拼 —— 特征集顺序在库里/代码里不一致,不排序会误判 */
function fingerprint(
  features: ReadonlyArray<StoredPersonaFeature | PersonaFeatureDraft>,
  of: (f: StoredPersonaFeature | PersonaFeatureDraft) => string,
): string {
  return features
    .map(of)
    .sort()
    .join('\n');
}

/**
 * 该患者这次算出来的画像,相对当前 active 版本要怎么落库。
 *
 * @param current 当前 active persona 的 feature 行;null / 空数组 = 没有当前版本 → 必写(semantic)
 * @param next    本次算出的 drafts
 */
export function personaWriteVerdict(
  current: readonly StoredPersonaFeature[] | null,
  next: readonly PersonaFeatureDraft[],
): PersonaWriteVerdict {
  // 首次(无 active 版本)必写。注意:current 是空数组但 persona 行存在的情况也走这里 ——
  // 只有"上一版一个特征都没产出"才会这样,极罕见,多写一版无害。
  if (!current || current.length === 0) return 'semantic';
  if (next.length === 0) return 'semantic'; // 从有到无(全部降级)也是语义变化

  if (fingerprint(current, semanticKey) !== fingerprint(next, semanticKey)) return 'semantic';
  if (fingerprint(current, fullKey) !== fingerprint(next, fullKey)) return 'volatile';
  return 'unchanged';
}
