#!/usr/bin/env python3
"""Count how many approved small-molecule drugs act covalently on their target.

Data for the ProteinIQ guide /guides/approved-covalent-drugs.

Population
  The 2,104 approved small-molecule drugs of the ProteinIQ Lipinski analysis
  (public/data/guides/lipinski-rule-of-five/approved-drugs-ro5.csv): ChEMBL 37
  parents with max_phase 4 that are small molecules (or defined peptides and
  oligosaccharides of at most 900 Da), without metals. Structures and
  mechanisms of action are read from the ChEMBL REST API.

Steps
  1. Warhead search. RDKit substructure patterns (WARHEADS below) flag drugs
     that carry an electrophilic group or another group that reacts with
     proteins or DNA.
  2. Mechanism search. ChEMBL drug_mechanism records whose text or action type
     mentions covalent, irreversible, alkylating, cross-linking and similar
     terms flag more drugs.
  3. Candidate list. Every flagged drug, plus a short list of drugs known to act
     covalently through a group the patterns cannot see (for example the
     activated sulfoxide of proton pump inhibitors), is a candidate.
  4. Hand review. scripts/covalent-drugs-review.tsv records, for every
     candidate, whether it acts covalently on its therapeutic target, how, and
     which definition it meets. The script refuses to run when a candidate
     has no decision. Notes on how each decision was reached are in
     scripts/covalent-drugs-review-notes.md.
  5. Counts. Strict and broad definitions, by warhead, target class and
     approval decade.

Definitions (applied by hand in the review file)
  Broad: the drug, or an active metabolite formed in the body, forms a covalent
    bond with its therapeutic target (a protein, DNA or RNA) as part of the
    accepted mechanism of action, reversible or not. Drugs whose covalent
    chemistry only produces a cofactor adduct, targets a non-therapeutic
    protein by accident, or is a side reaction are not counted.
  Strict: the subset of broad drugs whose covalent bond to a defined residue of
    a protein target is the designed basis of activity: a recognition scaffold
    plus a deliberately installed or optimised reactive group, confirmed by
    structure or mass spectrometry. Drugs found by phenotypic screening,
    serendipity or from natural products before the mechanism was understood
    stay in broad only, even when the mechanism is now well known.

Outputs (public/data/guides/approved-covalent-drugs/)
  covalent-drug-candidates.csv  every candidate with its decision
  covalent-drugs.csv            the broad set (one row per drug) with strict flag
  summary.json                  every aggregate quoted in the guide
  analyze-covalent-drugs.py     a copy of this script
  covalent-drugs-review.tsv     a copy of the hand-review decisions
  covalent-drugs-outside-chembl.tsv  hand-checked covalent drugs the ChEMBL 37 set lacks

Run with a Python that has RDKit (tested with RDKit 2025.03.5):
  python scripts/covalent-drugs-chembl.py [--cache-dir DIR]

ChEMBL responses are cached in --cache-dir. Delete the cache to pick up a new
ChEMBL release.
"""

from __future__ import annotations

import argparse
import collections
import csv
import json
import re
import shutil
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

import rdkit
from rdkit import Chem, RDLogger

RDLogger.DisableLog("rdApp.*")

REPO_ROOT = Path(__file__).resolve().parents[1]
POPULATION_CSV = REPO_ROOT / "public/data/guides/lipinski-rule-of-five/approved-drugs-ro5.csv"
REVIEW_TSV = REPO_ROOT / "scripts/covalent-drugs-review.tsv"
OUTSIDE_TSV = REPO_ROOT / "scripts/covalent-drugs-outside-chembl.tsv"
OUT_DIR = REPO_ROOT / "public/data/guides/approved-covalent-drugs"

CHEMBL_API = "https://www.ebi.ac.uk/chembl/api/data"
USER_AGENT = "ProteinIQ covalent drug analysis (https://proteiniq.io/guides/approved-covalent-drugs)"

