"""Count the molecular targets of approved drugs in ChEMBL. Python 3.9+, standard library.

Supports the guide content/guides/drug-targets.mdx ("How many drug targets are there?").

Input: the public ChEMBL REST API (https://www.ebi.ac.uk/chembl/api/data). The first run
on 2026-10-29 reported ChEMBL_37, released 2026-05-01. The script records the release it
sees in provenance.json; rerun it after each ChEMBL release to refresh every number.
US prescription weights come from the ClinCalc DrugStats Top 300 list (MEPS 2024 estimates),
which is fetched at run time and not republished: the product-to-ChEMBL mapping goes to the
cache directory, and only the aggregate shares reach provenance.json.

Run:  python scripts/analyze-chembl-drug-targets.py [--refresh]
Raw API pages are cached in .tmp/chembl-drug-targets/ (use --refresh to download again).
A full download takes about 15 minutes. Each run copies this script next to its outputs, so
the published copy can be run from any directory and writes there (cache in ./chembl-cache).
Writes into public/data/guides/drug-targets/:
  approved-drug-mechanisms.csv.gz  one row per approved drug and curated efficacy target (gzip)
  approved-drug-targets.csv      one row per distinct target, with class and drug counts
  target-class-summary.csv       targets and drugs per target class, by modality
  provenance.json                release, filters, definitions and headline counts
  README.md                      licence and credit
The outputs derive from ChEMBL (CC BY-SA 3.0) and are shared under the same licence.
Outputs are generated by this script, never edited by hand.

Definitions
- Approved drug: a ChEMBL parent molecule (salts and hydrates fold into their parent via
  molecule_hierarchy) with max_phase 4 and therapeutic_flag true. ChEMBL gives max_phase 4 to
  drugs approved in at least one market covered by its drug sources (FDA, EMA, PMDA and
  others). therapeutic_flag false removes imaging agents, contrast media and similar
  non-therapeutic products. Drugs flagged withdrawn stay in and are counted in provenance.json.
- Efficacy target: a target in ChEMBL's curated drug_mechanism table for that parent with
  disease_efficacy = 1, meaning the interaction is thought to produce the therapeutic effect.
  Mechanisms with no target (target_chembl_id null) give the drug no target.
- Target: a ChEMBL target ID. ChEMBL curates a mechanism against the most specific target
  it can justify, so one target can be a single protein, a protein complex (for example a
  GABA-A receptor), a protein family (several receptor subtypes hit together), a selectivity
  group, a protein-protein interaction, a nucleic acid, or a whole organism or cell.
- Organism group: Human (Homo sapiens), Pathogen or parasite (any other organism, which in
  this set is bacteria, viruses, fungi, protozoa, worms and ectoparasites), or No organism.
- Target class: from ChEMBL's protein classification of the target's components, mapped by
  bucket() below. A target gets a class when all specifically classified components agree on
  it; otherwise "Mixed classes". Kinases are ChEMBL's Enzyme > Kinase branch, which holds
  protein kinases only.
- Modality: ChEMBL molecule_type. Small molecule; biologic = Antibody, Antibody drug
  conjugate, Protein, Enzyme, Cell, Gene or Vaccine component; Other = Oligonucleotide,
  Oligosaccharide and Unknown (mostly mixtures and extracts).
"""

import argparse
import csv
import gzip
import html
import io
import json
import re
import shutil
import time
import urllib.parse
import urllib.request
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path

API = "https://www.ebi.ac.uk/chembl/api/data"
CLINCALC = "https://clincalc.com/DrugStats/Top300Drugs.aspx"
SCRIPT = Path(__file__).resolve()
# Inside the ProteinIQ repository the script lives in scripts/; the published copy next to the
# data writes into the current directory instead.
if SCRIPT.parent.name == "scripts":
    CACHE = SCRIPT.parent.parent / ".tmp" / "chembl-drug-targets"
    OUT = SCRIPT.parent.parent / "public" / "data" / "guides" / "drug-targets"
