"""Count FDA-licensed monoclonal antibody products in the FDA Purple Book (ProteinIQ analysis, October 23, 2026).

Input: the FDA Purple Book "Monthly Historical Data Changes Report - September 2026" database download,
  https://www.accessdata.fda.gov/drugsatfda_docs/PurpleBook/2026/purplebook-search-September-data-download.csv
  linked from https://purplebooksearch.fda.gov/downloads, downloaded October 23, 2026
  (HTTP Last-Modified Wed, 30 Sep 2026 17:32:54 GMT; 487,303 bytes;
  SHA-256 b9d04a8b1ea9c6605fdeff01127f0de32cb092534a40441b036e39c9665974e6).
  The file opens with that month's changes; the full product list follows its second header row.
  Keep the download outside the repository and pass its path.

Run from this directory (Python 3.13, standard library only):
  python analyze.py <path-to>/purplebook-search-September-data-download.csv
Writes purple-book-antibodies.csv (one row per BLA) and summary.json.

Inclusion rules
- Antibody products are 351(a) BLAs whose proper name contains an antibody INN: the -mab stem or the
  2022 INN scheme's -tug, -bart, -mig and -ment stems, plus muromonab-CD3, named before the -mab
  stem existed. Biosimilars (351(k)) are counted separately.
- One product per new antibody active ingredient, dated by its earliest Purple Book BLA. Later BLAs for the
  same antibody (new routes, hyaluronidase co-formulations, fixed combinations of licensed antibodies,
  re-licensure) are listed but not counted again. A fixed-dose product that introduces new antibodies
  (Inmazeb, Opdualag) counts once, as a mixture.
- Each product gets one format in CATEGORY; unlisted antibody names are unconjugated monospecific antibodies.
- Fc fusions and Fc-based proteins (FC_BASED) are reported but excluded from the antibody count, as are
  polyclonal antibody fragments (POLYCLONAL) and tebentafusp, a T-cell receptor fused to an anti-CD3 scFv
  whose INN has no antibody stem.
- A product is licensed when any of its BLAs is "Licensed"; it is revoked when every BLA is revoked.
"""
import collections
import csv
import datetime as dt
import json
import re
import sys

ANTIBODY_STEM = re.compile(r'(mab|tug|bart|mig|ment)$')
LEGACY_ANTIBODY = {'muromonab-cd3'}  # approved in 1986, before the -mab stem existed
CUTOFF_2025 = dt.date(2025, 12, 31)

# Format calls for antibodies that are not unconjugated monospecific antibodies (keys are proper names
# without the four-letter suffix). Everything else with an antibody stem is 'unconjugated'.
CATEGORY = {
    **dict.fromkeys([
        'gemtuzumab ozogamicin', 'brentuximab vedotin', 'ado-trastuzumab emtansine', 'inotuzumab ozogamicin',
        'polatuzumab vedotin', 'enfortumab vedotin', 'fam-trastuzumab deruxtecan', 'sacituzumab govitecan',
        'belantamab mafodotin', 'loncastuximab tesirine', 'tisotumab vedotin', 'mirvetuximab soravtansine',
        'datopotamab deruxtecan', 'telisotuzumab vedotin', 'pivekimab sunirine'], 'antibody-drug conjugate'),
    **dict.fromkeys([
        'blinatumomab', 'emicizumab', 'amivantamab', 'faricimab', 'mosunetuzumab', 'teclistamab',
        'epcoritamab', 'glofitamab', 'talquetamab', 'elranatamab', 'tarlatamab', 'zanidatamab',
        'zenocutuzumab', 'linvoseltamab'], 'bispecific'),
    **dict.fromkeys([
        'abciximab', 'ranibizumab', 'certolizumab pegol', 'idarucizumab', 'caplacizumab', 'brolucizumab'],
        'fragment'),
    'ibritumomab tiuxetan': 'radioconjugate (therapeutic)',
    'tositumomab and iodine i-131 tositumomab': 'radioconjugate (therapeutic)',
    'capromab pendetide': 'radioconjugate (diagnostic imaging)',
    'nofetumomab': 'radioconjugate (diagnostic imaging)',
    'technetium 99m tc fanolesomab': 'radioconjugate (diagnostic imaging)',
    'moxetumomab pasudotox': 'immunotoxin',
    'atoltivimab, maftivimab, and odesivimab': 'mixture',
    'nivolumab and relatlimab': 'mixture',
}

