import { Injectable } from '@nestjs/common';
import {
  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)。
 */
const EXTRACTION_NAME_KW = ['残根', '残冠', '无法保留', '不能保留'];

/**
 * 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',
      };
    });

    /**
     * ⭐ 窗口温度的边界时刻,**按标签 key 建映射,不塞进 detail[]**。
     *
     * 为什么不放 detail[]:那是**数组**,同一个标签在不同患者身上的下标不一样,
     * 于是筛选路径 `detail.0.hotUntil` 对谁都不成立 —— Prisma 的 json 路径过滤
     * (以及任何索引)都要求**稳定路径**。挪到 `temperature.<key>.hotUntil` 之后,
     * 召回池列表可以直接用现成的 Prisma where 按温度筛(`plan.service.buildListWhere`),
     * 矩阵与列表因此共用同一个引擎、天然对得上数(T14 的「口径对数」)。
     *
     * **不是易变键** —— 同一批事实换个时刻重算值不变,所以 ⛔ 别加进
     * persona-diff 的 VOLATILE_DATA_KEYS(加了就永远不会因为"新来一条诊断把温度顶热了"
     * 而升版本,等于把这次修的冻结原样搬到另一层)。档位在**读时**由 classifyTemperature 现算。
     */
    const temperature: Record<string, { hotUntil: string; warmUntil: string }> = {};
    for (const k of keys) {
      const hot = hottestBounds(agg.get(k)!.bounds);
      if (hot) temperature[k] = hot;
    }

    return {
      key: this.key,
      description: labels.join(' / '),
      score: null,
      data: { types: keys, labels, detail, temperature },
      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);
  }

}