else:
    CACHE = Path.cwd() / "chembl-cache"
    OUT = Path.cwd()
PAGE = 1000
BATCH = 50

MOLECULE_FIELDS = [
    "molecule_chembl_id",
    "pref_name",
    "molecule_type",
    "max_phase",
    "first_approval",
    "withdrawn_flag",
    "therapeutic_flag",
    "molecule_hierarchy",
    "oral",
    "parenteral",
    "topical",
]

CLASS_ORDER = [
    "GPCRs",
    "Ion channels",
    "Protein kinases",
    "Nuclear receptors",
    "Proteases",
    "Other enzymes",
    "Transporters",
    "Epigenetic regulators",
    "Other membrane receptors",
    "Other transcription factors",
    "Secreted proteins",
    "Surface antigens",
    "Other or unclassified proteins",
    "Mixed classes",
    "Non-protein targets",
]
PROTEIN_TARGET_KINDS = {
    "SINGLE PROTEIN": "Single protein",
    "CHIMERIC PROTEIN": "Single protein",
    "PROTEIN COMPLEX": "Protein complex",
    "PROTEIN COMPLEX GROUP": "Protein complex",
    "PROTEIN NUCLEIC-ACID COMPLEX": "Protein complex",
    "PROTEIN FAMILY": "Protein family or group",
    "SELECTIVITY GROUP": "Protein family or group",
    "PROTEIN-PROTEIN INTERACTION": "Protein-protein interaction",
}
MODALITIES = ["Small molecule", "Biologic", "Other"]
README = """# Targets of approved drugs in ChEMBL

Data behind https://proteiniq.io/guides/drug-targets, generated by analyze-chembl-drug-targets.py
from the ChEMBL REST API. provenance.json records the ChEMBL release, filters, definitions and
every headline count; the script docstring defines each column.

US prescription shares use the ClinCalc DrugStats Top 300 list, which the script downloads at run
time. That list is not redistributed here.

## License

ChEMBL is licensed under [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/), so these
files are shared under the same license. Please credit ChEMBL (Zdrazil et al., Nucleic Acids
Research, 2024) and ProteinIQ, with a link to https://proteiniq.io/guides/drug-targets.
"""

BIOLOGIC_TYPES = {
    "Antibody",
    "Antibody drug conjugate",
    "Protein",
    "Enzyme",
    "Cell",
    "Gene",
    "Vaccine component",
}

# ClinCalc names that differ from every ChEMBL name or synonym, or that name a salt
# family. None means the product has no single ChEMBL drug (extracts, mixed salts).
PRESCRIPTION_NAME_OVERRIDES = {
    "Thyroid": None,
    "Magnesium Salts": None,
    "Sodium Salts": None,
    "Calcium": None,
    "Senna": None,
    "Omega-3-acid Ethyl Esters": None,
    "Isosorbide": "ISOSORBIDE MONONITRATE",
    "Valproate": "VALPROIC ACID",
    "Lithium": "LITHIUM CARBONATE",
    "Insulin Isophane": "INSULIN HUMAN",
    "Insulin Isophane Human": "INSULIN HUMAN",
    "Pancrelipase Lipase": "PANCRELIPASE",
    "Pancrelipase Protease": "PANCRELIPASE",
    "Pancrelipase Amylase": "PANCRELIPASE",
    "Polyethylene Glycol 3350": "POLYETHYLENE GLYCOL 3350",
    "Polyethylene Glycol 3350 With Electrolytes": "POLYETHYLENE GLYCOL 3350",
    "Calcium Phosphate": None,
    "Tenofovir": "TENOFOVIR DISOPROXIL",
    "Fluticasone": "FLUTICASONE PROPIONATE",
    "Mometasone": "MOMETASONE FUROATE",
    "Olmesartan": "OLMESARTAN MEDOXOMIL",
    "Lactate": "LACTIC ACID",
}


