import { Injectable } from '@nestjs/common';
import {
  EXTRACTION_NAME_KEYWORDS,
  PersonaFeatureKey,
  gapTemperatureBounds,
  hottestBounds,
  type TemperatureBounds,
} from '@pac/types';
import type {
  FeatureExtractor,
  FeatureExtractorContext,
  PersonaFeatureDraft,
} from './feature.interface';
import type { PotentialGap } from '../../clinical-gap/potential-treatment.selector';
import { nextAgeBoundary } from './time-boundary';

/**
 * potential_treatment 潜在治疗(C.1.1 转化机会)— 规则层,snapshot · 多标签
 *
 * 标签:潜在种植/正畸/早矫/修复/牙周/根管/拔牙/补牙。"有客观需求但未完成治疗"的转化机会。
 *
 * ⭐ 复用召回 gap 核心(ctx.potentialGaps,来自 PotentialTreatmentSelector → 共享 buildGapCore):
 *   = 召回挖的"诊断了/建议了但没启动对应治疗",**去掉召回的时间门**(常态属性)。
 *   口径与召回完全一致(圈人群 = 召回候选),单一真理源。
 *
 * K 码 → 8 业务标签映射:
 *   种植←K08(年龄>18)· 补牙←K02 · 根管←K04 · 牙周←K05/K06 ·
 *   正畸←K07(>12且≤40)· 早矫←K07(3-12)·
 *   修复←K03(默认)· 拔牙←K01 + K03(name 含 残根/残冠/无法保留/不能保留)
 *   K00 发育 / K09 囊肿 不在业务 8 标签 → 不出此标签(召回仍覆盖)。
 *
 * ⭐ **本特征同时是初选矩阵的两根轴**:X 轴 = `data.types`(8 标签),
 *   Y 轴 = `data.detail[].hotUntil / warmUntil`(窗口温度边界,读时定档)。
 *   温度为什么这么存、为什么逐条 gap 各判各的窗,见 `@pac/types` 的 temperature.ts 文件头。
 *
 * 业务 spec 对账:
 *   - 置信度(病历100%/影像AI 70-90%/客服勾选 50-70%)= PAC diagnosis=1.0 / recommendation=0.8(已建模)。
 *   - Step3 主诉意愿加分 = 排序事,消费方自算(score 弃用原则,不进标签)。
 *   - "非已丢单"(sales_chance)= PAC 未摄入丢单数据 → 省略(注明,follow-up)。
 */
/// ⚠️ 单一定义在 @pac/types —— 初选矩阵的 SQL 侧要用同一份(见 potential-label-rules.ts)。
///   ⛔ 别在这里另写一份:改了关键词而 SQL 没跟上,会出现「矩阵有这人、列表没有」且不报错。
const EXTRACTION_NAME_KW = EXTRACTION_NAME_KEYWORDS;

/**
 * classifyGapToLabel 里的年龄闸(满这些岁数时,同一批 gap 的标签映射会变):
 *   3  → 够岁数进早矫;13 → 早矫转正畸;19 → 够岁数进种植(K08 要 >18);41 → 超龄退出正畸。
 * 与下方 switch 的边界一一对应,改一处要改两处 —— 见 tests/persona-time-boundary.spec.ts 的对账用例。
 */
const AGE_GATES = [3, 13, 19, 41] as const;

/// 周岁(单一源,potential_treatment / urgency_level 共用)
export function ageYearsAt(birth: Date | null, now: Date): number | null {
  if (!birth) return null;
  let age = now.getFullYear() - birth.getFullYear();
  const m = now.getMonth() - birth.getMonth();
  if (m < 0 || (m === 0 && now.getDate() < birth.getDate())) age--;
  return age >= 0 && age <= 120 ? age : null;
}

/// 单个 gap → 8 业务标签 key(年龄/name 拆分);不命中(K00/K09/年龄外)→ null。
/// ⭐ 单一真理源:potential_treatment 出标签 + urgency_level 判"潜在待转" 共用此分类。
export function classifyGapToLabel(
  g: PotentialGap,
  age: number | null,
): { key: string; zh: string } | null {
  switch (g.primaryCode) {
    case 'K08':
      return age !== null && age > 18 ? { key: 'implant', zh: '潜在种植' } : null;
    case 'K02':
      return { key: 'filling', zh: '潜在补牙' };
    case 'K04':
      return { key: 'endo', zh: '潜在根管' };
    case 'K05':
    case 'K06':
      return { key: 'perio', zh: '潜在牙周' };
    case 'K01':
      return { key: 'extraction', zh: '潜在拔牙' };
    case 'K03': {
      const nm = g.nameZh ?? '';
      const isExtract = EXTRACTION_NAME_KW.some((k) => nm.includes(k));
      return isExtract ? { key: 'extraction', zh: '潜在拔牙' } : { key: 'restoration', zh: '潜在修复' };
    }
    case 'K07':
      if (age === null) return null; // 年龄未知 → 无法分早矫/正畸
      if (age >= 3 && age <= 12) return { key: 'early_ortho', zh: '潜在早矫' };
      if (age > 12 && age <= 40) return { key: 'ortho', zh: '潜在正畸' };
      return null; // >40 / <3 不召正畸
    default:
      return null; // K00 / K09 不在业务 8 标签
  }
}

