import { Injectable } from '@nestjs/common';
import {
  CONTRAINDICATION_DOMAIN_ORDER,
  CONTRAINDICATION_LABEL,
  type ClinicalSignal,
  PersonaFeatureKey,
  type TreatmentDomain,
} from '@pac/types';
import type {
  ActiveFact,
  FeatureExtractor,
  FeatureExtractorContext,
  PersonaFeatureDraft,
} from './feature.interface';
import { ageYearsAt } from './potential-treatment.feature';
import { earliestFuture, nextAgeBoundary } from './time-boundary';

/**
 * contraindication 禁忌标签(D.2.4)— snapshot · 多标签
 *
 * ── 数据从哪来 ──
 * 病历自由文本在**摄入时**已被 ClinicalSignalService 抽成结构化 `content.clinical_signals`
 * (字典 data/_shared/dict/clinical-signals.yaml)。本特征只做**裁决**,不碰文本:
 *
 *     摄入层  抽取(这句话里提到抗凝药吗)      per-row、确定性、可重放
 *        ↓    写回源 fact 的 content.clinical_signals
 *     衍生层  裁决(他现在是不是手术禁忌)      跨记录、跨时间 ← 本文件
 *
 * 这条分界线跟 PAC 已有的「翻译 vs 衍生」是同一条(诊断 K 码在 assembler 翻译,
 * 治疗链在 chain-composer 衍生)。抽取混进本文件的话,召回闸门要用就只能重写一遍正则。
 *
 * ── 三档严重度决定下游怎么用 ──
 *   absolute 绝对禁忌 → 打「X 禁忌」标签;将来接召回闸门挡的就是这一档
 *   relative 相对禁忌 → 打标签 + 话术警示,**不挡召回**(能不能做由医生按身体条件评估)
 *   check    核查项   → **本特征不产出**(见下)
 * 定量条件无数据的(糖尿病要 HbA1c、高血压要收缩压)一律落 check —— 控制达标的糖尿病
 * 是可以种植的,硬判禁忌医学上就是错的。判断权还给医生。
 *
 * ⭐ check 档为什么不出现在画像里:业务口径 —— 「有糖尿病史但控制状态未知」对客服不构成
 *   可执行信息,摆出来只会让人把「问一句」当成「不能治」,是净噪音。信号本身**照常抽取并
 *   存在 fact 的 content.clinical_signals 里**(事实一条不丢,可查可回溯),只是画像层不说。
 *   这就是「只讲事实」与「只说有用的话」的分工:事实层求全,画像层求准。
 *
 * ── 时效 ──
 * 「禁忌≠终身禁忌」:妊娠 280 天、哺乳 180 天(产后半年解除)、放疗/抗骨吸收药终身。
 * 到期日在**抽取时**就按源病历的 occurredAt 算好写进 signal,本文件只做一次比较。
 * nextBoundaryAt 报出最近的到期时刻 → 到期当晚自动重算,标签自己消失,不用人管。
 */
@Injectable()
export class ContraindicationFeatureExtractor implements FeatureExtractor {
  readonly key = PersonaFeatureKey.CONTRAINDICATION;
  private static readonly IMPLANT_MIN_AGE = 19; // ≤18 禁忌 → 满 19 解除

  /// 会带 clinical_signals 的 fact 类型(与 clinical-signals.yaml 的 scan_fields 对应)。
  /// 这里列的是**读取范围**,写入范围由 yaml 决定;yaml 加了新类型而这里没加 = 白抽,
  /// 由 tests/clinical-signal-extractor.spec.ts 的一致性用例守住。
  private static readonly SIGNAL_FACT_TYPES = ['emr_record', 'diagnosis_record'] as const;

  extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
    const detail: ContraindicationDetail[] = [];
    const factIds = new Set<string>();

    // ── ① 年龄禁忌(不依赖任何 fact,证据是 patient.birthDate)──
    const age = ctx.patient.birthDate ? ageYearsAt(ctx.patient.birthDate, ctx.now) : null;
    if (age !== null && age >= 0 && age <= 18) {
      const lift = new Date(ctx.patient.birthDate!);
      lift.setFullYear(lift.getFullYear() + ContraindicationFeatureExtractor.IMPLANT_MIN_AGE);
      detail.push({
        code: 'implant_age',
        label: '骨骼发育未完全',
        domains: ['implant'],
        severity: 'absolute',
        reason: `年龄${age}岁`,
        phrase: null,
        source: null,
        validUntil: lift.toISOString().slice(0, 10),
      });
    }

    // ── ② 病历信号裁决(check 档在 resolveSignals 里已滤掉,不进画像)──
    detail.push(...resolveSignals(collectSignals(ctx, factIds), ctx.now));

    if (detail.length === 0) return null;

    const domains = orderedDomains(detail);
    const labels = domains.map((d) => CONTRAINDICATION_LABEL[d]);