def get_json(url, retries=4):
    for attempt in range(retries):
        try:
            request = urllib.request.Request(
                url,
                headers={"Accept": "application/json", "User-Agent": "ProteinIQ drug-target guide"},
            )
            with urllib.request.urlopen(request, timeout=120) as response:
                return json.load(response)
        except Exception:
            if attempt == retries - 1:
                raise
            time.sleep(2 * (attempt + 1))


def cached(name, fetch, refresh):
    path = CACHE / name
    if path.exists() and not refresh:
        return json.loads(path.read_text(encoding="utf8"))
    data = fetch()
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data), encoding="utf8")
    return data


def fetch_all_from(url, key):
    rows = []
    while url:
        payload = get_json(url)
        rows.extend(payload[key])
        next_page = payload["page_meta"]["next"]
        url = f"https://www.ebi.ac.uk{next_page}" if next_page else None
    return rows


def fetch_all(resource, key, params):
    query = dict(params, limit=PAGE, offset=0)
    return fetch_all_from(f"{API}/{resource}.json?{urllib.parse.urlencode(query)}", key)


def fetch_by_ids(resource, key, id_field, ids, params=None):
    rows = []
    ids = sorted(ids)
    for start in range(0, len(ids), BATCH):
        query = dict(params or {}, limit=PAGE)
        query[f"{id_field}__in"] = ",".join(str(i) for i in ids[start : start + BATCH])
        rows.extend(fetch_all_from(f"{API}/{resource}.json?{urllib.parse.urlencode(query)}", key))
    return rows


def download(refresh):
    status = cached("status.json", lambda: get_json(f"{API}/status.json"), refresh)
    molecules = cached(
        "molecules.json",
        lambda: fetch_all("molecule", "molecules", {"max_phase": 4, "only": ",".join(MOLECULE_FIELDS)}),
        refresh,
    )
    mechanisms = cached("mechanisms.json", lambda: fetch_all("mechanism", "mechanisms", {}), refresh)
    target_ids = {m["target_chembl_id"] for m in mechanisms if m["target_chembl_id"]}
    targets = cached(
        "targets.json",
        lambda: fetch_by_ids(
            "target",
            "targets",
            "target_chembl_id",
            target_ids,
            {"only": "target_chembl_id,pref_name,target_type,organism,tax_id,target_components"},
        ),
        refresh,
    )
    component_ids = {
        c["component_id"] for t in targets for c in t["target_components"] if c.get("component_id")
    }
    components = cached(
        "components.json",
        lambda: fetch_by_ids(
            "target_component",
            "target_components",
            "component_id",
            component_ids,
            {"only": "component_id,accession,component_type,description,organism,protein_classifications"},
        ),
        refresh,
    )
    classes = cached(
        "protein_classes.json",
        lambda: fetch_all("protein_classification", "protein_classifications", {}),
        refresh,
    )
    return status, molecules, mechanisms, targets, components, classes


def modality(molecule_type):
    if molecule_type == "Small molecule":
        return "Small molecule"
    if molecule_type in BIOLOGIC_TYPES:
        return "Biologic"
    return "Other"


def class_paths(classes):
    by_id = {c["protein_class_id"]: c for c in classes}

    def path(class_id):
        names = []
        while class_id in by_id and by_id[class_id]["class_level"] > 0:
            names.append(by_id[class_id]["pref_name"])
            class_id = by_id[class_id]["parent_id"]
        return list(reversed(names))

    return {class_id: path(class_id) for class_id in by_id}


def bucket(path):
    """Map a ChEMBL protein-class path (level 1 first) to one guide class."""
    if not path:
        return "Other or unclassified proteins"
    level1, level2 = path[0], path[1] if len(path) > 1 else ""
    if any("G protein-coupled receptor" in name for name in path):
        return "GPCRs"
    if level1 == "Ion channel":
        return "Ion channels"
    if level1 == "Enzyme" and level2 == "Kinase":
        return "Protein kinases"
    if level1 == "Transcription factor" and level2 == "Nuclear receptor":
        return "Nuclear receptors"
    if level1 == "Enzyme" and level2 == "Protease":
        return "Proteases"
    if level1 == "Enzyme":
        return "Other enzymes"
    if level1 in ("Transporter", "Auxiliary transport protein"):
        return "Transporters"
    if level1 == "Epigenetic regulator":
        return "Epigenetic regulators"
    if level1 == "Membrane receptor":
        return "Other membrane receptors"
    if level1 == "Transcription factor":
        return "Other transcription factors"
    if level1 == "Secreted protein":
        return "Secreted proteins"
    if level1 == "Surface antigen":
        return "Surface antigens"
    return "Other or unclassified proteins"


