"""
=============================================================================
第 4 讲 · 工具模块：虚构的临床试验与病人
=============================================================================

对应讲义：lectures/lecture04_accountable_ai.md 第 0.4 节（本讲的玩具世界）

-----------------------------------------------------------------------------
【这个模块做什么？】
-----------------------------------------------------------------------------
临床试验匹配：给一个病人，判断他是否符合某个临床试验的入组条件（以及在几千个试验里找出他可能符合的那些）。
这个模块生成一个可以精确评估的玩具版本：

    · 一个很小的**医学本体**（ontology）：概念之间的上下位关系，例如"局灶性癫痫"是一种"癫痫"。
      真实系统用 SNOMED CT 这样有几十万个概念的标准本体。
    · **试验**：每个试验有一个目标疾病和若干条入组 / 排除条件（年龄、诊断、ECOG 体能状态、肌酐、
      HbA1c、怀孕、HIV、心血管疾病、既往治疗……）。每条条件既有**自然语言**的写法（给 LLM 读），
      也有对应的**形式化公式**（给求解器用，见 logic.py）。
    · **病人**：结构化的事实（其中一部分是**缺失**的：病历里没写），以及几种不同措辞的**病历文字**
      （同一个病人，换种说法 —— 用来检验"同样的事实是否得到同样的决定"）。
    · **决策政策**（policy）：遇到缺失值、遇到含糊的概念时怎么办。本模块采用的政策是：
        P1 缺失的值视为"未知"：只要存在一种可能的取值让病人符合条件，就判为"可能合格"（进入下一步筛查），
           并且把这些取值作为**明确的假设**列出来；
        P2 "控制良好的高血压"**不算**"有临床意义的心血管疾病"（本体里它不在心血管疾病之下）。
      标准答案 = 在这套政策下，求解器给出的判定。

【边界】这是一个极度简化的世界：真实的入组条件有几十条，涉及时间（"过去 6 个月内"）、剂量、
    基因检测结果、多种疾病之间的复杂逻辑；真实的病历是几十页的自由文本，充满否定、不确定和矛盾。
"""

import random
from dataclasses import dataclass

from logic import solve

# =============================================================================
# 第 1 部分：本体
# =============================================================================
# 子概念 → 父概念。"has dx:X" 为真，当且仅当病人有某个属于 X 的具体诊断。
PARENT = {
    "focal epilepsy": "epilepsy", "generalized epilepsy": "epilepsy", "epilepsy": "neurological disorder",
    "migraine": "neurological disorder",
    "type 1 diabetes": "diabetes", "type 2 diabetes": "diabetes", "diabetes": "metabolic disorder",
    "breast cancer": "cancer", "lung cancer": "cancer", "colon cancer": "cancer",
    "heart failure": "cardiovascular disease", "coronary artery disease": "cardiovascular disease",
    "uncontrolled hypertension": "cardiovascular disease",
    "controlled hypertension": "hypertension",
    "chronic kidney disease": "kidney disease",
    "asthma": "respiratory disease", "COPD": "respiratory disease",
}
LEAF_DX = ["focal epilepsy", "generalized epilepsy", "migraine", "type 1 diabetes", "type 2 diabetes",
           "breast cancer", "lung cancer", "colon cancer", "heart failure", "coronary artery disease",
           "uncontrolled hypertension", "controlled hypertension", "chronic kidney disease", "asthma", "COPD"]
CONCEPTS = sorted(set(PARENT) | set(PARENT.values()))
TARGETS = ["epilepsy", "focal epilepsy", "migraine", "diabetes", "type 2 diabetes", "breast cancer",
           "lung cancer", "cancer", "asthma", "COPD", "chronic kidney disease", "heart failure"]
TREATMENT_OF = {"epilepsy": "anticonvulsant", "diabetes": "insulin", "cancer": "chemotherapy",
                "respiratory disease": "inhaled steroid", "cardiovascular disease": "beta blocker",
                "migraine": "triptan", "kidney disease": "dialysis"}
