import type { ClinicalSignal } from '@pac/types';
import type { SignalDef, SignalDict, SignalTtl } from './signal-dict';

/**
 * 临床信号抽取 —— 纯函数,无 Nest / 无 IO。
 *
 * 之所以刻意做成纯函数:准确率必须能拿**生产真实文本**回归验证
 * (tests/clinical-signal-extractor.spec.ts 与 cli/scan-clinical-signals 共用同一份实现),
 * 一旦掺进 DI 就只能靠构造 mock 来测,而这个模块判错的代价是医疗风险,不是功能缺陷。
 */

/** 句子 + 它在原文里的起始偏移 */
interface Sentence {
  text: string;
  offset: number;
}

/** 子句分隔符 —— **不切断否定作用域**,只用来判断「再断言」是否另起了一段。见下 isNegated。 */
const CLAUSE_SEPARATORS = ['；', ';', '、', ',', '，'];

function splitSentences(text: string, delimiters: string[]): Sentence[] {
  const out: Sentence[] = [];
  let start = 0;
  for (let i = 0; i < text.length; i++) {
    if (delimiters.includes(text[i]!)) {
      if (i > start) out.push({ text: text.slice(start, i), offset: start });
      start = i + 1;
    }
  }
  if (start < text.length) out.push({ text: text.slice(start), offset: start });
  return out;
}

/**
 * 判定句内某关键词是否被否定。
 *
 * 【作用域】否定词管到**句末**,并列符(;;、,)不切断 —— 这是全模块最要紧的一条规则:
 *
 *   「患者否认有高血压、冠心病等心血管疾病;糖尿病、痛风等代谢性疾病。」
 *     ↑否认                                ↑糖尿病在分号之后,仍在否定作用域内
 *
 *   生产 88.9 万条既往史实测:按分号切作用域 → 糖尿病判阳性 1,328 人;
 *   按句切 → 141 人。**差 9.4 倍**,而正确答案是后者。这条错了整个模块就是负资产。
 *
 * 【再断言】只有当否定词与关键词之间**隔了一个并列符**时,`有/患/服用…` 才翻回阳性:
 *
 *   「否认系统病史,有高血压」  → 有 在「,」之后 → 阳性 ✅
 *   「患者否认有高血压」        → 有 紧贴否认,无分隔符 → 仍是否定 ✅
 *
 *   若不要求隔分隔符,「否认有」这个极常见的搭配会被整片判成阳性 —— 那是医疗事故级的错。
 */
function isNegated(
  sentence: string,
  kwIndex: number,
  markers: string[],
  reassert: string[],
): boolean {
  const before = sentence.slice(0, kwIndex);

  // 取**最近**的那个否定词(一句里可能有多个:「否认A,未见B」)
  let negEnd = -1;
  for (const m of markers) {
    const i = before.lastIndexOf(m);
    if (i >= 0 && i + m.length > negEnd) negEnd = i + m.length;
  }
  if (negEnd < 0) return false;

  const between = sentence.slice(negEnd, kwIndex);
  const sepIdx = Math.max(...CLAUSE_SEPARATORS.map((s) => between.lastIndexOf(s)));
  if (sepIdx < 0) return true; // 否定词与关键词之间没有并列符 → 同一段陈述,否定成立

  // 分隔符之后才出现的再断言词才算数
  const afterSep = between.slice(sepIdx + 1);
  return !reassert.some((r) => afterSep.includes(r));
}

function addDays(base: Date, days: number): string {
  const d = new Date(base.getTime() + days * 86_400_000);
  return d.toISOString().slice(0, 10);
}

/** ttl → (validUntil 硬失效日, reviewAfter 复评日)。两者都相对**源事实发生时刻**算,与"现在"无关。 */
function resolveTtl(
  ttl: SignalTtl,
  occurredAt: Date | null,
): { validUntil: string | null; reviewAfter: string | null } {
  if (ttl === 'lifelong') return { validUntil: null, reviewAfter: null };
  // 源 fact 没有 occurredAt(理论上 actual 都有)→ 无法起算时效。
  // 保守取向:不敢标"已失效"(可能漏挡真禁忌),按终身处理,交由人看原文判断。
  if (!occurredAt) return { validUntil: null, reviewAfter: null };
  if ('days' in ttl) return { validUntil: addDays(occurredAt, ttl.days), reviewAfter: null };
  return { validUntil: null, reviewAfter: addDays(occurredAt, ttl.review_days) };
}