def organism_group(organism):
    if organism == "Homo sapiens":
        return "Human"
    if not organism or organism == "null":
        return "No organism"
    return "Pathogen or parasite"


def pathogen_type(organism):
    """Rough organism type from the ChEMBL organism name, for the pathogen breakdown only."""
    name = organism.lower()
    if "virus" in name or "hepacivirus" in name or "herpes" in name:
        return "Virus"
    fungi = ["candida", "aspergillus", "trichophyton", "fung", "cryptococcus", "dermatophyte", "mucorales", "pneumocystis"]
    if any(word in name for word in fungi):
        return "Fungus"
    parasites = [
        "plasmodium",
        "trypanosoma",
        "giardia",
        "trichomonas",
        "cryptosporidium",
        "schistosoma",
        "ascaris",
        "nematoda",
        "onchoc",
        "strongyloides",
        "taen",
        "pediculus",
        "sarcoptes",
    ]
    if any(word in name for word in parasites):
        return "Parasite"
    return "Bacterium"


def classify_targets(targets, components, classes):
    paths = class_paths(classes)
    component_by_id = {c["component_id"]: c for c in components}
    rank = {name: i for i, name in enumerate(CLASS_ORDER)}
    info = {}
    for target in targets:
        kind = PROTEIN_TARGET_KINDS.get(target["target_type"], "Non-protein")
        accessions, buckets, level1 = [], set(), set()
        for member in target["target_components"]:
            component = component_by_id.get(member.get("component_id"))
            if not component or component.get("component_type") != "PROTEIN":
                continue
            accessions.append(component["accession"])
            component_paths = [
                paths.get(c["protein_classification_id"], [])
                for c in component.get("protein_classifications") or []
            ]
            level1.update(path[0] for path in component_paths if path)
            if component_paths:
                buckets.add(min((bucket(path) for path in component_paths), key=rank.get))
        if kind == "Non-protein":
            target_class = "Non-protein targets"
        else:
            specific = buckets - {"Other or unclassified proteins"}
            if len(specific) == 1:
                target_class = specific.pop()
            elif not specific:
                target_class = "Other or unclassified proteins"
            else:
                target_class = "Mixed classes"
        info[target["target_chembl_id"]] = {
            "pref_name": target["pref_name"],
            "target_type": target["target_type"],
            "target_kind": kind,
            "organism": target["organism"] or "",
            "organism_group": organism_group(target["organism"]),
            "target_class": target_class,
            "chembl_level1_classes": "; ".join(sorted(level1)),
            "uniprot_accessions": sorted(set(accessions)),
        }
    return info


def approved_parents(molecules):
    parents = {}
    for molecule in molecules:
        parent = (molecule.get("molecule_hierarchy") or {}).get("parent_chembl_id")
        if parent and parent != molecule["molecule_chembl_id"]:
            continue
        parents[molecule["molecule_chembl_id"]] = molecule
    return parents


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


def resolve_name(name, drugs, all_parents, refresh):
    """Return the approved parent ChEMBL ID for a prescription ingredient name, or None."""
    if name in PRESCRIPTION_NAME_OVERRIDES:
        target_name = PRESCRIPTION_NAME_OVERRIDES[name]
        if target_name is None:
            return None
        name = target_name
    by_name = {(m["pref_name"] or "").upper(): k for k, m in all_parents.items()}
    if name.upper() in by_name:
        return by_name[name.upper()]
    slug = re.sub(r"[^a-z0-9]+", "-", name.lower())
    hits = cached(
        f"synonyms/{slug}.json",
        lambda: get_json(
            f"{API}/molecule.json?"
            + urllib.parse.urlencode(
                {"molecule_synonyms__molecule_synonym__iexact": name, "only": "molecule_chembl_id,molecule_hierarchy,max_phase", "limit": 50}
            )
        )["molecules"],
        refresh,
    )
    for hit in hits:
        parent = (hit.get("molecule_hierarchy") or {}).get("parent_chembl_id") or hit["molecule_chembl_id"]
        if parent in all_parents:
            return parent
    return None


