/**
 * Persona Feature Registry(标签注册表)— 单一真理源(代码,git/PR review)
 *
 * 画像的核心是「标签化」。每个标签按业务整理好的「标签卡」形式定义(对齐 CDP 标签中台 / OneModel):
 *   标签值 / 数据来源 / 数据字段 / 标签释义 / 计算算法 + 时间语义 / 层级 / owner / version。
 *
 * - 存储:**代码常量**(本文件),不落库;DB(persona_features)只存每患者每标签的实例值。
 * - 时间语义:每个标签自己声明(snapshot 当前态 / window 窗口 / lifetime 全史 / trend 趋势 / mixed)。
 * - 收口:producer 产出的 key 必须在此登记(CI 防漂移,见 persona-design-v2.md §二)。
 * - 来源可切:同一标签现可从 PAC 事实层自算,宿主 CDP 报表给出后切宿主值,口径(算法)不变。
 */
export type FeatureTier = 'rule' | 'statistical' | 'model' | 'llm';
export type FeatureTimeSemantics = 'snapshot' | 'window' | 'lifetime' | 'trend' | 'mixed';

/**
 * ⭐ 标签类区 —— 出自《客户画像标签字典 v3.0》的 A/B/C/D 编号(每个标签上方注释里那个 `A.1.1` 就是它)。
 *
 * 2026-07-30 起这是**唯一一套分类**:首屏 chip 的颜色、首屏顺序、画像详情抽屉的分组标题与顺序,
 * 全从这里出。此前抽屉另有一套「跟进要点 / 价值与阶段 / 基础属性」的三分组(按打电话的用途分),
 * 与业务字典并存 —— 两套分类同时活着,加标签时要想"这个归哪一组"两遍,而且两边会各自漂。
 * 现在收成一套:业务字典怎么分,PAC 就怎么分、怎么排、怎么上色。
 */
export type PersonaFeatureCategory = 'A' | 'B' | 'C' | 'D';

/**
 * 类区展示元数据 —— 中文名 + 颜色。
 * 颜色沿用 PERSONA_FEATURE_META 那套 tone 语义(见 labels.ts 里"红色是稀缺资源"那段):
 *   C 琥珀 = 有事可做(该聊什么)   B 靛蓝 = 钱与阶段
 *   D 玫红 = 要小心 / 要避让       A 灰   = 背景信息,不需要判断
 */
export const PERSONA_FEATURE_CATEGORY_META: Record<
  PersonaFeatureCategory,
  { label: string; tone: string }
> = {
  C: { label: '临床需求', tone: 'amber' },
  B: { label: '价值与阶段', tone: 'indigo' },
  D: { label: '行为与偏好', tone: 'rose' },
  A: { label: '基础属性', tone: 'slate' },
};

/**
 * 类区展示顺序(业务定序 2026-07-30):**该聊什么 → 给多大力度 → 怎么谈 / 要避让 → 背景**。
 * 不是字典的 A→D 字母序 —— 字母序把"基础属性"排第一,客服最不需要的东西占了首屏开头。
 */
export const PERSONA_FEATURE_CATEGORY_ORDER: PersonaFeatureCategory[] = ['C', 'B', 'D', 'A'];

/**
 * ⭐ 身份卡「关键标签」—— 患者姓名下面直接露出的那几个,不是全部 16 个。
 *
 * 业务口径(2026-07-29):客服扫一眼就能判断「这通电话能不能打、该聊什么」的才算关键。
 * 其余标签一个不少,仍在「画像标签 → 详情」抽屉里全量可查 ——
 * 这里取舍的是**首屏**,不是信息。
 *
 * ⚠️ **这个数组只管"哪些进首屏",不管顺序**(2026-07-30 起)。顺序统一走
 *    personaFeatureSortKey(类区序 C→B→D→A + 类区内序)—— 与画像详情抽屉同出一源。
 *    以前这里是"数组序即展示序",于是同一批标签在首屏和抽屉里是两个顺序,客服要重新找一遍。
 *
 * 急迫等级不进首屏:顶栏「优先级」条已经表达同一件事(急迫是它的最大权重项),
 * 同屏两处讲一件事,客服会以为是两个指标。
 *
 * 没进来的:时间偏好 / 治疗敏感(通话**中**才用得上,不是扫一眼要的);
 * 年龄段 / 性别(姓名行已经有性别年龄,重复占位);家庭构成 / 转介绍达人
 * (有价值但不改变这通电话怎么开场)。
 *
 * ⚠️ 想加 key 先自问:客服扫一眼真会拿它做决定吗?
 */
export const PERSONA_KEY_FEATURE_KEYS: readonly string[] = [
  'treatment_history',
  'potential_treatment',
  'rfm',
  'lifecycle_stage',
  'entitlement_status',
  'discount_anchor',
  'acquisition_channel',
  'contraindication',
  'special_attention',
];