TREATMENTS = sorted(set(TREATMENT_OF.values()) | {"metformin"})


def ancestors(concept):
    """
    一个概念和它的所有祖先（包括它自己）。

    【例子】ancestors("focal epilepsy") → {"focal epilepsy", "epilepsy", "neurological disorder"}
    【智能体联系】SatIR 用 SNOMED CT 的上下位关系做同样的事：试验要求"癫痫"，有"局灶性癫痫"的病人也算。
    """
    out = {concept}
    while concept in PARENT:
        concept = PARENT[concept]
        out.add(concept)
    return out


# =============================================================================
# 第 2 部分：试验
# =============================================================================
@dataclass
class Criterion:
    """
    一条入组或排除条件。

    【字段】
        kind    : "inclusion" 或 "exclusion"
        text    : 自然语言的写法（给 LLM 读）
        formula : 形式化的公式（给求解器用）。排除条件的公式描述的是"被排除的情况"，
                  组合进试验公式时会取"非"。
        cross   : True = 这条条件涉及多个变量之间的逻辑（例如"ECOG 0-1，或者 70 岁以下 ECOG 2"），
                  在 SatIR 的数据库投影里只能被放宽（04 号文件）
    """
    kind: str
    text: str
    formula: tuple
    cross: bool = False


@dataclass
class Trial:
    """一个临床试验：编号、目标疾病、条件列表。"""
    tid: str
    target: str
    criteria: list

    def formula(self):
        """整个试验的公式：所有入组条件都满足，且所有排除条件都不满足。"""
        parts = []
        for c in self.criteria:
            parts.append(("label", c.text, c.formula if c.kind == "inclusion" else ("not", c.formula)))
        return ("and", parts)

    def text(self):
        """试验的自然语言描述（给 LLM 读）。"""
        inc = [c.text for c in self.criteria if c.kind == "inclusion"]
        exc = [c.text for c in self.criteria if c.kind == "exclusion"]
        return (f"Trial {self.tid} for {self.target}. Inclusion criteria: " + "; ".join(inc) +
                ". Exclusion criteria: " + ("; ".join(exc) if exc else "none") + ".")


def make_trial(tid, rng):
    """
    随机生成一个试验。

    【思路】每个试验都有目标疾病和年龄范围；再按目标疾病的类型加上常见的条件：
        癌症试验：ECOG（有时是"ECOG 0-1，或 70 岁以下 ECOG 2"这种跨变量的条件）、肌酐；
        糖尿病试验：HbA1c 的范围、"未用过胰岛素"；
        其他：随机加上肌酐、ECOG 等。排除条件从怀孕、HIV、心血管疾病、既往治疗里随机选。
    """
    target = rng.choice(TARGETS)
    lo, hi = rng.choice([(18, 65), (18, 75), (40, 80), (18, 85), (50, 85)])
    crit = [Criterion("inclusion", f"Age {lo} to {hi} years", ("and", [("ge", "age", lo), ("le", "age", hi)])),
            Criterion("inclusion", f"Diagnosis of {target}", ("bool", f"dx:{target}"))]
    family = ancestors(target)
    if "cancer" in family:
        if rng.random() < 0.4:
            crit.append(Criterion("inclusion", "ECOG performance status 0-1, or 2 if younger than 70",
                                  ("or", [("le", "ecog", 1), ("and", [("le", "ecog", 2), ("le", "age", 69)])]),
                                  cross=True))
        else:
            k = rng.choice([1, 2])
            crit.append(Criterion("inclusion", f"ECOG performance status 0-{k}", ("le", "ecog", k)))
        crit.append(Criterion("inclusion", "Serum creatinine at most 1.5 mg/dL", ("le", "creatinine", 15)))
    elif "diabetes" in family:
        a, b = rng.choice([(70, 100), (75, 110), (65, 95)])
        crit.append(Criterion("inclusion", f"HbA1c between {a / 10:.1f}% and {b / 10:.1f}%",
                              ("and", [("ge", "hba1c", a), ("le", "hba1c", b)])))
        if rng.random() < 0.5:
            crit.append(Criterion("exclusion", "Prior treatment with insulin", ("bool", "prior:insulin")))
    else:
        if rng.random() < 0.5:
            c = rng.choice([13, 15, 20])
            crit.append(Criterion("inclusion", f"Serum creatinine at most {c / 10:.1f} mg/dL", ("le", "creatinine", c)))
        if rng.random() < 0.3:
            crit.append(Criterion("inclusion", "ECOG performance status 0-2", ("le", "ecog", 2)))
    if rng.random() < 0.6:
        crit.append(Criterion("exclusion", "Pregnancy", ("bool", "pregnant")))
    if rng.random() < 0.5:
        crit.append(Criterion("exclusion", "HIV infection", ("bool", "hiv")))
    if rng.random() < 0.5:
        crit.append(Criterion("exclusion", "Clinically significant cardiovascular disease",
                              ("bool", "dx:cardiovascular disease")))
    if rng.random() < 0.3:
        t = rng.choice(TREATMENTS)
        if not any(c.text == f"Prior treatment with {t}" for c in crit):
            crit.append(Criterion("exclusion", f"Prior treatment with {t}", ("bool", f"prior:{t}")))
    return Trial(tid, target, crit)