# Substructure patterns. A hit marks a candidate; it does not mean the drug is covalent.
WARHEADS: dict[str, str] = {
    "acrylamide": "[C;!R;!$(C-[N,O,S,Cl])]=[C;!R]-C(=O)[NX3]",
    "propiolamide / butynamide": "C#C-C(=O)[NX3]",
    "cyanoacrylamide": "C=C(C#N)C(=O)N",
    "terminal enone or acrylate": "[CH2]=[CX3]-C(=O)[#6,O]",
    "vinyl sulfone or sulfonamide": "C=C-S(=O)(=O)",
    "fumarate ester": "O=C(O)C=CC(=O)O",
    "alpha-halo ketone or amide": "[Cl,Br,I][CH2]C(=O)[#6,#7]",
    "nitrile": "[CX4,c,$(C=C)]C#N",
    "boronic acid or oxaborole": "[BX3]([#8,#7,#6])[#8]",
    "aldehyde": "[CX3H1](=O)[#6]",
    "epoxide": "C1OC1",
    "aziridine": "C1NC1",
    "beta-lactam": "O=C1CCN1",
    "beta-lactone": "O=C1CCO1",
    "diazabicyclooctane (N-sulfooxy urea)": "[#6]N(OS(=O)(=O)[OX2H1,OX1-])C(=O)N",
    "sulfonyl halide": "S(=O)(=O)[F,Cl]",
    "disulfide": "[SX2][SX2]",
    "thiol": "[SX2H1]",
    "2-haloethylamine": "[NX3][CH2][CH2][Cl,Br,I]",
    "alkyl sulfonate": "[CX4]OS(=O)(=O)[CX4,c]",
    "nitrosourea": "N(N=O)C(=O)N",
    "triazene": "N=N-N",
    "hydrazine": "[NX3][NX3H2]",
    "propargylamine": "C#C[CH2][NX3]",
    "cyclopropylamine": "[NX3]C1CC1",
    "aryl carbamate": "c[OX2]C(=O)[NX3]",
    "aryl ester (acyl donor)": "c[OX2]C(=O)[CX4,c]",
    "phosphate or phosphonate ester with leaving group": "P(=[O,S])([OX2,F])[SX2,OX2,F]",
    "sulfinyl benzimidazole": "[#6]S(=O)c1nc2ccccc2[nH,n]1",
    "thienopyridine": "c1csc2c1CN(CC2)",
    "ketoamide or trifluoromethyl ketone": "[$(C(=O)C(=O)N),$(C(=O)C(F)(F)F)]",
    "quinone": "O=C1C=CC(=O)C=C1",
    "isothiocyanate": "N=C=S",
    "nitroso": "[#6]N=O",
}

# Drugs the patterns cannot see but that are known to act through covalent chemistry,
# added so the hand review decides on them explicitly. Matched by ChEMBL parent name.
NAMED_CANDIDATES = {
    "Omeprazole", "Esomeprazole", "Lansoprazole", "Dexlansoprazole", "Pantoprazole", "Rabeprazole",
    "Aspirin", "Disulfiram", "Fluorouracil", "Capecitabine", "Floxuridine", "Tegafur", "Trifluridine",
    "Azacitidine", "Decitabine", "Vigabatrin", "Eflornithine", "Exemestane", "Orlistat",
    "Selegiline", "Rasagiline", "Tranylcypromine", "Phenelzine", "Isocarboxazid", "Cycloserine",
    "Finasteride", "Dutasteride", "Temozolomide", "Procarbazine", "Dacarbazine", "Cangrelor",
    "Ticagrelor", "Nirmatrelvir", "Ensitrelvir", "Saxagliptin", "Saxagliptin anhydrous", "Vildagliptin", "Sitagliptin",
    "Linagliptin", "Alogliptin", "Avibactam", "Relebactam", "Relebactam anhydrous", "Durlobactam", "Vaborbactam",
    "Sulbactam", "Tazobactam", "Clavulanic acid", "Enmetazobactam", "Ibrutinib", "Acalabrutinib",
    "Zanubrutinib", "Tirabrutinib", "Pirtobrutinib", "Selinexor", "Vonoprazan", "Allopurinol",
    "Isoniazid", "Ethionamide", "Carbidopa", "Foscarbidopa", "Warfarin", "Fomepizole", "Nitisinone",
    "Dimethyl fumarate", "Omaveloxolone", "Tavaborole", "Ethacrynic acid", "Carfilzomib", "Fosfomycin",
    "Telaprevir", "Boceprevir", "Voxelotor", "Anagliptin", "Teneligliptin", "Trelagliptin", "Gemigliptin",
    "Evogliptin", "Neostigmine", "Physostigmine", "Pyridostigmine", "Rivastigmine", "Distigmine",
    "Demecarium", "Isoflurophate", "Malathion", "Methoxsalen", "Trioxsalen", "Altretamine",
    "Trofosfamide", "Nimustine", "Fotemustine", "Ranimustine", "Mitobronitol", "Mitolactol", "Carboquone",
    "Mebanazine", "Iproniazid", "Nialamide", "Pheniprazine", "Cobicistat", "Ritonavir", "Nafamostat",
    "Camostat", "Sivelestat", "Gabexate", "Bardoxolone methyl", "Ilaprazole", "Dibromomannitol",
    "Benorilate", "Triflusal", "Nitrofurantoin", "Metronidazole", "Hydroxyurea", "Cyanamide",
}