# Fc fusions and Fc-based proteins, reported but not counted as antibodies.
FC_BASED = {
    'etanercept', 'alefacept', 'abatacept', 'rilonacept', 'romiplostim', 'belatacept', 'aflibercept',
    'ziv-aflibercept', 'dulaglutide', 'asfotase alfa', 'luspatercept', 'efgartigimod alfa',
    'efgartigimod alfa and hyaluronidase', 'eflapegrastim', 'efbemalenograstim alfa',
    'nogapendekin alfa inbakicept', 'sotatercept', 'atacicept', 'tividenofusp alfa', 'insulin efsitora alfa',
    'antihemophilic factor (recombinant), fc fusion protein',
    'coagulation factor ix (recombinant), fc fusion protein',
    'antihemophilic factor (recombinant), fc-vwf-xten fusion protein',
}
FC_SAME_MOLECULE = {'ziv-aflibercept': 'aflibercept', 'efgartigimod alfa and hyaluronidase': 'efgartigimod alfa'}
POLYCLONAL = {
    'digoxin immune fab (ovine)', 'crotalidae polyvalent immune fab (ovine)', 'crotalidae immune f(ab\')2 (equine)',
    'centruroides (scorpion) immune f(ab\')2 (equine) injection'}
OTHER_REPORTED = {'tebentafusp': 'TCR-anti-CD3 scFv fusion (not counted)'}

# Combination and co-formulation BLAs: proper name -> antibodies they contain.
COMPONENTS = {
    'atoltivimab, maftivimab, and odesivimab': ['atoltivimab', 'maftivimab', 'odesivimab'],
    'nivolumab and relatlimab': ['nivolumab', 'relatlimab'],
    'pertuzumab, trastuzumab, and hyaluronidase': ['pertuzumab', 'trastuzumab'],
}

NOTES = {
    '103463': 'Purple Book date 1992; muromonab-CD3 (Orthoclone OKT3) was first approved in 1986',
    '761060': 'First approved in 2000 and withdrawn in 2010; the Purple Book lists only the 2017 license',
    '761484': 'Strohl (2026) gives December 17, 2025; the Purple Book lists February 13, 2026',
    '761320': 'Ophthalmic bevacizumab licensed under its own 351(a) BLA',
    '761358': 'Subcutaneous infliximab licensed under its own 351(a) BLA',
    '761440': 'Re-licensed after the 2020 BLA was revoked',
}


def key(name):
    """Proper name in lower case without the FDA four-letter suffix."""
    n = re.sub(r'\s+', ' ', name.strip().lower())
    return re.sub(r'-[a-z]{4}$', '', n)


def antibody_parts(k):
    if k in COMPONENTS:
        return COMPONENTS[k]
    parts = [p for p in re.split(r',? and |, ', k) if 'hyaluronidase' not in p]
    return parts


def has_antibody_stem(k):
    return k in LEGACY_ANTIBODY or any(ANTIBODY_STEM.search(w) for p in antibody_parts(k) for w in re.split(r'[ -]', p))


def date(s):
    try:
        return dt.datetime.strptime(s.strip(), '%B %d, %Y').date()
    except ValueError:
        return None


def load(path):
    with open(path, encoding='utf-8-sig', newline='') as f:
        rows = list(csv.reader(f))
    headers = [i for i, r in enumerate(rows) if r and r[0] == 'N/R/U']
    start = headers[1]  # the full product list follows the second header row
    hdr = rows[start]
    return [dict(zip(hdr, r)) for r in rows[start + 1:] if any(c.strip() for c in r)]