def prescription_share(drugs, drug_classes, all_parents, refresh):
    page = CACHE / "clincalc-top300.html"
    if refresh or not page.exists():
        request = urllib.request.Request(CLINCALC, headers={"User-Agent": "Mozilla/5.0 ProteinIQ drug-target guide"})
        with urllib.request.urlopen(request, timeout=120) as response:
            page.write_bytes(response.read())
    text = page.read_text(encoding="utf8", errors="ignore")
    year = re.search(r"Total Prescriptions \((\d{4})\)", text).group(1)
    products = re.findall(
        r"<tr>\s*<td>(\d+)</td>\s*<td><a href=\"[^\"]+\">([^<]+)</a></td>\s*<td>([\d,]+)</td>", text
    )
    rows, totals = [], Counter()
    for rank, product, prescriptions in products:
        product = html.unescape(product)
        count = int(prescriptions.replace(",", ""))
        ingredients = [part.strip() for part in product.split(";")]
        mapped = {part: resolve_name(part, drugs, all_parents, refresh) for part in ingredients}
        classes = set()
        for chembl_id in mapped.values():
            if chembl_id:
                classes |= drug_classes.get(chembl_id, set())
        totals["all"] += count
        totals["products"] += 1
        if classes:
            totals["with_known_target"] += count
            totals["products_with_known_target"] += 1
        if "GPCRs" in classes:
            totals["gpcr"] += count
            totals["gpcr_products"] += 1
        rows.append(
            [
                int(rank),
                product,
                "; ".join(f"{part}={mapped[part] or ''}" for part in ingredients),
                "; ".join(c for c in CLASS_ORDER if c in classes),
                int("GPCRs" in classes),
            ]
        )
    with open(CACHE / "top-300-prescription-mapping.csv", "w", newline="", encoding="utf8") as handle:
        writer = csv.writer(handle)
        writer.writerow(["clincalc_rank", "product", "ingredient_chembl_ids", "target_classes", "acts_on_gpcr"])
        writer.writerows(rows)
    return {
        "source": CLINCALC,
        "prescription_year": int(year),
        "products": totals["products"],
        "products_with_known_target": totals["products_with_known_target"],
        "gpcr_products": totals["gpcr_products"],
        "pct_products": pct(totals["gpcr_products"], totals["products"]),
        "prescriptions": totals["all"],
        "prescriptions_with_known_target": totals["with_known_target"],
        "gpcr_prescriptions": totals["gpcr"],
        "pct_prescriptions": pct(totals["gpcr"], totals["all"]),
        "pct_prescriptions_with_known_target": pct(totals["gpcr"], totals["with_known_target"]),
    }


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--refresh", action="store_true", help="download every API page again")
    args = parser.parse_args()
    status, molecules, mechanisms, targets, components, classes = download(args.refresh)

    all_parents = approved_parents(molecules)
    drugs = {k: m for k, m in all_parents.items() if m["therapeutic_flag"]}
    target_info = classify_targets(targets, components, classes)

    mechanism_rows = [
        m for m in mechanisms if m["parent_molecule_chembl_id"] in drugs and m["disease_efficacy"] == 1
    ]
    drugs_with_mechanism = {m["parent_molecule_chembl_id"] for m in mechanism_rows}
    actions = defaultdict(set)
    for m in mechanism_rows:
        if m["target_chembl_id"]:
            actions[(m["parent_molecule_chembl_id"], m["target_chembl_id"])].add(m["action_type"] or "")
    links = sorted(actions)

    drug_targets = defaultdict(set)
    for drug_id, target_id in links:
        drug_targets[drug_id].add(target_id)
    drug_classes = {d: {target_info[t]["target_class"] for t in ts} for d, ts in drug_targets.items()}

    OUT.mkdir(parents=True, exist_ok=True)
    # gzip with a fixed timestamp keeps the published file byte-stable between identical runs.
    with gzip.GzipFile(OUT / "approved-drug-mechanisms.csv.gz", "wb", mtime=0) as raw, io.TextIOWrapper(
        raw, encoding="utf8", newline=""
    ) as handle:
        writer = csv.writer(handle)
        writer.writerow(
            [
                "drug_chembl_id",
                "drug_name",
                "molecule_type",
                "modality",
                "first_approval",
                "withdrawn",
                "target_chembl_id",
                "target_name",
                "target_type",
                "organism",
                "organism_group",
                "target_class",
                "action_types",
            ]
        )
        for drug_id, target_id in links:
            drug, target = drugs[drug_id], target_info[target_id]
            writer.writerow(
                [
                    drug_id,
                    drug["pref_name"] or "",
                    drug["molecule_type"],
                    modality(drug["molecule_type"]),
                    drug["first_approval"] or "",
                    int(bool(drug["withdrawn_flag"])),
                    target_id,
                    target["pref_name"],
                    target["target_type"],
                    target["organism"],
                    target["organism_group"],
                    target["target_class"],
                    "; ".join(sorted(actions[(drug_id, target_id)])),
                ]
            )

    target_drugs = defaultdict(Counter)
    for drug_id, target_id in links:
        target_drugs[target_id][modality(drugs[drug_id]["molecule_type"])] += 1
    with open(OUT / "approved-drug-targets.csv", "w", newline="", encoding="utf8") as handle:
        writer = csv.writer(handle)
        writer.writerow(
            [
                "target_chembl_id",
                "target_name",
                "target_type",
                "target_kind",
                "organism",
                "organism_group",
                "target_class",
                "chembl_level1_classes",
                "uniprot_accessions",
                "approved_drugs",
                "small_molecule_drugs",
                "biologic_drugs",
                "other_drugs",
            ]
        )
        for target_id in sorted(target_drugs, key=lambda t: (-sum(target_drugs[t].values()), t)):
            target, counts = target_info[target_id], target_drugs[target_id]
            writer.writerow(
                [
                    target_id,
                    target["pref_name"],
                    target["target_type"],
                    target["target_kind"],
                    target["organism"],
                    target["organism_group"],
                    target["target_class"],
                    target["chembl_level1_classes"],
                    " ".join(target["uniprot_accessions"]),
                    sum(counts.values()),
                    counts["Small molecule"],
                    counts["Biologic"],
                    counts["Other"],
                ]
            )

    used = sorted(target_drugs)
    human = [t for t in used if target_info[t]["organism_group"] == "Human"]
    human_protein = [t for t in human if target_info[t]["target_kind"] != "Non-protein"]

    def accessions(target_ids):
        return {a for t in target_ids for a in target_info[t]["uniprot_accessions"]}

    target_counts = {
        "distinct_targets": len(used),
        "by_organism_group": dict(Counter(target_info[t]["organism_group"] for t in used)),
        "by_target_kind": dict(Counter(target_info[t]["target_kind"] for t in used)),
        "human_by_target_kind": dict(Counter(target_info[t]["target_kind"] for t in human)),
        "pathogen_by_target_kind": dict(
            Counter(
                target_info[t]["target_kind"]
                for t in used
                if target_info[t]["organism_group"] == "Pathogen or parasite"
            )
        ),
        "human_protein_targets": len(human_protein),
        "human_proteins_behind_targets": len(accessions(human_protein)),
        "human_proteins_as_single_protein_targets": len(
            accessions(t for t in human if target_info[t]["target_kind"] == "Single protein")
        ),
        "human_by_class": {
            c: sum(1 for t in human if target_info[t]["target_class"] == c) for c in CLASS_ORDER
        },
        "human_proteins_by_class": {
            c: len(accessions(t for t in human if target_info[t]["target_class"] == c)) for c in CLASS_ORDER
        },
    }

    target_counts["pathogen_by_organism_type"] = dict(
        Counter(
            pathogen_type(target_info[t]["organism"])
            for t in used
            if target_info[t]["organism_group"] == "Pathogen or parasite"
        )
    )
    targets_with_one_drug = [t for t in used if sum(target_drugs[t].values()) == 1]
    target_counts["targets_with_one_approved_drug"] = len(targets_with_one_drug)
    target_counts["human_targets_with_one_approved_drug"] = sum(
        1 for t in targets_with_one_drug if target_info[t]["organism_group"] == "Human"
    )

    # Earliest first_approval year among each human target's drugs; drugs without a year
    # (mostly old drugs approved outside the US) do not date a target.
    earliest = {}
    for drug_id, target_id in links:
        year = drugs[drug_id]["first_approval"]
        if year and target_info[target_id]["organism_group"] == "Human":
            earliest[target_id] = min(year, earliest.get(target_id, year))
    target_counts["human_targets_dated"] = len(earliest)
    target_counts["new_human_targets_by_decade"] = {
        ("before 1950" if decade < 1950 else f"{decade}s"): sum(
            1 for y in earliest.values() if (y // 10 * 10 if y >= 1950 else 1940) == decade
        )
        for decade in range(1940, 2030, 10)
    }
    target_counts["new_human_targets_since_2010_by_class"] = dict(
        Counter(target_info[t]["target_class"] for t, year in earliest.items() if year >= 2010).most_common()
    )

    by_modality = {name: [d for d in drugs if modality(drugs[d]["molecule_type"]) == name] for name in MODALITIES}
    by_modality["All"] = list(drugs)
    summary_rows = []
    for scope in ["All", "Small molecule", "Biologic"]:
        with_target = [d for d in by_modality[scope] if d in drug_targets]
        scope_targets = {t for d in with_target for t in drug_targets[d]}
        scope_human = {t for t in scope_targets if target_info[t]["organism_group"] == "Human"}
        for target_class in CLASS_ORDER:
            class_targets = {t for t in scope_targets if target_info[t]["target_class"] == target_class}
            human_class_targets = class_targets & scope_human
            any_drugs = [d for d in with_target if target_class in drug_classes[d]]
            only_drugs = [d for d in with_target if drug_classes[d] == {target_class}]
            summary_rows.append(
                {
                    "modality": scope,
                    "target_class": target_class,
                    "targets": len(class_targets),
                    "human_targets": len(human_class_targets),
                    "pct_of_human_targets": pct(len(human_class_targets), len(scope_human)),
                    "drugs_any_target_in_class": len(any_drugs),
                    "drugs_all_targets_in_class": len(only_drugs),
                    "drugs_with_known_target": len(with_target),
                    "approved_drugs": len(by_modality[scope]),
                    "pct_of_drugs_with_known_target": pct(len(any_drugs), len(with_target)),
                    "pct_of_approved_drugs": pct(len(any_drugs), len(by_modality[scope])),
                }
            )
    with open(OUT / "target-class-summary.csv", "w", newline="", encoding="utf8") as handle:
        writer = csv.DictWriter(handle, fieldnames=list(summary_rows[0]))
        writer.writeheader()
        writer.writerows(summary_rows)

    def gpcr_share(drug_ids):
        drug_ids = list(drug_ids)
        hits = sum(1 for d in drug_ids if "GPCRs" in drug_classes.get(d, set()))
        return {"gpcr_drugs": hits, "denominator": len(drug_ids), "pct": pct(hits, len(drug_ids))}

    def first_approval(d):
        return drugs[d]["first_approval"] or 0

    decades = {}
    for start in range(1940, 2030, 10):
        low = 0 if start == 1940 else start
        cohort = [d for d in drugs if first_approval(d) and low <= first_approval(d) < start + 10]
        label = "before 1950" if start == 1940 else f"{start}s"
        decades[label] = {"approved": len(cohort), **gpcr_share(d for d in cohort if d in drug_targets)}

    small = by_modality["Small molecule"]
    human_gpcr = sum(1 for t in human if target_info[t]["target_class"] == "GPCRs")
    denominators = {
        "small_molecules_with_known_target": gpcr_share(d for d in small if d in drug_targets),
        "all_drugs_with_known_target": gpcr_share(d for d in drugs if d in drug_targets),
        "small_molecules_all": gpcr_share(small),
        "all_therapeutic_approved": gpcr_share(drugs),
        "all_max_phase_4_parents_incl_non_therapeutic": gpcr_share(all_parents),
        "first_approved_2015_or_later_with_known_target": gpcr_share(
            d for d in drugs if first_approval(d) >= 2015 and d in drug_targets
        ),
        "human_targets": {"gpcr_targets": human_gpcr, "denominator": len(human), "pct": pct(human_gpcr, len(human))},
        "antibodies_with_known_target": gpcr_share(
            d for d in drugs if drugs[d]["molecule_type"] in ("Antibody", "Antibody drug conjugate") and d in drug_targets
        ),
        "proteins_and_peptides_with_known_target": gpcr_share(
            d for d in drugs if drugs[d]["molecule_type"] == "Protein" and d in drug_targets
        ),
        "by_first_approval_decade": decades,
        "us_prescriptions": prescription_share(drugs, drug_classes, all_parents, args.refresh),
    }

    provenance = {
        "license": "CC BY-SA 3.0, derived from ChEMBL; credit ChEMBL (Zdrazil et al., Nucleic Acids Research, 2024) and ProteinIQ",
        "chembl_release": status["chembl_db_version"],
        "chembl_release_date": status["chembl_release_date"],
        "api": API,
        "retrieved_at": datetime.fromtimestamp((CACHE / "molecules.json").stat().st_mtime, timezone.utc).isoformat(
            timespec="seconds"
        ),
        "filters": {
            "approved": "molecule max_phase = 4, parent molecules only (molecule_hierarchy), therapeutic_flag = true",
            "mechanism": "drug_mechanism rows for those parents with disease_efficacy = 1 and a non-null target",
        },
        "drugs": {
            "max_phase_4_molecules_incl_salts": len(molecules),
            "max_phase_4_parents": len(all_parents),
            "excluded_non_therapeutic_parents": len(all_parents) - len(drugs),
            "approved_therapeutic_drugs": len(drugs),
            "withdrawn_flag_among_them": sum(1 for d in drugs.values() if d["withdrawn_flag"]),
            "by_molecule_type": dict(Counter(d["molecule_type"] for d in drugs.values())),
            "by_modality": {name: len(by_modality[name]) for name in MODALITIES},
            "with_mechanism_record": len(drugs_with_mechanism),
            "with_known_target": len(drug_targets),
            "with_known_target_by_modality": {
                name: sum(1 for d in by_modality[name] if d in drug_targets) for name in MODALITIES
            },
            "with_human_target": sum(
                1 for ts in drug_targets.values() if any(target_info[t]["organism_group"] == "Human" for t in ts)
            ),
            "with_pathogen_targets_only": sum(
                1
                for ts in drug_targets.values()
                if all(target_info[t]["organism_group"] == "Pathogen or parasite" for t in ts)
            ),
            "drug_target_links": len(links),
            "targets_per_drug_median": sorted(len(ts) for ts in drug_targets.values())[len(drug_targets) // 2],
        },
        "targets": target_counts,
        "gpcr_share_by_denominator": denominators,
    }
    (OUT / "provenance.json").write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf8")
    (OUT / "README.md").write_text(README, encoding="utf8")
    if SCRIPT.parent != OUT:
        shutil.copyfile(SCRIPT, OUT / SCRIPT.name)
    print(json.dumps(provenance, indent=2))


if __name__ == "__main__":
    main()