# Warhead classes grouped into the families shown in the guide's chart.
WARHEAD_FAMILY = {
    "Beta-lactam": "Beta-lactams",
    "Acrylamide": "Michael acceptors",
    "Butynamide": "Michael acceptors",
    "Fumarate Michael acceptor": "Michael acceptors",
    "Cyanoenone": "Michael acceptors",
    "Nitrogen mustard": "DNA alkylators",
    "Aziridine": "DNA alkylators",
    "Nitrosourea": "DNA alkylators",
    "Alkyl sulfonate": "DNA alkylators",
    "Bromoalkyl amide": "DNA alkylators",
    "Methylating triazene or hydrazine": "DNA alkylators",
    "Carbinolamine alkylator": "DNA alkylators",
    "Psoralen (photoactivated)": "DNA alkylators",
    "Sulfinyl benzimidazole (prodrug)": "Disulfide-forming drugs",
    "Thienopyridine (prodrug)": "Disulfide-forming drugs",
    "Disulfide": "Disulfide-forming drugs",
    "Aryl carbamate": "Cholinesterase inhibitors",
    "Organophosphate": "Cholinesterase inhibitors",
    "Propargylamine": "MAO inhibitors",
    "Hydrazine": "MAO inhibitors",
    "Fluoropyrimidine": "Nucleoside analogs",
    "Azacytosine nucleoside": "Nucleoside analogs",
    "Nitrile": "Nitriles and ketoamides",
    "Ketoamide": "Nitriles and ketoamides",
    "Boronic acid": "Boronic acids and DBOs",
    "Diazabicyclooctane": "Boronic acids and DBOs",
}

MECHANISM_TERMS = re.compile(
    r"covalen|irreversib|alkylat|acylat|cross-?link|suicide|mechanism-based|carbamoylat|thiol|cystein",
    re.I,
)
NON_SMALL_MOLECULE_ACTIONS = {"CROSS-LINKING AGENT"}


def http_json(url: str, retries: int = 5) -> dict:
    for attempt in range(retries):
        try:
            request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
            with urllib.request.urlopen(request, timeout=120) as response:
                return json.loads(response.read())
        except urllib.error.HTTPError as error:
            if error.code == 404 or attempt == retries - 1:
                raise
        except Exception:
            if attempt == retries - 1:
                raise
        time.sleep(2 * (attempt + 1))
    raise RuntimeError("unreachable")


def paginate(resource: str, params: dict[str, str]) -> list[dict]:
    query = urllib.parse.urlencode({**params, "limit": "1000", "format": "json"})
    url: str | None = f"{CHEMBL_API}/{resource}.json?{query}"
    items: list[dict] = []
    key = None
    while url:
        payload = http_json(url)
        key = key or next(k for k in payload if k != "page_meta")
        items.extend(payload[key])
        next_url = payload["page_meta"].get("next")
        url = f"https://www.ebi.ac.uk{next_url}" if next_url else None
    return items