# =============================================================================
# 第 3 部分：病人
# =============================================================================
@dataclass
class Patient:
    """
    一个病人。

    【字段】
        pid       : 编号
        age, female, dx（具体诊断的列表）, prior（既往治疗的列表）
        labs      : dict："ecog"、"creatinine"（×10 的整数，15 表示 1.5 mg/dL）、"hba1c"（×10）；缺失为 None
        pregnant  : True / False / None（未知）；男性固定为 False
        hiv       : True / False / None（未知）
    """
    pid: str
    age: int
    female: bool
    dx: list
    prior: list
    labs: dict
    pregnant: object
    hiv: object

    def facts(self):
        """
        病人的结构化事实（求解器的输入）：变量名 → 值；未知的值为 None。

        【思路】诊断按本体展开："dx:epilepsy" 为真，当且仅当病人有某种属于癫痫的具体诊断。
            诊断列表视为完整的（没写的诊断就是没有）；化验、怀孕、HIV 则可能缺失。
        """
        f = {"age": self.age, "female": self.female, "pregnant": self.pregnant, "hiv": self.hiv}
        f.update(self.labs)
        have = set().union(*[ancestors(d) for d in self.dx]) if self.dx else set()
        for c in CONCEPTS:
            f[f"dx:{c}"] = c in have
        for t in TREATMENTS:
            f[f"prior:{t}"] = t in self.prior
        return f

    def note(self, variant=0):
        """
        病人的病历文字。不同的 variant 是同一组事实的不同写法（顺序、措辞、缺失值怎么表达）。

        【参数】variant：0、1、2……
        【返回】str
        """
        rng = random.Random(f"note|{self.pid}|{variant}")
        sex = "woman" if self.female else "man"
        parts = [rng.choice([f"{self.age}-year-old {sex}", f"{sex.capitalize()}, aged {self.age}",
                             f"A {sex} of {self.age} years"]) + " with " + " and ".join(self.dx) + "."]
        labs = self.labs
        for key, name, fmt in [("ecog", "ECOG performance status", lambda v: str(v)),
                               ("creatinine", "Serum creatinine", lambda v: f"{v / 10:.1f} mg/dL"),
                               ("hba1c", "HbA1c", lambda v: f"{v / 10:.1f}%")]:
            v = labs.get(key)
            if v is not None:
                parts.append(f"{name} {fmt(v)}.")
            elif key != "hba1c" or any("diabetes" in d for d in self.dx):
                short = {"ecog": "ECOG score", "creatinine": "creatinine value", "hba1c": "HbA1c value"}[key]
                parts.append(rng.choice([f"{name} not reported.", f"No recent {short} on file.", ""]))
        if self.female:
            if self.pregnant is None:
                parts.append(rng.choice(["Pregnancy status not documented.", ""]))
            else:
                parts.append("Pregnant." if self.pregnant else rng.choice(["Not pregnant.", "Negative pregnancy test."]))
        if self.hiv is None:
            parts.append(rng.choice(["HIV status unknown.", "No HIV test on record.", ""]))
        else:
            parts.append("HIV positive." if self.hiv else rng.choice(["HIV negative.", "HIV test negative."]))
        parts.append("Previously treated with " + ", ".join(self.prior) + "." if self.prior else
                     rng.choice(["No prior treatments.", "Treatment-naive."]))
        head, rest = parts[0], [p for p in parts[1:] if p]
        rng.shuffle(rest)
        return " ".join([head] + rest)


