"""
MASLDTrialReadQC-Kor — 핵심 분석 로직 모듈

이 모듈은 streamlit 에 의존하지 않는다 (CLI/검수에서 직접 import 가능).
사용 패키지: pandas, numpy, scipy.stats. scikit-learn 은 선택적(없어도 동작).

기능:
  1. load_data / measurement_quality_flags  — 측정 품질 플래그
  2. reader_drift                           — central reader 종단 drift 탐지
  3. site_scanner_bias                      — 사이트/스캐너 systematic bias (funnel)
  4. implausible_changes / biopsy_agreement — 비현실 변화율 + biopsy 일치도 drift
  5. build_rbqm_report                      — RBQM 리포트 생성

면책: 본 모듈의 품질 기준·자연 변화율 임계값은 예시·교육용이다. 실제 임상시험
모니터링 의사결정은 자격을 갖춘 담당자가 최신 가이드라인으로 검증해 수행해야 한다.
"""

from __future__ import annotations

import os
import numpy as np
import pandas as pd
from scipy import stats

# ----------------------------------------------------------------------
# 품질 기준 (예시·교육용 — 최신 가이드라인으로 검증 필요)
# ----------------------------------------------------------------------
# VCTE: IQR/median <30% 및 유효측정 >=10 이 신뢰성 통념 (예시값)
QC_THRESHOLDS = {
    "vcte_iqr_median_max": 0.30,      # IQR/median ratio 상한
    "vcte_valid_min": 10,             # 유효측정 최소 횟수
    # 생물학적으로 implausible 한 주당 변화율(상한, 절대값)
    "lsm_max_weekly_drop_kpa": 0.30,  # kPa/week 초과 급감 = implausible
    "pdff_max_weekly_drop_pct": 0.50, # %/week 초과 급감 = implausible
    # reader drift: reader 기울기가 동료 중앙값에서 robust z(MAD) 로
    # 이 값 초과 이탈 + 회귀 자체 유의 시 플래그
    "reader_drift_robust_z": 2.5,
    "reader_drift_p": 0.05,
    # 사이트 bias: funnel 2*SD(=z) 초과 시 outlier
    "site_bias_z": 2.0,
}

NIT_COLUMNS = ["vcte_lsm_kpa", "vcte_cap_dbm", "mri_pdff_pct", "mre_kpa", "fib4", "elf"]


# ======================================================================
# 0. 데이터 로딩
# ======================================================================
def load_data(data_dir):
    """data_dir 의 measurements/biopsy/sites CSV 를 읽어 dict 반환."""
    meas = pd.read_csv(os.path.join(data_dir, "measurements.csv"))
    biopsy = pd.read_csv(os.path.join(data_dir, "biopsy.csv"))
    sites = pd.read_csv(os.path.join(data_dir, "sites.csv"))
    return {"measurements": meas, "biopsy": biopsy, "sites": sites}


# ======================================================================
# 1. 측정 품질 플래그
# ======================================================================
def measurement_quality_flags(meas: pd.DataFrame) -> pd.DataFrame:
    """VCTE IQR/median·유효측정 기준 위반 행을 플래그.

    반환: 원본 + qc_iqr_fail, qc_valid_fail, qc_any_fail, qc_reason 컬럼.
    """
    df = meas.copy()
    iqr_fail = df["vcte_iqr_median_ratio"] > QC_THRESHOLDS["vcte_iqr_median_max"]
    valid_fail = df["vcte_valid_measurements"] < QC_THRESHOLDS["vcte_valid_min"]

    df["qc_iqr_fail"] = iqr_fail
    df["qc_valid_fail"] = valid_fail
    df["qc_any_fail"] = iqr_fail | valid_fail

    def _reason(r):
        parts = []
        if r["qc_iqr_fail"]:
            parts.append(
                f"IQR/median {r['vcte_iqr_median_ratio']:.2f} "
                f">{QC_THRESHOLDS['vcte_iqr_median_max']:.2f}"
            )
        if r["qc_valid_fail"]:
            parts.append(
                f"유효측정 {int(r['vcte_valid_measurements'])} "
                f"<{QC_THRESHOLDS['vcte_valid_min']}"
            )
        return "; ".join(parts)

    df["qc_reason"] = df.apply(_reason, axis=1)
    return df