def cached(cache: Path, key: str, fetch):
    path = cache / f"{key}.json"
    if path.exists():
        return json.loads(path.read_text(encoding="utf-8"))
    value = fetch()
    path.write_text(json.dumps(value), encoding="utf-8")
    return value


def load_population() -> list[dict]:
    with open(POPULATION_CSV, encoding="utf-8", newline="") as handle:
        return list(csv.DictReader(handle))


def load_structures(cache: Path, ids: list[str]) -> dict[str, str]:
    def fetch() -> dict[str, str]:
        found: dict[str, str] = {}
        for start in range(0, len(ids), 40):
            batch = ",".join(ids[start : start + 40])
            for molecule in paginate(
                "molecule", {"molecule_chembl_id__in": batch, "only": "molecule_chembl_id,molecule_structures"}
            ):
                structures = molecule.get("molecule_structures") or {}
                found[molecule["molecule_chembl_id"]] = structures.get("canonical_smiles") or ""
        return found

    return cached(cache, "smiles", fetch)


def load_mechanisms(cache: Path) -> dict[str, list[dict]]:
    mechanisms = cached(cache, "mechanisms", lambda: paginate("mechanism", {}))
    by_parent: dict[str, list[dict]] = collections.defaultdict(list)
    for mechanism in mechanisms:
        by_parent[mechanism["parent_molecule_chembl_id"]].append(mechanism)
    return by_parent


def load_targets(cache: Path, mechanisms: dict[str, list[dict]], ids: set[str]) -> dict[str, dict]:
    target_ids = sorted({m["target_chembl_id"] for i in ids for m in mechanisms.get(i, []) if m["target_chembl_id"]})

    def fetch() -> dict[str, dict]:
        found: dict[str, dict] = {}
        for start in range(0, len(target_ids), 40):
            batch = ",".join(target_ids[start : start + 40])
            for target in paginate(
                "target", {"target_chembl_id__in": batch, "only": "target_chembl_id,pref_name,target_type,organism"}
            ):
                found[target["target_chembl_id"]] = target
        return found

    return cached(cache, "targets", fetch)


def decade_label(year: int) -> str:
    return "Before 1950" if year < 1950 else f"{year // 10 * 10}s"


def find_candidates(population, smiles, mechanisms, targets):
    patterns = {name: Chem.MolFromSmarts(smarts) for name, smarts in WARHEADS.items()}
    broken = [name for name, pattern in patterns.items() if pattern is None]
    if broken:
        raise SystemExit(f"Invalid SMARTS: {broken}")
    candidates = []
    for row in population:
        chembl_id = row["chembl_id"]
        mol = Chem.MolFromSmiles(smiles.get(chembl_id, ""))
        hits = [name for name, pattern in patterns.items() if mol is not None and mol.HasSubstructMatch(pattern)]
        mech = mechanisms.get(chembl_id, [])
        mechanism_text = " | ".join(
            dict.fromkeys(
                (m.get("mechanism_of_action") or "") + (f" [{m['mechanism_comment']}]" if m.get("mechanism_comment") else "")
                for m in mech
            )
        )
        mechanism_hit = any(
            MECHANISM_TERMS.search(
                " ".join(str(m.get(k) or "") for k in ("mechanism_of_action", "mechanism_comment", "binding_site_comment", "selectivity_comment"))
            )
            or m.get("action_type") in NON_SMALL_MOLECULE_ACTIONS
            for m in mech
        )
        named = row["name"].lower() in {n.lower() for n in NAMED_CANDIDATES}
        # Nitriles, esters, aldehydes and similar groups are common in non-covalent drugs. A
        # drug is a candidate on those alone only when ChEMBL's mechanism text or the named
        # list also points at covalent chemistry. weak_only_hits (below) lists the rest.
        weak_only = set(hits) <= {
            "nitrile", "aryl ester (acyl donor)", "triazene", "hydrazine", "cyclopropylamine",
            "phosphate or phosphonate ester with leaving group", "nitroso", "thiol", "aldehyde",
            "epoxide", "quinone", "fumarate ester", "alpha-halo ketone or amide",
            "ketoamide or trifluoromethyl ketone", "terminal enone or acrylate", "aryl carbamate",
        }
        if (hits and not weak_only) or mechanism_hit or named:
            targets_text = "; ".join(
                dict.fromkeys(
                    (targets.get(m["target_chembl_id"]) or {}).get("pref_name", "")
                    for m in mech
                    if m.get("target_chembl_id") and m.get("disease_efficacy") == 1
                )
            )
            candidates.append(
                {
                    "chembl_id": chembl_id,
                    "name": row["name"],
                    "first_approval": row["first_approval"],
                    "warhead_hits": ";".join(hits),
                    "mechanism_flag": mechanism_hit,
                    "named": named,
                    "chembl_mechanism": mechanism_text,
                    "chembl_targets": targets_text,
                }
            )
    return candidates


