import { ContraindicationFeatureExtractor } from '../src/modules/persona/features/contraindication.feature';
import { loadSignalDict } from '../src/modules/clinical-signals/signal-dict';
import {
  extractSignalsFromContent,
  extractSignalsFromText,
} from '../src/modules/clinical-signals/signal-extractor';

/**
 * 临床信号抽取回归。
 *
 * 【这个文件为什么比一般 spec 重要】
 * 判错的代价不是功能缺陷,是医疗风险 —— 把「否认药物过敏」判成阳性,PAC 会给一个
 * 没有过敏的患者挂「麻醉禁忌」;反过来把真的抗凝药漏掉,PAC 会把一个出血高危患者
 * 推去拔牙。两个方向都不是"体验问题"。
 *
 * 下面的用例**全部抄自生产真实文本**(jvs-dw,88.9 万条既往史里的高频形态),
 * 不是我编的语料 —— 编出来的句子只能验证我脑子里的规则,验证不了医生实际怎么写。
 */

const dict = loadSignalDict();
const OCCURRED = new Date('2026-01-01T00:00:00Z');

function codes(text: string, field = 'past_history') {
  return extractSignalsFromText(text, field, OCCURRED, dict);
}
function find(text: string, code: string) {
  return codes(text).find((s) => s.code === code);
}

describe('字典自身', () => {
  it('加载并通过 schema 校验', () => {
    expect(dict.signals.length).toBeGreaterThan(0);
    expect(dict.version).toMatch(/^clinical-signals@/);
  });

  it('check 档不得声明治疗域,absolute/relative 必须声明(loader 强校验)', () => {
    for (const s of dict.signals) {
      if (s.severity === 'check') expect(s.contraindicates).toHaveLength(0);
      else expect(s.contraindicates.length).toBeGreaterThan(0);
    }
  });

  // yaml 里加了新 fact 类型而画像那边没加 = 白抽:信号写进库了,但没人读。
  // 这种漏配不会报错,只会安静地少一批禁忌 —— 必须由测试守住。
  it('yaml 的 scan_fields 与画像 contraindication 读取的 fact 类型一致', () => {
    const yamlTypes = dict.scan_fields.map((f) => f.fact_type).sort();
    const readTypes = [
      ...(ContraindicationFeatureExtractor as unknown as { SIGNAL_FACT_TYPES: readonly string[] })
        .SIGNAL_FACT_TYPES,
    ].sort();
    expect(readTypes).toEqual(yamlTypes);
  });

  it('扫描字段里不含主诉/检查所见/处置 —— 它们不承载全身病史,只会引噪音', () => {
    const emr = dict.scan_fields.find((f) => f.fact_type === 'emr_record');
    expect(emr?.fields).not.toContain('illness_desc');
    expect(emr?.fields).not.toContain('exam_findings');
    expect(emr?.fields).not.toContain('disposal');
  });
});

describe('否定作用域 —— 并列符不切断(本模块最要紧的一条)', () => {
  // 生产 594 条。分号后的「糖尿病」仍在「患者否认」的作用域内。
  // 按分号切作用域会把它判成阳性 → 生产实测 1,328 人 vs 正确的 141 人,差 9.4 倍。
  const 长否定串 =
    '患者否认有高血压、冠心病等心血管疾病;糖尿病、痛风等代谢性疾病。患者否认有肝炎、梅毒、艾滋病等传染性疾病。';

  it('分号后的并列项仍被否定', () => {
    expect(find(长否定串, 'diabetes')?.negated).toBe(true);
    expect(find(长否定串, 'hypertension')?.negated).toBe(true);
  });

  it('句号切断作用域 —— 下一句独立判定', () => {
    const t = '否认系统性疾病史。5年前安装心脏起搏器。';
    expect(find(t, 'cardiac_implant')?.negated).toBe(false);
  });

  it('「否认有X」不因中间那个「有」翻成阳性', () => {
    // 若「再断言」不要求隔并列符,「否认有」这个极常见搭配会被整片判反 —— 医疗事故级
    expect(find('患者否认有高血压', 'hypertension')?.negated).toBe(true);
  });

  it('隔了并列符的再断言翻回阳性', () => {
    expect(find('否认系统病史,有高血压病史', 'hypertension')?.negated).toBe(false);
  });

  it('取最近的否定词,不被句首的否定词裹挟', () => {
    expect(find('否认药物过敏史,有哮喘史', 'asthma')?.negated).toBe(false);
  });
});