/**
 * 该次命中是否被排除词吞掉(命中区间落在某个更长的排除词里)。
 *
 * 中文无词边界,短关键词必然被更长的词包住 —— 生产实测两例:
 *   「孕期」⊂「备孕期」    备孕 = 还没怀上,判成妊娠禁忌是错的(78 行)
 *   「妊娠」⊂「妊娠期龈炎」 那是牙龈炎诊断名,不是当前妊娠状态(58 行)
 * 靠把 `any` 写得更长治不了(变体无穷),必须有显式反向清单。
 */
function excluded(sentence: string, idx: number, kwLen: number, notAny: string[]): boolean {
  for (const bad of notAny) {
    let from = 0;
    for (;;) {
      const b = sentence.indexOf(bad, from);
      if (b < 0) break;
      if (b <= idx && b + bad.length >= idx + kwLen) return true; // 命中区间被完整包住
      from = b + 1;
    }
  }
  return false;
}

/** 找到首个未被排除的命中位置;没有则 -1 */
function findKeyword(sentence: string, kw: string, notAny: string[]): number {
  let from = 0;
  for (;;) {
    const i = sentence.indexOf(kw, from);
    if (i < 0) return -1;
    if (!excluded(sentence, i, kw.length, notAny)) return i;
    from = i + 1;
  }
}

/** 一个 code 在一段文本里的多次命中如何收敛 —— 阳性 > 不可判 > 否定 */
function rank(negated: boolean | null): number {
  return negated === false ? 2 : negated === null ? 1 : 0;
}

/**
 * 从**一个字段**的文本里抽信号。
 *
 * @param occurredAt 源 fact 的发生时刻,用于起算时效
 */
export function extractSignalsFromText(
  text: string,
  field: string,
  occurredAt: Date | null,
  dict: SignalDict,
): ClinicalSignal[] {
  if (!text || !text.trim()) return [];
  const { markers, reassert, sentence_delimiters, undecidable_patterns } = dict.negation;
  const sentences = splitSentences(text, sentence_delimiters);

  // code → 当前最优命中(按 rank 收敛)
  const best = new Map<string, ClinicalSignal>();

  for (const sent of sentences) {
    // 模板没删干净(「患者否认/自述有…」)→ 本句所有信号都判不可判。
    // 这不是抽取能力不足,是**源数据本身没有结论** —— PAC 不替宿主猜。
    const undecidable = undecidable_patterns.some((p) => sent.text.includes(p));

    for (const def of dict.signals) {
      const notAny = def.not_any ?? [];
      for (const kw of def.any) {
        const idx = findKeyword(sent.text, kw, notAny);
        if (idx < 0) continue;

        const negated = undecidable
          ? null
          : isNegated(sent.text, idx, markers, reassert);
        const { validUntil, reviewAfter } = resolveTtl(def.ttl, occurredAt);
        const candidate: ClinicalSignal = {
          code: def.code,
          label: def.label,
          phrase: sent.text.trim().slice(0, 120),
          span: [sent.offset + idx, sent.offset + idx + kw.length],
          field,
          negated,
          severity: def.severity,
          contraindicates: def.contraindicates,
          validUntil,
          reviewAfter,
          extractedBy: dict.version,
        };
        const prev = best.get(def.code);
        if (!prev || rank(candidate.negated) > rank(prev.negated)) {
          best.set(def.code, candidate);
        }
        break; // 同一 def 命中一个关键词即可,不必把同义词全试一遍
      }
    }
  }

  return [...best.values()];
}

/**
 * 从一条 fact 的 content 抽信号(按字典配置的字段范围)。
 *
 * 多字段命中同一 code 时同样按 阳性>不可判>否定 收敛 —— 既往史写"否认糖尿病"、
 * 现病史写"糖尿病服药中",以后者为准(漏挡禁忌比多挡一条危险得多)。
 */
export function extractSignalsFromContent(
  content: Record<string, unknown>,
  fields: string[],
  occurredAt: Date | null,
  dict: SignalDict,
): ClinicalSignal[] {
  const best = new Map<string, ClinicalSignal>();
  for (const field of fields) {
    const raw = content[field];
    if (typeof raw !== 'string') continue;
    for (const sig of extractSignalsFromText(raw, field, occurredAt, dict)) {
      const prev = best.get(sig.code);
      if (!prev || rank(sig.negated) > rank(prev.negated)) best.set(sig.code, sig);
    }
  }
  return [...best.values()];
}

/** 字典里所有信号定义,按 code 索引(消费方查 label / severity 用) */
export function indexSignalDefs(dict: SignalDict): Map<string, SignalDef> {
  return new Map(dict.signals.map((s) => [s.code, s]));
}