def load_review() -> dict[str, dict]:
    with open(REVIEW_TSV, encoding="utf-8", newline="") as handle:
        rows = [r for r in csv.DictReader((line for line in handle if not line.startswith("#")), delimiter="\t")]
    return {r["chembl_id"]: r for r in rows}


def pct(part: int, whole: int) -> float:
    return round(100 * part / whole, 1) if whole else 0.0


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--cache-dir", default=str(Path(tempfile.gettempdir()) / "proteiniq-covalent-drugs"))
    parser.add_argument("--out-dir", default=str(OUT_DIR))
    parser.add_argument("--candidates-only", action="store_true", help="write the candidate list and stop (before the review exists)")
    args = parser.parse_args()
    cache = Path(args.cache_dir)
    cache.mkdir(parents=True, exist_ok=True)
    out = Path(args.out_dir)
    out.mkdir(parents=True, exist_ok=True)

    status = cached(cache, "status", lambda: http_json(f"{CHEMBL_API}/status.json"))
    population = load_population()
    ids = [row["chembl_id"] for row in population]
    smiles = load_structures(cache, ids)
    mechanisms = load_mechanisms(cache)
    targets = load_targets(cache, mechanisms, set(ids))
    candidates = find_candidates(population, smiles, mechanisms, targets)
    candidates.sort(key=lambda c: c["name"].lower())

    fields = list(candidates[0])
    with open(cache / "candidates.tsv", "w", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=fields, delimiter="\t", lineterminator="\n")
        writer.writeheader()
        writer.writerows(candidates)
    print(f"{len(population)} drugs, {len(candidates)} candidates -> {cache / 'candidates.tsv'}")
    if args.candidates_only:
        return

    review = load_review()
    missing = [c["name"] for c in candidates if c["chembl_id"] not in review]
    if missing:
        sys.exit(f"{len(missing)} candidates have no review decision: {missing[:20]}")
    stale = [i for i in review if i not in {c["chembl_id"] for c in candidates}]
    if stale:
        sys.exit(f"review rows that are not candidates: {stale}")
    write_outputs(args, out, status, population, candidates, review)