def quality_summary_by_site(meas_flagged: pd.DataFrame) -> pd.DataFrame:
    """사이트별 VCTE 품질 실패율 요약."""
    g = meas_flagged.groupby("site_id").agg(
        n_measurements=("qc_any_fail", "size"),
        n_qc_fail=("qc_any_fail", "sum"),
        mean_iqr_median=("vcte_iqr_median_ratio", "mean"),
        mean_valid=("vcte_valid_measurements", "mean"),
    )
    g["qc_fail_rate"] = (g["n_qc_fail"] / g["n_measurements"]).round(3)
    g["mean_iqr_median"] = g["mean_iqr_median"].round(3)
    g["mean_valid"] = g["mean_valid"].round(1)
    return g.reset_index().sort_values("qc_fail_rate", ascending=False)


# ======================================================================
# 2. central reader 종단 drift 탐지
# ======================================================================
def reader_drift(meas: pd.DataFrame, metric: str = "vcte_lsm_kpa") -> pd.DataFrame:
    """central reader 종단 drift 탐지 (reader 간 상대 편차 기반).

    방법:
      1) case-mix(질병 중증도) 보정 — metric 을 환자별 평균으로 centering 한
         within-patient 잔차를 만든다 (환자 효과 제거 → 환자 mix 에 강건).
      2) reader 별로 잔차의 trial week 추세 기울기를 회귀로 추정한다.
      3) 정상 reader 라면 모든 reader 의 기울기가 비슷해야 하므로, reader
         기울기 분포의 중앙값/MAD 로 robust z 점수를 계산한다. robust z 가
         임계값을 넘고 회귀 자체가 유의한 reader 를 systematic drift 로 플래그.
         (단일 절대 임계값보다 동료 대비 이탈을 보는 편이 trial 전반의 진성
          치료효과 추세를 drift 로 오인하지 않는다.)

    반환: reader_id, n, slope_per_week, robust_z, p_value, r2, drift_flag, direction
    """
    df = meas[["patient_id", "reader_id", "week", metric]].dropna().copy()

    # 환자별 평균으로 centering -> within-patient 잔차 (case-mix 보정)
    pat_mean = df.groupby("patient_id")[metric].transform("mean")
    df["residual"] = df[metric] - pat_mean

    rows = []
    for rid, sub in df.groupby("reader_id"):
        if len(sub) < 5 or sub["week"].nunique() < 2:
            rows.append(
                {"reader_id": rid, "n": len(sub), "slope_per_week": np.nan,
                 "p_value": np.nan, "r2": np.nan}
            )
            continue
        lr = stats.linregress(sub["week"], sub["residual"])
        rows.append(
            {
                "reader_id": rid,
                "n": len(sub),
                "slope_per_week": round(lr.slope, 4),
                "p_value": round(lr.pvalue, 5),
                "r2": round(lr.rvalue ** 2, 3),
                "_slope_raw": lr.slope,
                "_p_raw": lr.pvalue,
            }
        )
    out = pd.DataFrame(rows)

    # reader 기울기 분포의 robust 중앙값/MAD 로 상대 이탈 평가
    slopes = out["_slope_raw"].dropna()
    if len(slopes) >= 3:
        med = slopes.median()
        mad = (slopes - med).abs().median()
        scale = mad * 1.4826 if mad > 0 else slopes.std(ddof=1)
        scale = scale if scale and scale > 0 else 1.0
    else:
        med, scale = 0.0, 1.0

    def _rz(s):
        if s != s:
            return np.nan
        return round((s - med) / scale, 2)

    out["robust_z"] = out["_slope_raw"].apply(_rz)
    out["drift_flag"] = out.apply(
        lambda r: bool(
            (r["robust_z"] == r["robust_z"])
            and abs(r["robust_z"]) > QC_THRESHOLDS["reader_drift_robust_z"]
            and (r["_p_raw"] == r["_p_raw"])
            and r["_p_raw"] < QC_THRESHOLDS["reader_drift_p"]
        ),
        axis=1,
    )
    out["direction"] = out["_slope_raw"].apply(
        lambda s: "n/a" if s != s else ("상향(과대)" if s > 0 else "하향(과소)")
    )
    out = out.drop(columns=["_slope_raw", "_p_raw"])
    return out.sort_values(
        "robust_z", key=lambda s: s.abs(), ascending=False
    ).reset_index(drop=True)


