"""Compare published wheels on two fixed SMILES slices, including parsing.

Use separate Python environments for chematic 1.1.3, chematic 1.1.4 and
RDKit 2026.09.1. This runner uses the standard library; each worker imports
only its selected chemistry package. See --help for interpreter arguments.
"""
import argparse
import hashlib
import json
import math
import platform
import random
import statistics
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

CORPUS_SHA256 = "f48777e96f74738336f47f682fbff5af6775a3474fe25daa28b595340feee95f"
OPS = ("tpsa", "morgan", "tpsa_and_morgan", "tpsa_reused", "morgan_reused")
ENGINES = ("v113", "v114", "rdkit")


def worker(args):
    if args.worker == "rdkit":
        import rdkit
        from rdkit import Chem, DataStructs, RDLogger
        from rdkit.Chem import rdMolDescriptors, rdFingerprintGenerator
        assert rdkit.__version__ == "2026.09.1"
        RDLogger.DisableLog("rdApp.*")
        generator = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=2048)
        parse = Chem.MolFromSmiles
        tpsa = rdMolDescriptors.CalcTPSA
        morgan = generator.GetFingerprint
        packed = lambda fp: DataStructs.BitVectToBinaryText(fp).hex()
    else:
        import chematic
        expected = "1.1.3" if args.worker == "v113" else "1.1.4"
        assert chematic.__version__ == expected
        parse = chematic.from_smiles
        tpsa = lambda mol: mol.rdkit_tpsa
        morgan = lambda mol: mol.rdkit_ecfp_config(2, 2048)
        packed = lambda fp: bytes(fp).hex()

    blob = args.corpus.read_bytes()
    assert hashlib.sha256(blob).hexdigest() == CORPUS_SHA256
    rows = [line.split()[0] for line in blob.decode().splitlines() if line.strip()]
    rows = rows[args.offset:args.offset + 1000]
    assert len(rows) == 1000
    functions = {"tpsa": tpsa, "morgan": morgan,
                 "tpsa_and_morgan": lambda mol: (tpsa(mol), morgan(mol)),
                 "tpsa_reused": tpsa, "morgan_reused": morgan}

    def normal(op, values):
        if op in ("tpsa", "tpsa_reused"):
            assert all(math.isfinite(value) for value in values)
            return values
        if op in ("morgan", "morgan_reused"):
            return [packed(value) for value in values]
        assert all(math.isfinite(value[0]) for value in values)
        return [[value[0], packed(value[1])] for value in values]

    result = {}
    order = OPS[args.block % len(OPS):] + OPS[:args.block % len(OPS)]
    for op in order:
        fn = functions[op]
        fn(parse("CCO"))  # Disjoint warmup, outside the timed input batch.
        timings = []
        first = None
        reused = op.endswith("_reused")
        molecules = [parse(smiles) for smiles in rows] if reused else []
        for mol in molecules:
            fn(mol)  # One untimed preparation call on each reused molecule.
        for _ in range(args.repeats):
            start = time.perf_counter_ns()
            values = ([fn(mol) for mol in molecules for _ in range(50)] if reused
                      else [fn(parse(smiles)) for smiles in rows])
            timings.append(time.perf_counter_ns() - start)
            normalized = normal(op, values)  # Outside the timer.
            if reused:
                for index in range(1000):
                    group = normalized[index * 50:(index + 1) * 50]
                    assert all(value == group[0] for value in group)
                normalized = normalized[::50]
            if first is None:
                first = normalized
            else:
                assert normalized == first
            del values
        result[op] = {"timings_ns": timings, "values": first}
        del molecules
    print(json.dumps(result))


def agreement(op, reference, observed):
    assert len(reference) == len(observed) == 1000
    max_error = 0.0
    for expected, actual in zip(reference, observed):
        if op in ("morgan", "morgan_reused"):
            assert expected == actual, "Morgan bits differ"
        else:
            a, b = ((expected, actual) if op in ("tpsa", "tpsa_reused")
                    else (expected[0], actual[0]))
            max_error = max(max_error, abs(a - b))
            assert math.isclose(a, b, rel_tol=0, abs_tol=1e-9), "TPSA differs"
            if op == "tpsa_and_morgan":
                assert expected[1] == actual[1], "Morgan bits differ"
    return max_error