def main(path):
    data = load(path)
    blas = collections.defaultdict(list)
    for d in data:
        blas[d['BLA Number'].strip()].append(d)

    records = []
    for bla, ds in blas.items():
        lt = ds[0]['License Type'].strip()
        name = ds[0]['Proper Name'].strip()
        k = key(name)
        orig = [date(d['Approval Date']) for d in ds if d['Submission Type'].strip() in ('Original', '')]
        first = min(x for x in orig if x) if any(orig) else min(date(d['Approval Date']) for d in ds)
        rec = {
            'bla': bla, 'license_type': lt, 'center': ds[0]['Center'].strip(), 'proper_name': name,
            'proprietary_names': '; '.join(sorted({d['Proprietary Name'].strip() for d in ds} - {'', 'N/A'})),
            'approval_date': first.isoformat(),
            'licensure': 'Licensed' if any(d['Licensure'] == 'Licensed' for d in ds) else
                         '/'.join(sorted({d['Licensure'] for d in ds})),
            'marketed': 'yes' if any(d['Marketing Status'].strip().rstrip('*') in ('Rx', 'OTC') for d in ds) else 'no',
            'reference_product': ds[0]['Ref. Product Proper Name'].strip(),
            'key': k,
        }
        if lt.startswith('351(k)'):
            if has_antibody_stem(key(rec['reference_product'])) or has_antibody_stem(k):
                rec.update(category='biosimilar', counted='no')
                records.append(rec)
            continue
        if k in FC_BASED:
            rec.update(category='Fc fusion or Fc-based protein', counted='no')
        elif k in POLYCLONAL:
            rec.update(category='polyclonal antibody fragment', counted='no')
        elif k in OTHER_REPORTED:
            rec.update(category=OTHER_REPORTED[k], counted='no')
        elif has_antibody_stem(k):
            rec.update(category=CATEGORY.get(k, 'unconjugated monospecific'), counted='antibody')
        else:
            continue
        records.append(rec)

    # Decide which BLA introduces each antibody (or Fc protein) for the first time.
    seen = set()
    for r in sorted(records, key=lambda r: (r['approval_date'], r['bla'])):
        if r['category'] == 'biosimilar':
            r['role'] = 'biosimilar'
            continue
        if r['counted'] == 'antibody':
            parts = antibody_parts(r['key'])
            new = [p for p in parts if p not in seen]
            seen.update(parts)
            if r['key'] in CATEGORY and CATEGORY[r['key']] == 'mixture':
                new = new and [r['key']]
            r['role'] = 'new product' if new else 'additional BLA'
            r['product'] = r['key'] if new else ' + '.join(parts)
        else:
            mol = FC_SAME_MOLECULE.get(r['key'], r['key'])
            r['role'] = 'additional BLA' if mol in seen else 'new product'
            seen.add(mol)
            r['product'] = mol
        r['note'] = NOTES.get(r['bla'], '')

    # Products carry the licensure of any of their BLAs.
    status = collections.defaultdict(list)
    for r in records:
        if r['category'] != 'biosimilar':
            for p in antibody_parts(r['key']) if r['counted'] == 'antibody' else [r['product']]:
                status[p].append(r['licensure'] == 'Licensed')
    for r in records:
        if r['role'] == 'new product':
            parts = antibody_parts(r['key']) if r['counted'] == 'antibody' else [r['product']]
            r['product_licensed'] = 'yes' if any(any(status[p]) for p in parts) else 'no'

    data_date = max(date(d['Approval Date']) for d in data if date(d['Approval Date']))
    new_abs = [r for r in records if r['counted'] == 'antibody' and r['role'] == 'new product']
    fc = [r for r in records if r['category'].startswith('Fc') and r['role'] == 'new product']
    ab_blas = [r for r in records if r['counted'] == 'antibody']
    bios = [r for r in records if r['category'] == 'biosimilar']

    def by_cat(rs):
        return dict(collections.Counter(r['category'] for r in rs).most_common())

    upto25 = [r for r in new_abs if dt.date.fromisoformat(r['approval_date']) <= CUTOFF_2025]
    summary = {
        'source': 'FDA Purple Book database download, September 2026 release (purplebook-search-September-data-download.csv)',
        'latest_approval_date_in_file': data_date.isoformat(),
        'antibody_products': {
            'total': len(new_abs),
            'licensed': sum(r['product_licensed'] == 'yes' for r in new_abs),
            'revoked': sum(r['product_licensed'] == 'no' for r in new_abs),
            'by_category': by_cat(new_abs),
            'licensed_by_category': by_cat([r for r in new_abs if r['product_licensed'] == 'yes']),
            'through_2025': {'total': len(upto25), 'by_category': by_cat(upto25)},
            'by_year': dict(sorted(collections.Counter(r['approval_date'][:4] for r in new_abs).items())),
        },
        'antibody_351a_blas': {
            'total': len(ab_blas),
            'licensed': sum(r['licensure'] == 'Licensed' for r in ab_blas),
            'additional_blas': sum(r['role'] == 'additional BLA' for r in ab_blas),
        },
        'fc_based_proteins': {
            'total': len(fc),
            'through_2025': sum(r['approval_date'] <= CUTOFF_2025.isoformat() for r in fc),
            'cber_clotting_factors': sum(r['center'] == 'CBER' for r in fc),
            'licensed': sum(r['product_licensed'] == 'yes' for r in fc),
        },
        'biosimilars': {
            'blas': len(bios),
            'interchangeable': sum('Interchangeable' in r['license_type'] for r in bios),
            'licensed': sum(r['licensure'] == 'Licensed' for r in bios),
            'reference_antibodies': len({key(r['reference_product']) for r in bios}),
            'by_reference': dict(collections.Counter(key(r['reference_product']) for r in bios).most_common()),
            'through_2025': sum(r['approval_date'] <= CUTOFF_2025.isoformat() for r in bios),
        },
    }

    cols = ['bla', 'license_type', 'center', 'proper_name', 'proprietary_names', 'approval_date', 'licensure',
            'marketed', 'category', 'counted', 'role', 'product', 'product_licensed', 'reference_product', 'note']
    order = {'antibody': 0, 'no': 1}
    records.sort(key=lambda r: (r['category'] == 'biosimilar', order[r['counted']], r['approval_date'], r['bla']))
    with open('purple-book-antibodies.csv', 'w', encoding='utf-8', newline='') as f:
        w = csv.DictWriter(f, fieldnames=cols, extrasaction='ignore', lineterminator='\n')
        w.writeheader()
        w.writerows(records)
    with open('summary.json', 'w', encoding='utf-8', newline='\n') as f:
        json.dump(summary, f, indent=2)
        f.write('\n')
    print(json.dumps(summary, indent=2))


if __name__ == '__main__':
    main(sys.argv[1])