    return {
      key: this.key,
      description: `${labels.join(' / ')}(${[...new Set(detail.map((d) => d.label))].join('、')})`,
      score: null,
      data: {
        // 兼容既有消费方:persona-tag-filters 按 data.domains 圈人(治疗域四类),
        // 前端 compactPersonaValue 读 data.labels;data.types 留病因码供详情/排查用。
        types: detail.map((d) => d.code),
        labels,
        domains,
        detail,
      },
      evidence: { factIds: [...factIds] },
    };
  }

  /**
   * 到期即改档的时刻:年龄满 19 / 任一禁忌的失效日 / 任一信号的复评日。
   *
   * 缺了这个,一个 2026-01 记录「哺乳期」的患者,标签会一直挂到她下次恰好有新病历为止 ——
   * 水位闸只在事实变了时放行,而"时间过去了"不是事实变化。
   */
  nextBoundaryAt(ctx: FeatureExtractorContext): Date | null {
    const dates: Array<Date | null> = [
      nextAgeBoundary(ctx.patient.birthDate, ctx.now, [
        ContraindicationFeatureExtractor.IMPLANT_MIN_AGE,
      ]),
    ];
    for (const s of collectSignals(ctx, new Set())) {
      if (s.negated !== false) continue;
      if (s.validUntil) dates.push(new Date(`${s.validUntil}T00:00:00Z`));
      if (s.reviewAfter) dates.push(new Date(`${s.reviewAfter}T00:00:00Z`));
    }
    return earliestFuture(ctx.now, ...dates);
  }
}

interface ContraindicationDetail {
  code: string;
  label: string;
  domains: TreatmentDomain[];
  /** 只会是 absolute / relative —— check 档在裁决时已滤掉,不进画像 */
  severity: 'absolute' | 'relative';
  reason: string;
  /// 医生原话片段 —— 详情页/话术引用的是这个,不是 PAC 的转述
  phrase: string | null;
  /// 证据来源:哪条 fact 的哪个字段 + 记录日期
  source: { factId: string; field: string; occurredAt: string | null } | null;
  validUntil: string | null;
}

/** 带 fact 溯源信息的信号 */
interface SourcedSignal extends ClinicalSignal {
  factId: string;
  occurredAt: Date | null;
}

function collectSignals(ctx: FeatureExtractorContext, factIds: Set<string>): SourcedSignal[] {
  const out: SourcedSignal[] = [];
  for (const type of ContraindicationFeatureExtractor['SIGNAL_FACT_TYPES']) {
    for (const f of ctx.factsByType.get(type) ?? []) {
      const raw = (f.content as { clinical_signals?: unknown } | null)?.clinical_signals;
      if (!Array.isArray(raw)) continue;
      for (const s of raw as ClinicalSignal[]) {
        out.push({ ...s, factId: f.id, occurredAt: (f as ActiveFact).occurredAt });
        if (s.negated === false) factIds.add(f.id);
      }
    }
  }
  return out;
}

/**
 * 同一个 code 在多年病历里反复出现时怎么裁决。
 *
 * 【终身型(validUntil=null 且 reviewAfter=null)】阳性一旦出现就不被后续否定推翻。
 *   放疗史 / 抗骨吸收药的骨坏死风险是不可逆的 —— 后来某份病历套用「否认系统病史」模板
 *   没提它,不代表那次放疗没发生过。记录冲突时保留更危险的一侧。
 *
 * 【其余】**最近一条病历为准**。抗凝药会停、牙周炎会控制住、妊娠会结束,
 *   最新的医生记录就是最新的事实。
 */
function resolveSignals(signals: SourcedSignal[], now: Date): ContraindicationDetail[] {
  const byCode = new Map<string, SourcedSignal>();
  for (const s of signals) {
    const prev = byCode.get(s.code);
    if (!prev) {
      byCode.set(s.code, s);
      continue;
    }
    const lifelong = s.validUntil === null && s.reviewAfter === null;
    if (lifelong) {
      // 阳性优先,其次比新
      if (prev.negated !== false && s.negated === false) byCode.set(s.code, s);
      else if (prev.negated === s.negated && newer(s, prev)) byCode.set(s.code, s);
    } else if (newer(s, prev)) {
      byCode.set(s.code, s);
    }
  }

  const out: ContraindicationDetail[] = [];
  for (const s of byCode.values()) {
    if (s.negated !== false) continue; // 否定 / 不可判都不产禁忌
    // check 档(糖尿病/高血压/骨质疏松/吸烟史 —— 有病史但控制状态未知)不进画像:
    // 对客服不构成可执行信息,摆出来只会让人把「问一句」当成「不能治」。
    // 信号本身照常留在 fact 的 content.clinical_signals 里,事实一条不丢。
    if (s.severity === 'check') continue;
    if (s.validUntil && s.validUntil <= iso(now)) continue; // 已过期(2016 年那条哺乳期)
    // 过了复评日:**仍然算禁忌**(抗凝药可能已停,但漏挡比多挡危险得多),
    // 只在依据行注明记录较早 —— 不降级、不消失,把"要不要确认"交给客服看日期判断。
    const stale = s.reviewAfter != null && s.reviewAfter <= iso(now);
    out.push({
      code: s.code,
      label: s.label,
      domains: s.contraindicates,
      severity: s.severity,
      reason: stale ? `${s.label}(记录较早,建议确认近况)` : s.label,
      phrase: s.phrase,
      source: {
        factId: s.factId,
        field: s.field,
        occurredAt: s.occurredAt ? iso(s.occurredAt) : null,
      },
      validUntil: s.validUntil,
    });
  }
  return out;
}

function newer(a: SourcedSignal, b: SourcedSignal): boolean {
  return (a.occurredAt?.getTime() ?? 0) > (b.occurredAt?.getTime() ?? 0);
}

function iso(d: Date): string {
  return d.toISOString().slice(0, 10);
}

/** 命中的治疗域,按风险高低固定序 —— 前端与话术共用同一个顺序,免得两处各排各的 */
function orderedDomains(detail: ContraindicationDetail[]): TreatmentDomain[] {
  const hit = new Set(detail.flatMap((d) => d.domains));
  return CONTRAINDICATION_DOMAIN_ORDER.filter((d) => hit.has(d));
}