def reader_drift_points(meas: pd.DataFrame, reader_id: str,
                        metric: str = "vcte_lsm_kpa") -> pd.DataFrame:
    """특정 reader 의 잔차 산점도용 (week, residual) 포인트 반환."""
    df = meas[["patient_id", "reader_id", "week", metric]].dropna().copy()
    pat_mean = df.groupby("patient_id")[metric].transform("mean")
    df["residual"] = df[metric] - pat_mean
    return df[df["reader_id"] == reader_id][["week", "residual", metric]].copy()


# ======================================================================
# 3. 사이트/스캐너 systematic bias (funnel plot)
# ======================================================================
def site_scanner_bias(meas: pd.DataFrame, metric: str = "vcte_lsm_kpa",
                      group: str = "site_id") -> pd.DataFrame:
    """case-mix 보정 후 사이트/스캐너별 평균 잔차로 systematic bias 탐지.

    case-mix 보정: metric 을 age/bmi/week 로 OLS 회귀(공통 모형)해 기대값을
    구하고, 관측-기대 잔차의 사이트별 평균을 bias 지표로 사용한다.

    funnel: 잔차 평균의 z 점수(=평균/표준오차)가 임계값 초과 시 calibration outlier.
    반환: group, n, mean_residual, se, z_score, outlier_flag
    """
    df = meas[[group, "age", "bmi", "week", metric]].dropna().copy()

    # 공통 case-mix 모형 (OLS, intercept 포함)
    X = np.column_stack([
        np.ones(len(df)), df["age"].to_numpy(float),
        df["bmi"].to_numpy(float), df["week"].to_numpy(float)
    ])
    y = df[metric].to_numpy(float)
    beta, *_ = np.linalg.lstsq(X, y, rcond=None)
    # numpy 2.x 의 작은 행렬 matmul 거짓 RuntimeWarning 억제 (X, beta 모두 finite)
    with np.errstate(divide="ignore", over="ignore", invalid="ignore"):
        expected = X.dot(beta)
    df["expected"] = expected
    df["residual"] = df[metric] - df["expected"]

    resid_sd = df["residual"].std(ddof=1)

    rows = []
    for gid, sub in df.groupby(group):
        n = len(sub)
        mean_res = sub["residual"].mean()
        se = resid_sd / np.sqrt(n) if n > 0 else np.nan
        z = mean_res / se if se and se > 0 else 0.0
        rows.append(
            {
                group: gid,
                "n": n,
                "mean_residual": round(mean_res, 3),
                "se": round(se, 3),
                "z_score": round(z, 2),
                "outlier_flag": bool(abs(z) > QC_THRESHOLDS["site_bias_z"]),
            }
        )
    return pd.DataFrame(rows).sort_values(
        "z_score", key=lambda s: s.abs(), ascending=False
    )