@Injectable()
export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
  readonly key = PersonaFeatureKey.POTENTIAL_TREATMENT;

  extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
    const gaps = ctx.potentialGaps ?? [];
    if (gaps.length === 0) return null;
    const age = ageYearsAt(ctx.patient.birthDate, ctx.now);

    // 按业务标签聚合:teeth 并集 / daysSince 取最大(最早需求)/ confidence 取最大 / 来源
    //
    // ⚠️⚠️ **两个方向相反的聚合并存,别把它们统一掉**:
    //   · `daysSince` 取 **max** = 「这个机会挂了多久」—— 话术勾子用(「您去年查出的龋齿一直没补」)
    //   · 温度边界取 **最热** = 「现在该不该打」—— 初选矩阵 Y 轴用
    //   拿 max(daysSince) 去判温度会得到反单调的结论(多一条旧需求 = 更冷),
    //   实测热少报 40-70%。整段推理见 @pac/types 的 temperature.ts 文件头。
    const agg = new Map<
      string,
      {
        zh: string;
        teeth: Set<string>;
        daysSince: number;
        confidence: number;
        hasDx: boolean;
        hasRec: boolean;
        /** 逐条 gap 按**自己 K 码**的窗口算出的边界,聚合时取最热 */
        bounds: Array<TemperatureBounds | null>;
      }
    >();
    const factIds = new Set<string>();
    for (const g of gaps) {
      const lbl = classifyGapToLabel(g, age);
      if (!lbl) continue;
      factIds.add(g.factId);
      const cur =
        agg.get(lbl.key) ??
        {
          zh: lbl.zh,
          teeth: new Set<string>(),
          daysSince: 0,
          confidence: 0,
          hasDx: false,
          hasRec: false,
          bounds: [] as Array<TemperatureBounds | null>,
        };
      for (const t of (g.tooth ?? '').split(';').map((s) => s.trim()).filter(Boolean)) cur.teeth.add(t);
      cur.daysSince = Math.max(cur.daysSince, g.daysSince);
      cur.confidence = Math.max(cur.confidence, g.confidence);
      // ⭐ 用 g.primaryCode 而不是 g.code:挖 gap 时用的就是 lookupDxTreatment(primaryCode)
      //   (见 potential-treatment.selector),温度必须跟它同一条规则,否则 K08 与
      //   IMPLANT_RECOMMENDED 会各判各的窗,同一个标签内部就先自相矛盾了。
      cur.bounds.push(gapTemperatureBounds(g.primaryCode, g.anchorAt));
      if (g.signalType === 'diagnosis') cur.hasDx = true;
      else cur.hasRec = true;
      agg.set(lbl.key, cur);
    }
    if (agg.size === 0) return null;

    // 稳定顺序:价值/紧迫高的在前
    const ORDER = ['implant', 'ortho', 'early_ortho', 'restoration', 'perio', 'endo', 'extraction', 'filling'];
    const keys = [...agg.keys()].sort((a, b) => ORDER.indexOf(a) - ORDER.indexOf(b));
    const labels = keys.map((k) => agg.get(k)!.zh);
    const detail = keys.map((k) => {
      const v = agg.get(k)!;
      const teeth = [...v.teeth].sort();
      return {
        key: k,
        label: v.zh,
        teeth,
        daysSince: v.daysSince,
        confidence: v.confidence,
        source: v.hasDx && v.hasRec ? 'both' : v.hasRec ? 'recommendation' : 'diagnosis',
      };
    });

    /**
     * ⛔ **不再写 `temperature`(2026-08 停写)** —— 初选矩阵的温度已改成
     *   「从召回单证据的锚点**读时**算」(见 plan/reason-temperature.sql.ts)。
     *
     * 为什么停:边界烤进画像 JSON 有两个硬伤 ——
     *   ① 改一次 `DiagnosisTreatmentMap` 的窗口天数就要全量重算画像(实测 4 小时 54 分),
     *      而**不重算不会报错**,只是全按旧窗口判,静默失效。
     *   ② 画像回答「这个人有哪些潜在治疗」,矩阵要回答「引擎为什么召回他」——
     *      两者本就不是一回事(实测 161 个格位画像有、召回单没有)。
     *
     * ⚠️ 存量 JSON 里的 `temperature` 键**暂时留着不清**:它已经没有读取方,
     *   清掉要跑一次全量重算才能恢复。等新链路在测试环境跑稳再单独清理。
     *   ⛔ 在那之前也别去读它 —— 它从此不再更新,是**过期数据**。
     */

    return {
      key: this.key,
      description: labels.join(' / '),
      score: null,
      data: { types: keys, labels, detail },
      evidence: { factIds: [...factIds] },
    };
  }

  /**
   * 标签映射里有年龄闸(见 classifyGapToLabel):种植要 >18、早矫 3-12、正畸 >12 且 ≤40。
   * 跨过这些生日,同一批 gap 会映射出不同标签 —— 事实没变但标签要变。
   */
  nextBoundaryAt(ctx: FeatureExtractorContext): Date | null {
    if (!(ctx.potentialGaps ?? []).length) return null; // 无 gap → 本特征不产出,也无从改档
    return nextAgeBoundary(ctx.patient.birthDate, ctx.now, AGE_GATES);
  }

}