describe('阳性 —— 生产真实句', () => {
  it.each([
    ['3年前行心脏支架治疗,现口服抗凝血药,降脂药治疗', 'anticoagulant'],
    ['4年前外院种植修复,3年前心脏搭桥手术,目前口服阿司匹林等抗凝药物,全身状况稳定', 'anticoagulant'],
    ['2020年5月因脚踝粉碎性骨折术后注射地舒单抗治疗骨质疏松,第二针未注射', 'antiresorptive'],
    ['2+年前颌面部放疗史', 'radiotherapy'],
    ['5年前安装心脏起搏器,半年前服用抗凝史', 'cardiac_implant'],
    ['佩戴心脏起搏器38年', 'cardiac_implant'],
    ['哺乳期', 'lactation'],
    ['1年前外院牙周治疗,现怀孕7月余', 'pregnancy'],
    ['青霉素过敏史,利多卡因过敏', 'local_anesthetic_allergy'],
    ['有哮喘史,四年前有过敏性紫癜', 'asthma'],
  ])('%s → %s 阳性', (text, code) => {
    expect(find(text, code)?.negated).toBe(false);
  });
});

describe('否定 —— 生产高频模板', () => {
  it.each([
    ['无特殊陈述。否认药物过敏史', 'local_anesthetic_allergy'],
    ['否认系统病史、否认传染病史,否认药物过敏史', 'diabetes'],
    ['既往体健,无高血压,心脏病,糖尿病史', 'diabetes'],
    ['既往体健,无高血压,心脏病,糖尿病史', 'hypertension'],
    ['每日刷牙两次,横竖混刷,每次小于3min。无吸烟史。余否认特殊', 'smoking'],
  ])('%s → %s 不判阳性', (text, code) => {
    const s = find(text, code);
    if (s) expect(s.negated).not.toBe(false);
  });

  it('新冠筛查模板不误伤(生产 5.3 万条)', () => {
    const t =
      '近2周内无体温大于37.3摄氏度;无流涕、咽喉痛等临床表现;近2周内无疫区(含境外)居住或旅行史;未接触过发热病人;无外出';
    expect(codes(t).filter((s) => s.negated === false)).toHaveLength(0);
  });
});

describe('不可判 —— 源数据本身没有结论', () => {
  // 生产 116 条:医生没删模板里那个斜杠。这不是 NLP 能力问题
  it('「患者否认/自述有…」→ negated=null,且不产禁忌', () => {
    const t = '患者否认/自述有高血压、冠心病等心血管疾病;糖尿病、痛风等代谢性疾病';
    expect(find(t, 'hypertension')?.negated).toBeNull();
    expect(find(t, 'diabetes')?.negated).toBeNull();
  });
});

describe('关键词精度陷阱(注释里写了原因的,这里守住)', () => {
  it('「钛网支架骨增量手术」是口腔术式,不能被裸「支架」吞成心脏植入物', () => {
    expect(find('2025年5月7日,右下后牙区行钛网支架骨增量手术', 'cardiac_implant')).toBeUndefined();
  });

  it('裸「过敏」不产局麻过敏(生产 22,653 条里 98.4% 是「否认药物过敏史」)', () => {
    expect(find('否认系统性疾病史及过敏史', 'local_anesthetic_allergy')).toBeUndefined();
    expect(find('过敏性鼻炎', 'local_anesthetic_allergy')).toBeUndefined();
  });

  it('吸烟必须带「史/龄」—— 裸「吸烟」在生产 85% 是「无吸烟史」', () => {
    expect(find('患者自述有吸烟史', 'smoking')?.negated).toBe(false);
  });

  // 下面两条是生产全量抽取(702 万行)实测揪出来的假阳性,不是设想出来的
  it('「备孕期」不算妊娠 —— 还没怀上(生产 78 行)', () => {
    expect(find('备孕期,otc洁牙', 'pregnancy')).toBeUndefined();
  });

  it('「妊娠期龈炎」是牙龈炎诊断名,不是当前妊娠状态(生产 58 行)', () => {
    expect(find('曾患妊娠期龈炎,否认夜磨牙史', 'pregnancy')).toBeUndefined();
    // 真怀孕仍要抓到
    expect(find('妊娠期龈炎;现怀孕6个月', 'pregnancy')?.negated).toBe(false);
  });
});