# ======================================================================
# 4. implausible 변화율 + biopsy 일치도 drift
# ======================================================================
def implausible_changes(meas: pd.DataFrame) -> pd.DataFrame:
    """환자별 종단 NIT 변화에서 생물학적으로 불가능한 패턴 플래그.

    탐지:
      - 인접 방문 간 LSM/PDFF 변화율(주당)이 임계값 초과로 급감
      - 비단조 패턴 (급감 후 급반등) — V 자형 / 역 V 자형

    반환: patient_id, visit_from, visit_to, metric, weekly_change, reason
    """
    rows = []
    metrics = {
        "vcte_lsm_kpa": QC_THRESHOLDS["lsm_max_weekly_drop_kpa"],
        "mri_pdff_pct": QC_THRESHOLDS["pdff_max_weekly_drop_pct"],
    }
    for pid, sub in meas.groupby("patient_id"):
        sub = sub.sort_values("week").reset_index(drop=True)
        for metric, max_drop in metrics.items():
            vals = sub[metric].values
            weeks = sub["week"].values
            for i in range(1, len(sub)):
                dw = weeks[i] - weeks[i - 1]
                if dw <= 0:
                    continue
                weekly = (vals[i] - vals[i - 1]) / dw
                if weekly < -max_drop:
                    rows.append(
                        {
                            "patient_id": pid,
                            "site_id": sub["site_id"].iloc[0],
                            "visit_from": sub["visit"].iloc[i - 1],
                            "visit_to": sub["visit"].iloc[i],
                            "metric": metric,
                            "weekly_change": round(weekly, 3),
                            "abs_change": round(vals[i] - vals[i - 1], 2),
                            "reason": f"급감 {weekly:.3f}/주 (임계 -{max_drop}/주)",
                        }
                    )
            # 비단조: 중간점이 양끝보다 크게 낮거나 높음
            if len(vals) >= 3:
                for i in range(1, len(vals) - 1):
                    lo = min(vals[i - 1], vals[i + 1])
                    hi = max(vals[i - 1], vals[i + 1])
                    span = hi - lo
                    ref = max(abs(np.mean(vals)), 1.0)
                    if vals[i] < lo - 0.4 * ref or vals[i] > hi + 0.4 * ref:
                        rows.append(
                            {
                                "patient_id": pid,
                                "site_id": sub["site_id"].iloc[0],
                                "visit_from": sub["visit"].iloc[i - 1],
                                "visit_to": sub["visit"].iloc[i + 1],
                                "metric": metric,
                                "weekly_change": np.nan,
                                "abs_change": round(vals[i] - np.mean([lo, hi]), 2),
                                "reason": f"비단조 패턴 (중간 방문 {sub['visit'].iloc[i]} 이상치)",
                            }
                        )
    if not rows:
        return pd.DataFrame(
            columns=["patient_id", "site_id", "visit_from", "visit_to",
                     "metric", "weekly_change", "abs_change", "reason"]
        )
    return pd.DataFrame(rows)


def _cohen_kappa_weighted(a, b, n_cat):
    """선형 가중 Cohen's kappa (ordinal). scikit-learn 없이 직접 구현."""
    a = np.asarray(a, dtype=int)
    b = np.asarray(b, dtype=int)
    if len(a) == 0:
        return np.nan
    cats = list(range(n_cat))
    O = np.zeros((n_cat, n_cat))
    for x, y in zip(a, b):
        O[x, y] += 1
    n = O.sum()
    if n == 0:
        return np.nan
    row = O.sum(axis=1)
    col = O.sum(axis=0)
    E = np.outer(row, col) / n
    # 선형 가중치
    W = np.zeros((n_cat, n_cat))
    denom = (n_cat - 1) if n_cat > 1 else 1
    for i in cats:
        for j in cats:
            W[i, j] = abs(i - j) / denom
    num = (W * O).sum()
    den = (W * E).sum()
    if den == 0:
        return 1.0
    return 1.0 - num / den


def _icc(a, b):
    """두 평가자 간 ICC(2,1) 근사 (consistency)."""
    a = np.asarray(a, dtype=float)
    b = np.asarray(b, dtype=float)
    n = len(a)
    if n < 2:
        return np.nan
    M = np.column_stack([a, b])
    grand = M.mean()
    row_means = M.mean(axis=1)
    col_means = M.mean(axis=0)
    ss_total = ((M - grand) ** 2).sum()
    ss_row = 2 * ((row_means - grand) ** 2).sum()
    ss_col = n * ((col_means - grand) ** 2).sum()
    ss_err = ss_total - ss_row - ss_col
    ms_row = ss_row / (n - 1)
    ms_err = ss_err / (n - 1) if (n - 1) > 0 else np.nan
    ms_col = ss_col / 1
    denom = ms_row + (ms_col - ms_err) / n
    if denom == 0 or np.isnan(ms_err):
        return np.nan
    return (ms_row - ms_err) / denom