/** 该标签是否进身份卡首屏(未登记的一律不进 —— 新标签得显式选进来) */
export const isKeyPersonaFeature = (key: string): boolean =>
  PERSONA_KEY_FEATURE_KEYS.includes(key);



/**
 * 详情页 `?` 悬浮卡的展示内容 —— **面向客服的人话版**。
 *
 * 为什么放在标签卡里而不是前端:原先前端 persona-feature-hover.tsx 自己硬编了一份
 * `ALGORITHMS` 字典,和后端口径各写各的,已经漂了 ——
 *   · 权益身份 hover 只列「商保客户 / 医保结算」2 类,extractor 实际产 5 类;
 *     还写了「显示保司 + 最近日期」,而 description/data 里压根没有保司名和日期;
 *   · 转介绍达人 hover 写「社交型 = 推外人 ≥3 人」,真实口径是「推荐 ≥3 人**且**带来成交,
 *     直系亲属关系 <3 归社交型」。
 * 说明写错比不写更糟(客服照着 hover 跟患者讲)。收到这里 = 跟 algorithm 口径贴脸放,
 * 改一处就会看到另一处;CI 侧由 tests/persona-spec-drift.spec.ts 兜底。
 *
 * 与 `algorithm` 的分工:algorithm 是口径原文(算法/数据同学核对用,含阈值与边界),
 * display 是同一件事的客服版(不出现分位、天数阈值以外的技术词)。
 */
export interface PersonaFeatureDisplay {
  /** 一句话副标题:这个标签在看什么 */
  subtitle: string;
  /**
   * 取值条目 —— **每条都是「加粗取值 + 一句短说明」**,这个结构本身就是可读性:
   * 客服扫的是左边那列取值,看到自己要找的那个再读右边一句。
   *
   * ⚠️ 不要为了"精简"把 label 去掉或整块留空 —— 试过一版把自明的标签清成空数组,
   * 结果卡片只剩三段平铺文字,没有层次也没有对照,反而更难读(取值多的标签尤其明显)。
   * 要精简就精简 body 的字数,不是砍掉结构。
   */
  rules: Array<{ label: string; body: string }>;
  /** 所属类区(业务字典 A/B/C/D;决定颜色 + 分组 + 排序) */
  category: PersonaFeatureCategory;
  /** 类区内排序(小的在前;留 10 的间隔便于插新标签) */
  order: number;
}

/**
 * ⭐ 展示排序键:先按类区序、再按类区内序;未登记的标签排最后(仍可见,不吞掉)。
 * **首屏 chip 和画像详情抽屉都用这一个** —— 这就是"同出一源"的落点,别再各写一套 sort。
 */
export function personaFeatureSortKey(key: string): [number, number] {
  const d = PERSONA_FEATURE_SPECS[key]?.display;
  if (!d) return [PERSONA_FEATURE_CATEGORY_ORDER.length, 0];
  return [PERSONA_FEATURE_CATEGORY_ORDER.indexOf(d.category), d.order];
}

/** 该标签属于哪个类区(未登记 → null,调用方自行归入"其他") */
export const personaFeatureCategory = (key: string): PersonaFeatureCategory | null =>
  PERSONA_FEATURE_SPECS[key]?.display?.category ?? null;

/** 类区颜色(tone key);未登记 → slate */
export const personaFeatureCategoryTone = (key: string): string => {
  const c = personaFeatureCategory(key);
  return c ? PERSONA_FEATURE_CATEGORY_META[c].tone : 'slate';
};

export interface PersonaFeatureSpec {
  key: string; // PersonaFeatureKey
  nameZh: string; // 标签名
  tier: FeatureTier; // 层级:规则 / 统计 / 模型 / LLM
  timeSemantics: FeatureTimeSemantics; // 时间语义
  labelValues?: string[]; // 标签值(枚举型标签)
  dataSource: string; // 数据来源(现状 + 未来切换)
  dataFields: string[]; // 数据字段
  meaning: string; // 标签释义
  algorithm: string; // 计算算法(口径,人读;真理实现在 extractor)
  display: PersonaFeatureDisplay; // 详情页 ? 悬浮卡内容(客服版)
  owner: string;
  version: number;
}