def make_patient(pid, rng, target=None):
    """
    随机生成一个病人。

    【参数】target：给定时，病人一定有一个属于这个概念的诊断（用来生成"和某个试验相关"的病人）
    """
    leaves = [d for d in LEAF_DX if target and target in ancestors(d)]
    dx = [rng.choice(leaves)] if leaves else [rng.choice(LEAF_DX)]
    if rng.random() < 0.4:
        extra = rng.choice(LEAF_DX)
        if extra not in dx:
            dx.append(extra)
    if rng.random() < 0.2 and "controlled hypertension" not in dx:
        dx.append("controlled hypertension")          # 很常见的合并症 —— 也是最容易引起歧义的一个
    female = rng.random() < 0.5
    age = rng.randint(14, 88)
    labs = {"ecog": rng.choice([0, 1, 1, 2, 2, 3]) if rng.random() < 0.7 else None,
            "creatinine": rng.randint(6, 22) if rng.random() < 0.6 else None,
            "hba1c": (rng.randint(55, 125) if rng.random() < 0.75 else None)
            if any("diabetes" in d for d in dx) else None}
    pregnant = (rng.random() < 0.08 if rng.random() < 0.7 else None) if female and age <= 45 else False
    hiv = (rng.random() < 0.05) if rng.random() < 0.6 else None
    prior = sorted({TREATMENT_OF[a] for d in dx for a in ancestors(d) if a in TREATMENT_OF and rng.random() < 0.5})
    if any("diabetes" in d for d in dx) and rng.random() < 0.5:
        prior = sorted(set(prior) | {"metformin"})
    return Patient(pid, age, female, dx, prior, labs, pregnant, hiv)


TYPES = {"age": "int", "ecog": "int", "creatinine": "int", "hba1c": "int", "female": "bool",
         "pregnant": "bool", "hiv": "bool"}
TYPES.update({f"dx:{c}": "bool" for c in CONCEPTS})
TYPES.update({f"prior:{t}": "bool" for t in TREATMENTS})


def gold_decision(trial, patient):
    """
    标准答案：在政策 P1、P2 下，求解器的判定。

    【返回】(是否可能合格, 需要的假设)
    """
    return solve(trial.formula(), patient.facts(), TYPES)


def make_cases(n=300, seed=0):
    """
    生成 n 个"试验—病人"对，病人都有试验的目标疾病（相关的配对，才值得判断是否合格）。

    【返回】list[(Trial, Patient)]
    """
    rng = random.Random(seed)
    cases = []
    for i in range(n):
        t = make_trial(f"T{i:04d}", rng)
        p = make_patient(f"P{i:04d}", rng, target=t.target)
        cases.append((t, p))
    return cases


if __name__ == "__main__":
    cases = make_cases(5)
    t, p = cases[1]
    print(t.text())
    print("\n病历（两种写法）：")
    print("  " + p.note(0))
    print("  " + p.note(1))
    ok, assumptions = gold_decision(t, p)
    print(f"\n标准判定：{'可能合格' if ok else '不合格'}；需要的假设：{assumptions}")
    elig = sum(gold_decision(t, p)[0] for t, p in make_cases(300))
    print(f"300 个相关的配对里，可能合格的：{elig}")