def biopsy_agreement(biopsy: pd.DataFrame) -> dict:
    """central pathologist 간 일치도를 trial 구간(enroll_block)별로 추적.

    반환 dict:
      - by_block : enroll_block 별 fibrosis kappa / NAS ICC
      - overall  : 전체 kappa / ICC
      - drift_flag : LATE 구간에서 일치도가 EARLY 대비 크게 저하되면 True
    """
    rows = []
    for block, sub in biopsy.groupby("enroll_block"):
        kappa = _cohen_kappa_weighted(
            sub["pathologist_a_fibrosis_stage"],
            sub["pathologist_b_fibrosis_stage"],
            n_cat=5,
        )
        icc = _icc(sub["pathologist_a_nas"], sub["pathologist_b_nas"])
        rows.append(
            {
                "enroll_block": block,
                "n": len(sub),
                "fibrosis_weighted_kappa": round(kappa, 3) if kappa == kappa else np.nan,
                "nas_icc": round(icc, 3) if icc == icc else np.nan,
            }
        )
    by_block = pd.DataFrame(rows).sort_values("enroll_block")

    overall_kappa = _cohen_kappa_weighted(
        biopsy["pathologist_a_fibrosis_stage"],
        biopsy["pathologist_b_fibrosis_stage"],
        n_cat=5,
    )
    overall_icc = _icc(biopsy["pathologist_a_nas"], biopsy["pathologist_b_nas"])

    # drift: EARLY vs LATE kappa 차이
    drift_flag = False
    drift_detail = ""
    if {"EARLY", "LATE"}.issubset(set(by_block["enroll_block"])):
        k_early = by_block.loc[
            by_block["enroll_block"] == "EARLY", "fibrosis_weighted_kappa"
        ].iloc[0]
        k_late = by_block.loc[
            by_block["enroll_block"] == "LATE", "fibrosis_weighted_kappa"
        ].iloc[0]
        if (k_early == k_early) and (k_late == k_late):
            if (k_early - k_late) > 0.15:
                drift_flag = True
                drift_detail = (
                    f"fibrosis κ가 EARLY {k_early:.2f} → LATE {k_late:.2f} 로 "
                    f"{k_early - k_late:.2f} 저하"
                )
    return {
        "by_block": by_block,
        "overall_kappa": round(overall_kappa, 3) if overall_kappa == overall_kappa else np.nan,
        "overall_icc": round(overall_icc, 3) if overall_icc == overall_icc else np.nan,
        "drift_flag": drift_flag,
        "drift_detail": drift_detail,
    }


# ======================================================================
# 5. RBQM 리포트
# ======================================================================
def _risk_level(score):
    if score >= 6:
        return "HIGH"
    if score >= 3:
        return "MEDIUM"
    return "LOW"


def site_risk_ranking(meas_flagged, bias_df) -> pd.DataFrame:
    """사이트별 risk score 종합 (품질 실패율 + bias outlier)."""
    qsum = quality_summary_by_site(meas_flagged)
    merged = qsum.merge(
        bias_df[["site_id", "z_score", "outlier_flag"]], on="site_id", how="left"
    )

    def _score(r):
        s = 0
        if r["qc_fail_rate"] >= 0.3:
            s += 4
        elif r["qc_fail_rate"] >= 0.1:
            s += 2
        if bool(r.get("outlier_flag", False)):
            s += 4
        if abs(r.get("z_score", 0) or 0) >= 1.5:
            s += 1
        return s

    merged["risk_score"] = merged.apply(_score, axis=1)
    merged["risk_level"] = merged["risk_score"].apply(_risk_level)
    return merged.sort_values("risk_score", ascending=False)