describe('时效 —— 你的行业洞察落地:禁忌≠终身禁忌', () => {
  it('放疗 / 抗骨吸收药 = 终身(validUntil=null)', () => {
    expect(find('2+年前颌面部放疗史', 'radiotherapy')?.validUntil).toBeNull();
    expect(find('长期服用双膦酸盐', 'antiresorptive')?.validUntil).toBeNull();
  });

  it('哺乳期 = 源事实发生日 + 180 天(产后 6 个月解除)', () => {
    expect(find('哺乳期', 'lactation')?.validUntil).toBe('2026-06-30');
  });

  it('妊娠 = +280 天', () => {
    expect(find('妊娠5月余', 'pregnancy')?.validUntil).toBe('2026-10-08');
  });

  it('抗凝药走复评日而非硬失效 —— 会停药,但不该凭空消失', () => {
    const s = find('长期口服泰嘉(抗凝药物)', 'anticoagulant');
    expect(s?.validUntil).toBeNull();
    expect(s?.reviewAfter).toBe('2028-01-01'); // 2026-01-01 + 730d(两个平年)
  });

  it('时效相对**源事实发生时刻**算,与"现在"无关 —— 存的是日期不是布尔,不会随时间变质', () => {
    const old = extractSignalsFromText('哺乳期', 'past_history', new Date('2016-03-01'), dict);
    expect(old[0]?.validUntil).toBe('2016-08-28'); // 十年前那条自然已过期,但事实照样留着
  });
});

describe('证据可回溯', () => {
  it('带原文片段、字符区间与来源字段 —— 详情页展示医生原话,不是 PAC 的转述', () => {
    const text = '既往体健。3年前行心脏支架治疗,现口服抗凝血药。';
    const s = extractSignalsFromText(text, 'past_history', OCCURRED, dict).find(
      (x) => x.code === 'cardiac_implant',
    );
    expect(s?.field).toBe('past_history');
    expect(text.slice(s!.span[0], s!.span[1])).toBe('心脏支架');
    expect(s?.phrase).toContain('心脏支架治疗');
    expect(s?.extractedBy).toBe(dict.version);
  });
});

describe('多字段收敛', () => {
  it('阳性 > 不可判 > 否定 —— 漏挡禁忌比多挡一条危险得多', () => {
    const out = extractSignalsFromContent(
      {
        past_history: '否认系统性疾病史。',
        pre_illness: '现怀孕7月余。',
      },
      ['past_history', 'pre_illness'],
      OCCURRED,
      dict,
    );
    expect(out.find((s) => s.code === 'pregnancy')?.negated).toBe(false);
  });

  it('非字符串字段安全跳过(exam_findings 是 JSON 原文)', () => {
    expect(
      extractSignalsFromContent({ past_history: null, other: 42 }, ['past_history', 'other'], OCCURRED, dict),
    ).toHaveLength(0);
  });
});

describe('无信号的文本不产任何 signal —— 关系到会不会掀起无谓的 fact 版本波', () => {
  it.each(['无特殊陈述', '无特殊', '体健', '同前', '曾在外院补牙', '定期洁牙史'])('%s', (t) => {
    expect(codes(t)).toHaveLength(0);
  });
});