def write_outputs(args, out: Path, status: dict, population, candidates, review) -> None:
    by_id = {r["chembl_id"]: r for r in population}
    rows = []
    for c in candidates:
        r = review[c["chembl_id"]]
        rows.append(
            {
                **c,
                "decision": r["decision"],
                "definition": r["definition"],
                "warhead_class": r["warhead_class"],
                "bond": r["bond"],
                "reversibility": r["reversibility"],
                "target_class": r["target_class"],
                "target": r["target"],
                "evidence": r["evidence"],
                "basis": r["basis"],
            }
        )
    broad = [r for r in rows if r["definition"] in ("strict", "broad")]
    strict = [r for r in rows if r["definition"] == "strict"]
    n = len(population)

    def tally(items, key):
        return dict(collections.Counter(r[key] for r in items).most_common())

    def decade(items):
        counts = collections.Counter()
        for r in items:
            year = r["first_approval"]
            counts[decade_label(int(year)) if year else "No ChEMBL first-approval year"] += 1
        order = sorted(counts, key=lambda d: (d == "No ChEMBL first-approval year", d != "Before 1950", d))
        return {d: counts[d] for d in order}

    all_by_decade = collections.Counter(decade_label(int(r["first_approval"])) for r in population if r["first_approval"])
    summary = {
        "chemblVersion": status["chembl_db_version"],
        "chemblReleaseDate": status["chembl_release_date"],
        "rdkitVersion": rdkit.__version__,
        "population": n,
        "populationWithFirstApproval": sum(1 for r in population if r["first_approval"]),
        "candidates": len(candidates),
        "decisions": tally(rows, "decision"),
        "broad": len(broad),
        "broadPct": pct(len(broad), n),
        "strict": len(strict),
        "strictPct": pct(len(strict), n),
        "broadByTargetKind": tally(broad, "bond"),
        "broadByWarheadClass": tally(broad, "warhead_class"),
        "strictByWarheadClass": tally(strict, "warhead_class"),
        "broadByTargetClass": tally(broad, "target_class"),
        "strictByTargetClass": tally(strict, "target_class"),
        "broadByReversibility": tally(broad, "reversibility"),
        "strictByReversibility": tally(strict, "reversibility"),
        "broadByDecade": decade(broad),
        "strictByDecade": decade(strict),
        "allApprovedByDecade": {d: all_by_decade[d] for d in sorted(all_by_decade, key=lambda d: (d != "Before 1950", d))},
        "broadByBondTarget": tally(
            [{**r, "bond_group": "Protein" if r["bond"].startswith(("Protein", "Prosthetic")) else r["bond"].split(" ")[0]} for r in broad],
            "bond_group",
        ),
        "broadByWarheadFamily": tally(
            [{**r, "family": WARHEAD_FAMILY.get(r["warhead_class"], "Other")} for r in broad], "family"
        ),
        "otherWarheadClasses": sorted({r["warhead_class"] for r in broad if r["warhead_class"] not in WARHEAD_FAMILY}),
        "kinaseCumulativeByYear": [
            {
                "year": y,
                "allKinaseInhibitors": sum(1 for r in population if r["kinase_inhibitor"] == "true" and r["first_approval"] and int(r["first_approval"]) <= y),
                "covalent": sum(1 for r in strict if r["target_class"] == "Protein kinases" and by_id[r["chembl_id"]]["kinase_inhibitor"] == "true" and int(r["first_approval"]) <= y),
            }
            for y in range(2001, 2025)
        ],
        "broadByResidue": tally([r for r in broad if r["bond"].startswith("Protein")], "bond"),
        "strictByResidue": tally(strict, "bond"),
        "broadByEvidence": tally(broad, "evidence"),
        "broadWithoutFirstApprovalYear": sorted(r["name"] for r in broad if not r["first_approval"]),
        "strictList": sorted((int(r["first_approval"]) if r["first_approval"] else 0, r["name"]) for r in strict),
        "broadWithoutBetaLactams": sum(1 for r in broad if r["warhead_class"] != "Beta-lactam"),
        "betaLactamsAll": sum(1 for r in broad if r["warhead_class"] == "Beta-lactam"),
        "strictWithoutPenicillanicSulfones": sum(1 for r in strict if r["name"].lower() not in ("sulbactam", "tazobactam", "enmetazobactam")),
        "kinaseInhibitorsAll": sum(1 for r in population if r["kinase_inhibitor"] == "true"),
        "kinaseInhibitorsCovalent": sum(1 for r in strict if r["target_class"] == "Protein kinases"),
        "kinaseCovalentByYear": sorted((int(r["first_approval"]), r["name"]) for r in strict if r["target_class"] == "Protein kinases"),
        "notCounted": sorted((r["name"], r["basis"]) for r in rows if r["definition"] == "none"),
        "kinaseL01ECovalent": sum(1 for r in strict if r["target_class"] == "Protein kinases" and by_id[r["chembl_id"]]["kinase_inhibitor"] == "true"),
        "kinaseL01EApprovedSince2013": sum(1 for r in population if r["kinase_inhibitor"] == "true" and r["first_approval"] and int(r["first_approval"]) >= 2013),
        "kinaseL01ECovalentSince2013": sum(1 for r in strict if r["target_class"] == "Protein kinases" and by_id[r["chembl_id"]]["kinase_inhibitor"] == "true" and int(r["first_approval"]) >= 2013),
        "decadeTable": [
            {
                "decade": d,
                "allApproved": all_by_decade[d],
                "broad": decade(broad).get(d, 0),
                "strict": decade(strict).get(d, 0),
                "broadWithoutBetaLactams": decade([r for r in broad if r["warhead_class"] != "Beta-lactam"]).get(d, 0),
                "broadPct": pct(decade(broad).get(d, 0), all_by_decade[d]),
                "strictPct": pct(decade(strict).get(d, 0), all_by_decade[d]),
                "broadWithoutBetaLactamsPct": pct(decade([r for r in broad if r["warhead_class"] != "Beta-lactam"]).get(d, 0), all_by_decade[d]),
            }
            for d in sorted(all_by_decade, key=lambda d: (d != "Before 1950", d))
        ],
        "since2010": {
            "allApproved": sum(1 for r in population if r["first_approval"] and int(r["first_approval"]) >= 2010),
            "strict": sum(1 for r in strict if r["first_approval"] and int(r["first_approval"]) >= 2010),
            "broad": sum(1 for r in broad if r["first_approval"] and int(r["first_approval"]) >= 2010),
        },
        "ruleOfFive": {
            "allApproved": {"n": n, "alertPct": pct(sum(1 for r in population if r["ro5_alert"] == "true"), n)},
            "broad": {"n": len(broad), "alert": sum(1 for r in broad if by_id[r["chembl_id"]]["ro5_alert"] == "true"), "alertPct": pct(sum(1 for r in broad if by_id[r["chembl_id"]]["ro5_alert"] == "true"), len(broad))},
            "broadWithoutBetaLactams": {"n": sum(1 for r in broad if r["warhead_class"] != "Beta-lactam"), "alert": sum(1 for r in broad if r["warhead_class"] != "Beta-lactam" and by_id[r["chembl_id"]]["ro5_alert"] == "true")},
            "strict": {"n": len(strict), "alert": sum(1 for r in strict if by_id[r["chembl_id"]]["ro5_alert"] == "true"), "alertPct": pct(sum(1 for r in strict if by_id[r["chembl_id"]]["ro5_alert"] == "true"), len(strict))},
            "strictMedianMw": sorted(float(by_id[r["chembl_id"]]["mw"]) for r in strict)[len(strict) // 2],
        },
        "window2012To2021": {
            "allApproved": sum(1 for r in population if r["first_approval"] and 2012 <= int(r["first_approval"]) <= 2021),
            "strict": sum(1 for r in strict if r["first_approval"] and 2012 <= int(r["first_approval"]) <= 2021),
            "broad": sum(1 for r in broad if r["first_approval"] and 2012 <= int(r["first_approval"]) <= 2021),
        },
        "warheadPatternHits": {
            name: sum(1 for c in candidates if name in c["warhead_hits"].split(";")) for name in WARHEADS
        },
    }
    (out / "summary.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
    columns = list(rows[0])
    with open(out / "covalent-drug-candidates.csv", "w", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=columns, lineterminator="\n")
        writer.writeheader()
        writer.writerows(rows)
    with open(out / "covalent-drugs.csv", "w", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=columns, lineterminator="\n")
        writer.writeheader()
        writer.writerows(broad)
    shutil.copyfile(Path(__file__), out / "analyze-covalent-drugs.py")
    shutil.copyfile(REVIEW_TSV, out / "covalent-drugs-review.tsv")
    if OUTSIDE_TSV.exists():
        shutil.copyfile(OUTSIDE_TSV, out / "covalent-drugs-outside-chembl.tsv")
    print(json.dumps({k: summary[k] for k in ("population", "candidates", "decisions", "broad", "strict")}, indent=2))


if __name__ == "__main__":
    main()