export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
  // ── B.1.1 RFM 层级(业务整理好的 CDP 口径,直接采用)──
  rfm: {
    key: 'rfm',
    nameZh: '价值分群(RFM)',
    tier: 'statistical',
    timeSemantics: 'mixed', // R=snapshot(最近) F/M=lifetime
    labelValues: [
      '重要价值客户',
      '重要保持客户',
      '重要发展客户',
      '重要挽留客户',
      '一般价值客户',
      '一般保持客户',
      '一般发展客户',
      '低活跃客户',
    ],
    dataSource:
      '现:PAC 事实层自算(encounter/treatment(actual)/挂号 + payment/recharge/refund);未来:宿主 CDP「客户综合分析报表」直接给 R/F/M',
    dataFields: ['last_visit_time', 'visit_times', 'net_receipts_total'],
    meaning: '基于最近就诊时间(R)、就诊频次(F)、累计消费(M)三维度的客户价值分层',
    algorithm: [
      'Step1 R/F/M 得分(1-5):',
      '  R ≤540天=5 / 541-730=4 / 731-1095=3 / 1096-1460=2 / >1460=1',
      '  F ≥5次=5 / 3-4次=4 / 2次=2 / 1次=1',
      '  M 累计消费分位:TOP20%=5 / 20-40%=4 / 40-60%=3 / 60-80%=2 / BOTTOM20%=1(租户内分位)',
      'Step2 分层:',
      '  R≥4 F≥3 M≥4→重要价值 / R=3 F≥3 M≥4→重要保持 / R≥4 F=2 M≥4→重要发展 / R≤2 F≥3 M≥4→重要挽留',
      '  R≥4 F≥3 M<4→一般价值 / R=3 F≥3 M<4→一般保持 / R≥4 F=2 M<4→一般发展 / 其余(含 R≤2 任意)→低活跃',
    ].join('\n'),
    display: {
      subtitle: '按消费高低 × 来得勤不勤 / 最近来没来 分层',
      rules: [
        { label: '重要 / 一般', body: '消费排在同院前 40% / 消费偏低' },
        { label: '价值', body: '最近还在来,而且来得勤' },
        { label: '保持', body: '来得勤,但有段时间没来了' },
        { label: '发展', body: '最近来过,但来的次数少' },
        { label: '挽留', body: '消费高,但很久没来了' },
        { label: '低活跃', body: '很久没来,或三项都偏低' },
      ],
      category: 'B',
      order: 10,
    },
    owner: 'pac-algo',
    version: 1,
  },

  // ── A.1.1 年龄段(业务 CDP 口径)──
  age_bracket: {
    key: 'age_bracket',
    nameZh: '年龄段',
    tier: 'rule',
    timeSemantics: 'snapshot', // 当下年龄(随时间增长,历史读版本流)
    // 区间与下方 algorithm / extractor BRACKETS 对齐(下界含、上界含)。
    // 原写 婴幼儿(0-3) / 中老年(46-55) 与 algorithm 的 0-2 / 46-54 自相矛盾,已订正。
    labelValues: [
      '婴幼儿(0-2)',
      '学龄前(3-6)',
      '替牙期(7-11)',
      '青少年(12-17)',
      '青年(18-25)',
      '中青年(26-30)',
      '中年(31-45)',
      '中老年(46-54)',
      '老年(55+)',
    ],
    dataSource: '现:PAC patient.birthDate 自算;未来:宿主 CDP「客户综合分析报表」client_age 直接取',
    dataFields: ['client_age'],
    meaning: '按口腔治疗需求特征精细划分年龄层,匹配适龄项目与家庭决策模式',
    algorithm: [
      'client_age 直接取值(PAC 现从 birthDate 算周岁);区间(下界含,3/55 重叠归下一档,≥55→老年):',
      '0-2→婴幼儿 / 3-6→学龄前 / 7-11→替牙期 / 12-17→青少年 / 18-25→青年 /',
      '26-30→中青年 / 31-45→中年 / 46-54→中老年 / ≥55→老年',
    ].join('\n'),
    display: {
      subtitle: '按建档生日算的当下年龄',
      rules: [
        { label: '婴幼儿 / 学龄前 / 替牙期', body: '0-2 / 3-6 / 7-11 岁' },
        { label: '青少年 / 青年 / 中青年', body: '12-17 / 18-25 / 26-30 岁' },
        { label: '中年 / 中老年 / 老年', body: '31-45 / 46-54 / 55 岁以上' },
      ],
      category: 'A',
      order: 20,
    },
    owner: 'pac-algo',
    version: 1,
  },

  // ── A.1.2 性别(业务 CDP 口径)──
  gender: {
    key: 'gender',
    nameZh: '性别',
    tier: 'rule',
    timeSemantics: 'snapshot',
    labelValues: ['男性', '女性', '未知'],
    dataSource: '现:PAC patient.gender 自映射;未来:宿主 CDP client_gender 直接取',
    dataFields: ['client_gender'],
    meaning: '客户生理性别,影响沟通话术与项目推荐',
    algorithm: 'client_gender 直接映射:"M"/"男"→男性;"F"/"女"→女性;其他→未知',
    display: {
      subtitle: '影响称呼与项目推荐',
      rules: [
        { label: '男性 / 女性', body: '按建档性别' },
        { label: '未知', body: '建档没填,或填的值无法识别' },
      ],
      category: 'A',
      order: 30,
    },
    owner: 'pac-algo',
    version: 1,
  },

  // ── A.2.1 获客渠道(业务 CDP 口径,L2 数仓已算)──
  acquisition_channel: {
    key: 'acquisition_channel',
    nameZh: '获客渠道',
    tier: 'rule',
    timeSemantics: 'snapshot', // 实为 static(不变的历史定格):初诊来源一经判定不改,既不可逆也不累计;退化场景归 snapshot
    labelValues: ['走入', '口碑客户', '集团销售渠道', '集团营销渠道', '地区营销渠道', '电商平台', '自媒体网络', '内部员工', '其他'],
    dataSource: 'DW fact_client_out.primary_category/sub_category(L2 数仓初诊来源)→ 摄入 PAC 副表 patient_profiles.acquisition_channel/sub(立柱标准枚举)',
    dataFields: ['primary_category', 'sub_category'],
    meaning: '客户首次到诊的获客来源,用于渠道价值分析。初诊来源一经判定不做二次改归类',
    algorithm: 'host primary_category 经 assembler enum_mapping 归一到 PAC 立柱标准(走入→walk_in 等);二级 sub_category 原值透传。冲突解决(数仓侧):推荐人 > 活动码/渠道码 > 客户类型',
    display: {
      subtitle: '第一次到诊是怎么来的',
      rules: [
        { label: '走入 / 口碑客户', body: '直接上门 / 老客户推荐来的' },
        { label: '集团、地区渠道', body: '销售或营销活动带来' },
        { label: '电商平台 / 自媒体网络', body: '线上平台来的' },
        { label: '内部员工 / 其他', body: '其余来源' },
      ],
      category: 'A',
      order: 10,
    },
    owner: 'pac-algo',
    version: 1,
  },

  // ── A.3.1 家庭构成(业务 CDP 口径;PAC 用 PatientRelation 反推)──
  family_structure: {
    key: 'family_structure',
    nameZh: '家庭构成',
    tier: 'rule',
    timeSemantics: 'snapshot',
    labelValues: ['单身家庭', '两口之家', '多口之家', '多代之家'],
    dataSource: '现:PAC PatientRelation 边表(摄入自 fact_customer_referee_out,混推荐人噪音);未来:宿主 CDP「社交关系维护」social_relations',
    dataFields: ['social_relations'],
    meaning: '基于直系亲属社交关系反推家庭结构,用于家庭套餐推荐与儿牙营销',
    algorithm: [
      '直系:spouse / child·grandchild / father·mother·grandparent;sibling/friend/other 不计(噪音)。',
      '有长辈→多代之家;否则有子女→多口之家;否则有配偶→两口之家;否则有非直系边→单身家庭;无边→不打标签。',
      '⚠️ 依赖关系边覆盖,样本稀疏 + 非全量时覆盖偏低。',
    ].join('\n'),
    display: {
      subtitle: '按院内已建档的亲属关系推断',
      rules: [
        { label: '多代之家', body: '有长辈(父母 / 祖辈)' },
        { label: '多口之家', body: '有子女' },
        { label: '两口之家', body: '有配偶' },
        { label: '单身家庭', body: '只有旁系或朋友' },
      ],
      category: 'A',
      order: 40,
    },
    owner: 'pac-algo',
    version: 1,
  },

  // ── B.1.2 转介绍达人(业务 CDP 口径;v1 用 DW 预聚合)──
  referral_champion: {
    key: 'referral_champion',
    nameZh: '转介绍达人',
    tier: 'rule',
    timeSemantics: 'lifetime', // recommend_num/amount 是全史累计计数器(单调只增),非可逆当前态
    labelValues: ['家庭型', '社交型'],
    dataSource: 'DW fact_client_out.recommend_num/recommend_amount(DW 预聚合推荐人数+转化额)+ PatientRelation;未来宿主 CDP social_relations + settlement_records 逐人核',
    dataFields: ['recommend_num', 'recommend_amount', 'social_relations'],
    meaning: '识别有推荐能力且带来实质转化的客户;家庭型(带家人)推家庭套餐,社交型(推外人)给推荐奖励',
    algorithm: [
      '门槛:recommend_num ≥3 且 recommend_amount > 0(实质转化,v1 用 DW 聚合额作代理)。',
      '家庭型:直系家庭关系 ≥3(带家人);否则 社交型(推外人)。不满足→不打标签。',
      '⚠️ v1 用 DW 预聚合(逐个被推荐人"均有效转化"需其结算,跨患者+需被推荐人已摄入,留 v2)。',
    ].join('\n'),
    display: {
      subtitle: '推荐过 3 人以上,且带来了成交',
      rules: [
        { label: '家庭型', body: '带的是家里人' },
        { label: '社交型', body: '带的是家人以外的人' },
      ],
      category: 'B',
      order: 40,
    },
    owner: 'pac-algo',
    version: 1,
  },

  // ── B.1.3 权益身份(业务 CDP 口径;多标签)──
  entitlement_status: {
    key: 'entitlement_status',
    nameZh: '权益身份',
    tier: 'rule',
    timeSemantics: 'lifetime', // 遍历历史全部结算记录
    labelValues: ['高端保险直付', '银行私行权益', '储值会员', '儿牙会员', '医保客户'],
    dataSource: 'DW fact_settlement_mode_out 卡券名(card_type_name/card_name)+ insurance_name/channel → payment_record.content;recharge_record(储值)',
    dataFields: ['settlement_records(卡券名称、权益类型)'],
    meaning: '通过历史结算卡券反推权益身份,识别高净值客户、预付费会员及医保覆盖客户',
    algorithm: [
      '遍历历史结算,命中关键词即打标(允许并列):',
      '高端保险直付 Bupa/中间带/保险直付/商保;银行私行 招行私行/私人银行/白金卡/贵宾卡;',
      '储值会员 储值/预存(或有充值);儿牙会员 儿牙会员/乐牙卡/涂氟年卡;医保 医保。未命中→不打标签。',
    ].join('\n'),
    display: {
      subtitle: '历史结算用过的卡券或保险(不代表现在仍有效)',
      rules: [
        { label: '高端保险直付', body: 'Bupa、万欣和等商保直付' },
        { label: '银行私行权益', body: '私人银行 / 白金卡' },
        { label: '储值会员', body: '有储值或预存' },
        { label: '儿牙会员', body: '儿牙卡 / 涂氟年卡' },
        { label: '医保客户', body: '用过医保结算' },
      ],
      category: 'B',
      order: 30,
    },
    owner: 'pac-algo',
    version: 2,
  },

  // ── B.1.4 生命周期(业务 CDP 口径;PAC 自算 + 修正顺序 bug)──
  lifecycle_stage: {
    key: 'lifecycle_stage',
    nameZh: '生命周期',
    tier: 'rule',
    timeSemantics: 'mixed', // 末诊 recency(window) + 消费斜率(trend,近1年 vs 历史年均)→ 复合,归 mixed
    labelValues: ['潜客', '新客', '成长客', '成熟客', '待激活', '沉睡客', '流失客'],
    dataSource: '现:PAC 自算(payment/recharge/refund + 就诊 fact + appointment);源表 fact_client_out 无 net_receipts_total/total_visit_times → PAC 算;宿主 CDP 报表给出后可切',
    dataFields: ['first_visit_time', 'last_visit_time', 'total_visit_times', 'net_receipts_this', 'net_receipts_total'],
    meaning: '客户在生命周期中的位置,决定运营策略匹配;成长客 vs 成熟客用消费斜率(近1年>历史年均×1.5)区分',
    algorithm: [
      'total=0 有预约→潜客;首诊≤180&就诊≤3→新客;',
      '末诊≤540&年均≥0.5&近1年>历史年均×1.5→成长客;同上但≤1.5→成熟客;',
      '末诊180-540&就诊≥2→待激活;末诊>730→流失客;末诊540-730&就诊≥2→沉睡客;否则→新客兜底。',
      '⚠️ 修正 spec 顺序 bug:原"沉睡>540"在"流失>730"前 → 流失永不触发;此处流失提前。',
    ].join('\n'),
    display: {
      subtitle: '客户走到了哪个阶段',
      rules: [
        { label: '潜客 / 新客', body: '还没来过 / 首诊半年内且不超过 3 次' },
        { label: '成长客 / 成熟客', body: '还在来,消费在涨 / 消费平稳' },
        { label: '待激活 / 沉睡客 / 流失客', body: '半年~1年半 / 1年半~2年 / 2年以上没来' },
      ],
      category: 'B',
      order: 20,
    },
    owner: 'pac-algo',
    version: 1,
  },

  // ── C.3.1 治疗史(业务 CDP 口径;PAC 读 canonical category)──
  treatment_history: {
    key: 'treatment_history',
    nameZh: '治疗史',
    tier: 'rule',
    timeSemantics: 'lifetime',
    labelValues: ['种植史', '正畸史', '修复史', '牙周治疗史'],
    dataSource: 'PAC treatment_record.content.category(canonical 已归一;spec 的"病历处置/结算关键词"在摄入层已收敛成 category)',
    dataFields: ['medical_record_tag', 'settlement_records'],
    meaning: '客户历史接受过的主要治疗类型,影响后续推荐与维护策略;允许并列,仅保留核心治疗类型',
    algorithm: [
      'implant→种植史;orthodontic→正畸史;prosthodontic/cosmetic→修复史(冠桥/贴面/嵌体);periodontic→牙周治疗史。',
      '不标记 surgical(拔牙)/restorative(充填)/endodontic(根管)等基础治疗。仅 actual;全不命中→不打标签。',
    ].join('\n'),
    display: {
      subtitle: '做过哪些主要治疗',
      rules: [
        { label: '种植史 / 正畸史', body: '种过牙 / 做过矫正' },
        { label: '修复史', body: '冠桥 / 贴面 / 嵌体' },
        { label: '牙周治疗史', body: '洁牙 / 刮治 / 牙周序列' },
      ],
      category: 'C',
      order: 10,
    },
    owner: 'pac-algo',
    version: 1,
  },

  // ── D.1.2 时间偏好(业务 CDP 口径)──
  time_preference: {
    key: 'time_preference',
    nameZh: '时间偏好',
    tier: 'rule',
    timeSemantics: 'window', // 近2年预约
    labelValues: ['工作日偏好', '周末偏好', '上午偏好', '下午偏好', '晚间偏好'],
    dataSource: 'PAC appointment_record.planned_for(预约槽时刻;北京 TZ 下干净营业钟形)。occurred_at 钟点不可靠,用 planned_for',
    dataFields: ['appointment_records(日期+时段)', 'settlement_records(日期)'],
    meaning: '客户习惯就诊时间(星期+时段),用于优化推送时间与预约排班;允许并列',
    algorithm: [
      '近2年预约(记录≥2),北京时区:工作日占比≥60%→工作日偏好;周末≥50%→周末偏好;',
      '上午8-12≥50%→上午;下午12-18≥50%→下午;晚间18-21≥50%→晚间。无命中/记录<2→不打标签。',
      '⚠️ TZ 硬编北京(+8),多宿主应读 host 时区(follow-up)。',
    ].join('\n'),
    display: {
      subtitle: '习惯什么时候来',
      rules: [
        { label: '工作日 / 周末', body: '近两年预约多在这几天' },
        { label: '上午 / 下午 / 晚间', body: '8-12 / 12-18 / 18-21 点' },
      ],
      category: 'D',
      order: 50,
    },
    owner: 'pac-algo',
    version: 1,
  },

  // ── D.1.3 折扣锚点(业务 CDP 口径)──
  discount_anchor: {
    key: 'discount_anchor',
    nameZh: '折扣锚点',
    tier: 'rule',
    timeSemantics: 'lifetime',
    labelValues: ['最低折扣率 + 对应结算时间/项目'],
    dataSource: 'PAC payment_record.content(amount_cents=应收/原价、discount_cents=4渠道折扣额和);DW 无 original_amount,折扣率自算',
    dataFields: ['actual_amount', 'original_amount', 'settlement_date'],
    meaning: '客户历史结算中享受过的最大折扣力度(最低折扣率),作为销售推优惠时的价格底线参考',
    algorithm: [
      '遍历有折扣的结算:折扣率 = 1 − discount_cents/amount_cents(DW 无原价字段;discount_*_rate 实为折扣金额)。',
      '取最小折扣率(力度最大)+ 保留日期/项目。无折扣→不打标签(业务:无锚点换推增值权益,不直接降价)。',
    ].join('\n'),
    display: {
      subtitle: '历史上给过的最深一次折扣',
      rules: [
        { label: '最低折扣', body: '原价 500 元以上的治疗里谈成过的最深折扣' },
        { label: '日期 / 项目', body: '那一次是什么时候、做的什么' },
      ],
      category: 'D',
      order: 10,
    },
    owner: 'pac-algo',
    version: 1,
  },

  // ── D.2.3 特别关注(业务 CDP 口径)──
  special_attention: {
    key: 'special_attention',
    nameZh: '特别关注',
    tier: 'rule',
    timeSemantics: 'window', // 近1年预约 + 当前标注
    labelValues: ['屡次爽约', '经常迟到', '免打扰', '不可等候'],
    dataSource: 'PAC appointment_record(status 履约/爽约 + arrived_at 迟到)+ patient_profile.doNotContact/notes/tags + emr_record',
    dataFields: ['appointment_records', 'customer_tags'],
    meaning: '客户行为特征中需一线服务特别注意的事项,直接影响排班与触达策略',
    algorithm: [
      '近1年:屡次爽约 履约率<50%且决定数≥3(到诊 completed/arrived;爽约 no_show);',
      '经常迟到 到店>预约+15min 占比≥50%且≥3次;免打扰 doNotContact;',
      '不可等候 notes/tags/病历含 不可等候/时间敏感/赶时间/不能等。全不命中→不打标签。',
    ].join('\n'),
    display: {
      subtitle: '排班和联系时要注意的事',
      rules: [
        { label: '屡次爽约', body: '近一年履约不到一半' },
        { label: '经常迟到', body: '多次晚到 15 分钟以上' },
        { label: '免打扰', body: '已标记不接受主动联系' },
        { label: '不可等候', body: '提过赶时间 / 不能等' },
      ],
      category: 'D',
      order: 30,
    },
    owner: 'pac-algo',
    version: 1,
  },

  // ── D.2.2 治疗敏感(业务 CDP 口径;关键词精炼排假阳)──
  treatment_sensitivity: {
    key: 'treatment_sensitivity',
    nameZh: '治疗敏感',
    tier: 'rule',
    timeSemantics: 'lifetime',
    labelValues: ['看牙恐惧', '晕针', '晕血', '密闭恐惧'],
    dataSource: 'EMR emr_record.content(past_history 既往史/illness_desc 等)+ profile.notes/tags。宿主未给 health_profile,EMR 既往史已承载',
    dataFields: ['medical_record_tag(病历备注)', 'health_profile(健康档案)'],
    meaning: '客户在治疗过程中的特殊敏感反应,直接影响医生匹配、舒缓策略与预约排班',
    algorithm: [
      '扫病历自由文本,命中即打标(允许并列):看牙恐惧 恐惧/害怕看牙/牙科焦虑/看牙紧张;',
      '晕针 晕针/害怕打针;晕血 晕血/见血不适;密闭恐惧 幽闭/密闭/长时间张口不适。全不命中→不打标签。',
      '⚠️ 关键词按真实数据精炼排假阳:裸"紧张"=颏肌紧张(正畸)、裸"见血"=可见血凝块(临床)、裸"张口受限"=物理开口受限,均剔除。',
    ].join('\n'),
    display: {
      subtitle: '沟通时要提前安抚的点',
      rules: [
        { label: '看牙恐惧', body: '病历提过害怕看牙' },
        { label: '晕针 / 晕血', body: '提过怕打针 / 见血不适' },
        { label: '密闭恐惧', body: '提过幽闭 / 长时间张口不适' },
      ],
      category: 'D',
      order: 40,
    },
    owner: 'pac-algo',
    version: 1,
  },

  // ── C.1.1 潜在治疗(转化机会;复用召回 gap 核心)──
  potential_treatment: {
    key: 'potential_treatment',
    nameZh: '潜在治疗',
    tier: 'rule',
    timeSemantics: 'snapshot', // 常态:有客观需求未完成(随诊断/治疗变化重算)
    labelValues: ['潜在种植', '潜在正畸', '潜在早矫', '潜在修复', '潜在牙周', '潜在根管', '潜在拔牙', '潜在补牙'],
    dataSource: '复用召回 gap 核心(PotentialTreatmentSelector → 共享 buildGapCore):诊断/建议 fact 未启动对应治疗,去召回时间门',
    dataFields: ['diagnosis_record', 'recommendation_record', 'treatment_record'],
    meaning: '客户"有客观需求但未完成治疗"的转化机会;以电子病历诊断/处置为核心,减少结算维度依赖。允许并列',
    algorithm: [
      '= 召回挖的"诊断了/建议了但没启动对应治疗"(牙位级 gap),去掉召回时间门(cooldown/预约/到诊)→ 常态属性。',
      'K 码→8 标签:种植←K08(>18)/补牙←K02/根管←K04/牙周←K05,K06/正畸←K07(>12≤40)/早矫←K07(3-12)/',
      '修复←K03(默认)/拔牙←K01+K03(残根残冠)。置信度=诊断1.0/建议0.8。⚠️非已丢单(sales_chance)PAC未摄入,省略。',
    ].join('\n'),
    display: {
      subtitle: '诊断或建议过、但还没做的项目',
      rules: [
        { label: '种植 / 修复 / 拔牙', body: '缺牙待种、需要冠桥、残根残冠需拔' },
        { label: '正畸 / 早矫', body: '成人正畸 13-40 岁 / 儿童早矫 3-12 岁' },
        { label: '根管 / 牙周 / 补牙', body: '牙髓、牙周、龋齿' },
      ],
      category: 'C',
      order: 20,
    },
    owner: 'pac-algo',
    version: 1,
  },

  // ── C.2.1 急迫等级(取最大;v1 仅潜在治疗路径)──
  urgency_level: {
    key: 'urgency_level',
    nameZh: '急迫等级',
    tier: 'rule',
    timeSemantics: 'snapshot',
    labelValues: ['紧急', '高', '中', '低'],
    dataSource: '潜在治疗(ctx.potentialGaps,复用召回 gap 核心)+ 末诊(同 lifecycle 口径)。已治疗复查路径待召回实现该场景',
    dataFields: ['is_pending', 'last_visit_time', 'settlement_records', 'sales_chance_records', 'medical_record_tag'],
    meaning: '客户当前所有待处理事项中最高的急迫程度,决定外呼/触达优先级。客户维度单一标签',
    algorithm: [
      'v1 仅潜在治疗路径(已治疗复查路径召回未实现,follow-up):有潜在待转 且',
      '末诊>90天→紧急 / 30-90→高 / <30 或新发现→中 / 美学预防→低(8 标签不含,暂不触发)。',
      '无潜在治疗→不打标签。Step2 取最大(多路径 MAX,现仅 1 路径)。',
    ].join('\n'),
    display: {
      subtitle: '有待做的项目,多久没来了',
      rules: [
        { label: '紧急', body: '有待治项且 90 天以上没来' },
        { label: '高', body: '30-90 天没来' },
        { label: '中', body: '近期来过或新发现' },
      ],
      category: 'C',
      order: 30,
    },
    owner: 'pac-algo',
    version: 1,
  },

  // ── D.2.4 禁忌标签(年龄 + 病历临床信号裁决)──
  contraindication: {
    key: 'contraindication',
    nameZh: '禁忌标签',
    tier: 'rule', // 规则层:抽取在摄入侧(clinical-signals.yaml),本特征只做裁决
    timeSemantics: 'snapshot', // 各类禁忌按自己的时效自动解除(见 algorithm)
    labelValues: ['种植禁忌', '正畸禁忌', '麻醉禁忌', '手术禁忌', '拍片禁忌'],
    dataSource:
      'patient.birthDate(年龄禁忌)+ 病历自由文本抽出的 content.clinical_signals(emr_record 既往史/全身情况/现病史/医嘱,diagnosis_record 诊断原文)。宿主未给结构化 health_profile,DW 也无此表',
    dataFields: ['patient.birthDate', 'emr_record.clinical_signals', 'diagnosis_record.clinical_signals'],
    meaning: '客户因健康/年龄不适合或需谨慎特定治疗;预约/方案前必须核查,影响可行性与安全预警。允许并列',
    algorithm: [
      '两层:摄入时按 data/_shared/dict/clinical-signals.yaml 把病历文本抽成结构化信号(带否定判定 + 到期日),本特征只做裁决。',
      '严重度三档 —— absolute 绝对禁忌(打标签,将来接召回闸门挡的是这一档)/ relative 相对禁忌(打标签+话术警示,不挡召回,由医生按身体条件评估)/ check 核查项(**不进画像**,只留在 fact 层)。',
      '时效:妊娠 280 天、哺乳 180 天(产后半年解除)、放疗/抗骨吸收药终身、抗凝药等设复评期(超期仍算禁忌,只在依据行注明"记录较早,建议确认近况")。到期日在抽取时按源病历日期算好,nextBoundaryAt 到期当晚由 02:00 stale-scan 自动重算。',
      '⚠️ 定量条件无数据的一律落 check 不判禁忌:糖尿病要 HbA1c(全量仅 10 条)、高血压要收缩压(几乎无人记录)。控制达标的糖尿病可以种植,硬判禁忌医学上是错的;而"有病史但状态未知"对客服不构成可执行信息,故 check 档不出现在画像里,只存在 fact 的 clinical_signals。',
      '⚠️ 否定作用域按【句】不按分号 —— 「患者否认有高血压、冠心病;糖尿病、痛风」里分号后的糖尿病仍被否定。生产实测两种切法差 9.4 倍。',
    ].join('\n'),
    display: {
      subtitle: '治疗安全预警 · 年龄 + 病历既往史/诊断',
      rules: [
        { label: '种植禁忌', body: '未满 19 岁颌骨未发育完全;或放疗史 / 抗骨吸收药 / 抗凝药 / 妊娠哺乳' },
        { label: '手术禁忌', body: '长期抗凝药、凝血功能障碍、抗骨吸收药、妊娠期 —— 拔牙/翻瓣/植骨前须医生评估' },
        { label: '麻醉禁忌', body: '局麻药物过敏(利多卡因/普鲁卡因等);哮喘为相对禁忌' },
        { label: '正畸禁忌', body: '诊断为重度牙周炎 —— 牙周控制后可矫治,属相对禁忌' },
      ],
      category: 'D',
      order: 20,
    },
    owner: 'pac-algo',
    version: 2,
  },
};

/**
 * 取某标签的客服版说明(详情页 `?` 悬浮卡)。未登记的 key 返回 null —— 前端据此不渲染悬浮卡,
 * 而不是编一段说明出来。新特征上线忘了写 display 会在页面上"少个 ?",比显示错的说明安全。
 */
export const personaFeatureDisplay = (key: string): PersonaFeatureDisplay | null =>
  PERSONA_FEATURE_SPECS[key]?.display ?? null;