def build_rbqm_report(data: dict) -> dict:
    """모든 분석을 실행하고 RBQM 리포트 구성요소 dict 반환."""
    meas = data["measurements"]
    biopsy = data["biopsy"]

    flagged = measurement_quality_flags(meas)
    qsum = quality_summary_by_site(flagged)
    drift = reader_drift(meas, metric="vcte_lsm_kpa")
    bias = site_scanner_bias(meas, metric="vcte_lsm_kpa", group="site_id")
    bias_scanner = site_scanner_bias(meas, metric="vcte_lsm_kpa", group="scanner_id")
    implausible = implausible_changes(meas)
    agreement = biopsy_agreement(biopsy)
    site_risk = site_risk_ranking(flagged, bias)

    return {
        "flagged": flagged,
        "quality_summary": qsum,
        "reader_drift": drift,
        "site_bias": bias,
        "scanner_bias": bias_scanner,
        "implausible": implausible,
        "biopsy_agreement": agreement,
        "site_risk": site_risk,
    }


def render_report_text(report: dict) -> str:
    """ICH E6 central monitoring 형식의 텍스트 리포트 생성."""
    L = []
    L.append("=" * 72)
    L.append("  MASLDTrialReadQC-Kor — RBQM / Central Statistical Monitoring 리포트")
    L.append("  (ICH E6(R2)/E6(R3) central monitoring 형식 · 연구용·참고용)")
    L.append("=" * 72)
    L.append("")
    L.append("[면책] 본 리포트는 합성 데이터 기반 연구용 산출물이다. 실제 임상시험")
    L.append("       모니터링 의사결정은 자격을 갖춘 담당자가 수행해야 한다.")
    L.append("")

    # 1. 측정 품질
    qs = report["quality_summary"]
    total_fail = int(qs["n_qc_fail"].sum())
    total_n = int(qs["n_measurements"].sum())
    L.append("1. 측정 품질 (VCTE IQR/median·유효측정 기준)")
    L.append(f"   전체 VCTE 측정 {total_n}건 중 {total_fail}건 품질기준 위반 "
             f"({100*total_fail/max(1,total_n):.1f}%).")
    for _, r in qs.iterrows():
        flag = "  <-- 검토필요" if r["qc_fail_rate"] >= 0.1 else ""
        L.append(f"   - {r['site_id']}: 실패율 {r['qc_fail_rate']:.1%}, "
                 f"평균 IQR/median {r['mean_iqr_median']:.2f}, "
                 f"평균 유효측정 {r['mean_valid']:.1f}{flag}")
    L.append("")

    # 2. reader drift
    dr = report["reader_drift"]
    drifted = dr[dr["drift_flag"]]
    L.append("2. Central reader 종단 drift (LSM 잔차 vs trial week)")
    if len(drifted) == 0:
        L.append("   유의한 reader drift 미탐지.")
    else:
        for _, r in drifted.iterrows():
            L.append(f"   - reader {r['reader_id']}: 기울기 {r['slope_per_week']:+.4f} "
                     f"kPa/주, {r['direction']}, p={r['p_value']:.4f}  <-- DRIFT")
    L.append("")

    # 3. 사이트/스캐너 bias
    sb = report["site_bias"]
    outliers = sb[sb["outlier_flag"]]
    L.append("3. 사이트/스캐너 systematic bias (case-mix 보정 funnel)")
    if len(outliers) == 0:
        L.append("   calibration outlier 사이트 미탐지.")
    else:
        for _, r in outliers.iterrows():
            L.append(f"   - {r['site_id']}: 평균잔차 {r['mean_residual']:+.2f} kPa, "
                     f"z={r['z_score']:+.1f}  <-- CALIBRATION OUTLIER")
    L.append("")

    # 4. implausible + biopsy
    im = report["implausible"]
    L.append("4. Implausible 종단 변화 + biopsy 일치도 drift")
    L.append(f"   생물학적으로 불가능/비단조 변화 {len(im)}건 플래그.")
    if len(im) > 0:
        for _, r in im.head(8).iterrows():
            L.append(f"   - {r['patient_id']} ({r['site_id']}) {r['metric']}: {r['reason']}")
        if len(im) > 8:
            L.append(f"   ... 외 {len(im) - 8}건")
    ag = report["biopsy_agreement"]
    L.append(f"   biopsy 전체 fibrosis 가중 κ={ag['overall_kappa']}, NAS ICC={ag['overall_icc']}")
    for _, r in ag["by_block"].iterrows():
        L.append(f"   - {r['enroll_block']} 구간: fibrosis κ={r['fibrosis_weighted_kappa']}, "
                 f"NAS ICC={r['nas_icc']}")
    if ag["drift_flag"]:
        L.append(f"   <-- 일치도 DRIFT: {ag['drift_detail']}")
    L.append("")

    # 5. risk 순위 + 권고
    sr = report["site_risk"]
    L.append("5. 사이트 risk 순위 및 권고 조치")
    for _, r in sr.iterrows():
        L.append(f"   - {r['site_id']}: risk={r['risk_level']} (점수 {r['risk_score']}), "
                 f"품질실패율 {r['qc_fail_rate']:.1%}, bias z={r.get('z_score', float('nan')):+.1f}")
    L.append("")
    L.append("   [권고]")
    for rec in build_recommendations(report):
        L.append(f"   - {rec}")
    L.append("")
    L.append("=" * 72)
    L.append("  ICH E6(R2)/E6(R3) · FDA 2013 RBM guidance · AASLD MASLD 2023 ·")
    L.append("  EASL-EASD-EASO MASLD 2024 — 기준값은 예시적이며 검증 필요.")
    L.append("=" * 72)
    return "\n".join(L)