def paired_summary(numerator, denominator):
    ratios = [a / b for a, b in zip(numerator, denominator)]
    rng = random.Random(114)
    bootstrap = sorted(statistics.median(rng.choices(ratios, k=len(ratios)))
                       for _ in range(10000))
    return {"median_speedup": statistics.median(ratios),
            "ci95": [bootstrap[249], bootstrap[9749]],
            "faster_blocks": sum(ratio > 1 for ratio in ratios)}


def run(args):
    interpreters = dict(zip(ENGINES, [args.python_113, args.python_114, args.python_rdkit]))
    assert all(interpreters.values()), "Supply all three interpreter paths"
    output = {"checked_at": datetime.now(timezone.utc).isoformat(),
              "host": {"platform": platform.platform(), "python": platform.python_version()},
              "corpus_sha256": CORPUS_SHA256, "blocks": args.blocks,
              "repeats_per_block": args.repeats,
              "protocol": "Sequential fresh processes; rotating engine and operation order; "
                          "1000 parse-plus-calculation calls, or 50 calls per prepared molecule "
                          "on 1000 molecules, per repeat; native results retained; imports, file "
                          "reads, normalization and comparison outside timer; reused-mode parsing "
                          "and one preparation call per molecule also outside timer",
              "settings": {"morgan_radius": 2, "morgan_bits": 2048,
                           "include_chirality": False, "count_simulation": False,
                           "tpsa_include_s_and_p": False}, "slices": []}
    for offset in (0, 5000):
        observations = {engine: {op: [] for op in OPS} for engine in ENGINES}
        reference = None
        max_error = 0.0
        orders = []
        for block in range(args.blocks):
            order = ENGINES[block % 3:] + ENGINES[:block % 3]
            orders.append(list(order))
            for engine in order:
                command = [str(interpreters[engine]), str(Path(__file__).resolve()),
                           "--worker", engine, "--corpus", str(args.corpus.resolve()),
                           "--offset", str(offset), "--block", str(block),
                           "--repeats", str(args.repeats)]
                result = json.loads(subprocess.check_output(command, text=True))
                if reference is None:
                    reference = {op: result[op]["values"] for op in OPS}
                for op in OPS:
                    max_error = max(max_error, agreement(op, reference[op], result[op]["values"]))
                    observations[engine][op].append(result[op]["timings_ns"])
            print(f"offset={offset}, block={block + 1}/{args.blocks}", file=sys.stderr)
        summary = {}
        for op in OPS:
            divisor = 50 if op.endswith("_reused") else 1
            medians = {engine: [statistics.median(batch) / divisor for batch in observations[engine][op]]
                       for engine in ENGINES}
            summary[op] = {"median_ms_per_1000": {engine: statistics.median(times) / 1e6
                                                for engine, times in medians.items()},
                           "v113_over_v114": paired_summary(medians["v113"], medians["v114"]),
                           "rdkit_over_v114": paired_summary(medians["rdkit"], medians["v114"])}
        output["slices"].append({"offset": offset, "rows": 1000, "engine_orders": orders,
                                  "tpsa_max_absolute_error": max_error,
                                  "morgan_bits_equal": True, "summary": summary,
                                  "raw_timings_ns": observations})
    args.output.write_text(json.dumps(output, indent=2) + "\n")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--corpus", type=Path, required=True)
    parser.add_argument("--worker", choices=ENGINES)
    parser.add_argument("--python-113", type=Path)
    parser.add_argument("--python-114", type=Path)
    parser.add_argument("--python-rdkit", type=Path)
    parser.add_argument("--offset", type=int, default=0)
    parser.add_argument("--block", type=int, default=0)
    parser.add_argument("--blocks", type=int, default=21)
    parser.add_argument("--repeats", type=int, default=5)
    parser.add_argument("--output", type=Path, default=Path("benchmark-results.json"))
    args = parser.parse_args()
    if args.worker:
        worker(args)
    else:
        run(args)