def build_recommendations(report: dict) -> list:
    """분석 결과 기반 reader 재교정·사이트 조치 권고 목록 생성."""
    recs = []
    drifted = report["reader_drift"][report["reader_drift"]["drift_flag"]]
    for _, r in drifted.iterrows():
        recs.append(
            f"reader {r['reader_id']}: 종단 {r['direction']} drift 확인 — "
            f"재교정 세션 및 과거 판독 재검토(re-read) 권고."
        )
    outliers = report["site_bias"][report["site_bias"]["outlier_flag"]]
    for _, r in outliers.iterrows():
        recs.append(
            f"{r['site_id']}: VCTE 스캐너 calibration outlier (z={r['z_score']:+.1f}) — "
            f"스캐너 phantom QC 및 측정 SOP 점검 권고."
        )
    hi_q = report["quality_summary"]
    for _, r in hi_q[hi_q["qc_fail_rate"] >= 0.3].iterrows():
        recs.append(
            f"{r['site_id']}: VCTE 품질 실패율 {r['qc_fail_rate']:.0%} — "
            f"on-site 모니터링 방문 및 VCTE 재교육 권고."
        )
    if report["biopsy_agreement"]["drift_flag"]:
        recs.append(
            "central pathology: 후반 구간 reader 간 일치도 저하 — adjudication "
            "consensus 세션 및 fibrosis staging 재교육 권고."
        )
    n_impl = len(report["implausible"])
    if n_impl > 0:
        recs.append(
            f"implausible 변화 {n_impl}건: 해당 환자 source data verification(SDV) "
            f"및 데이터 입력 오류 query 발행 권고."
        )
    if not recs:
        recs.append("현재 임계값 기준 즉각적 조치 필요 항목 없음 — 정기 모니터링 유지.")
    return recs


# ======================================================================
# CLI 검수용 진입점
# ======================================================================
if __name__ == "__main__":
    here = os.path.dirname(os.path.abspath(__file__))
    data = load_data(os.path.join(here, "data"))
    report = build_rbqm_report(data)
    print(render_report_text(report))
