#!/usr/bin/env python3
"""Generate/check the wholly fictional Chapter 10 screening-score case.

The program uses only Python's standard library.  No person, clinical study, or
real screening instrument is represented by these teaching records.
"""

import argparse
import csv
import io
import json
from pathlib import Path

N_CASES = 60
THRESHOLDS = (-1, 40, 55, 70, 101)
CSV_NAME = "ch10-fictional-screening.csv"
JSON_NAME = "ch10-case-results.json"


def make_rows():
    """Return records from fixed integer rules (no clock, OS, or global RNG)."""
    rows = []
    for case_id in range(1, N_CASES + 1):
        score = (case_id * 37 + 11) % 101
        # This unobserved offset prevents the displayed score from perfectly
        # determining the reference outcome, as in a useful classification case.
        latent_offset = (case_id * 19) % 29 - 14
        outcome = "positive" if score + latent_offset >= 55 else "negative"
        rows.append(
            {
                "case_id": f"F{case_id:03d}",
                "cohort": "pilot-A" if case_id % 2 else "pilot-B",
                "screening_score": score,
                "reference_outcome": outcome,
            }
        )
    return rows


def safe_ratio(numerator, denominator):
    """Return a four-place ratio, or None when its denominator is zero."""
    return None if denominator == 0 else round(numerator / denominator, 4)


def confusion_metrics(rows, threshold):
    """Compute confusion counts/metrics for the rule score >= threshold."""
    tp = fp = tn = fn = 0
    for row in rows:
        predicted_positive = row["screening_score"] >= threshold
        actual_positive = row["reference_outcome"] == "positive"
        if predicted_positive and actual_positive:
            tp += 1
        elif predicted_positive:
            fp += 1
        elif actual_positive:
            fn += 1
        else:
            tn += 1

    predicted_positive_n = tp + fp
    predicted_negative_n = tn + fn
    actual_positive_n = tp + fn
    actual_negative_n = tn + fp
    return {
        "threshold": threshold,
        "true_positive": tp,
        "false_positive": fp,
        "true_negative": tn,
        "false_negative": fn,
        "predicted_positive_n": predicted_positive_n,
        "predicted_negative_n": predicted_negative_n,
        "accuracy": safe_ratio(tp + tn, len(rows)),
        "precision": safe_ratio(tp, predicted_positive_n),
        "recall_sensitivity": safe_ratio(tp, actual_positive_n),
        "specificity": safe_ratio(tn, actual_negative_n),
        "false_positive_rate": safe_ratio(fp, actual_negative_n),
        "false_negative_rate": safe_ratio(fn, actual_positive_n),
        "negative_predictive_value": safe_ratio(tn, predicted_negative_n),
        "f1": safe_ratio(2 * tp, 2 * tp + fp + fn),
    }


def build_artifacts():
    rows = make_rows()
    evaluations = [confusion_metrics(rows, threshold) for threshold in THRESHOLDS]
    positive_n = sum(row["reference_outcome"] == "positive" for row in rows)
    results = {
        "schema_version": 1,
        "title": "Fictional Chapter 10 screening-score teaching case",
        "fictional": True,
        "provenance": (
            "Generated entirely by fixed integer rules in tools/ch10-generate-case.py; "
            "not people, clinical observations, or a validated screening instrument."
        ),
        "n_cases": len(rows),
        "class_counts": {"positive": positive_n, "negative": len(rows) - positive_n},
        "score_range": {"minimum": min(r["screening_score"] for r in rows), "maximum": max(r["screening_score"] for r in rows)},
        "decision_rule": "Predict positive when screening_score >= threshold.",
        "rounding": "Rates are rounded to four decimal places; a zero denominator is JSON null.",
        "threshold_evaluations": evaluations,
    }

    csv_buffer = io.StringIO(newline="")
    writer = csv.DictWriter(csv_buffer, fieldnames=rows[0].keys(), lineterminator="\r\n")
    writer.writeheader()
    writer.writerows(rows)
    csv_bytes = csv_buffer.getvalue().encode("utf-8")
    json_bytes = (json.dumps(results, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
    return rows, results, {CSV_NAME: csv_bytes, JSON_NAME: json_bytes}


def validate_invariants(rows, results):
    """Guard the published experiment independently of byte comparison."""
    assert len(rows) == N_CASES
    assert len({row["case_id"] for row in rows}) == N_CASES
    assert all(isinstance(row["screening_score"], int) for row in rows)
    assert all(0 <= row["screening_score"] <= 100 for row in rows)
    assert set(row["reference_outcome"] for row in rows) == {"positive", "negative"}
    evaluations = results["threshold_evaluations"]
    assert results["class_counts"] == {"positive": 27, "negative": 33}
    assert [item["threshold"] for item in evaluations] == list(THRESHOLDS)
    assert [
        (item["true_positive"], item["false_positive"], item["true_negative"], item["false_negative"])
        for item in evaluations
    ] == [
        (27, 33, 0, 0),
        (27, 10, 23, 0),
        (25, 2, 31, 2),
        (19, 0, 33, 8),
        (0, 0, 33, 27),
    ]
    assert all(
        item["true_positive"] + item["false_positive"] + item["true_negative"] + item["false_negative"] == N_CASES
        for item in evaluations
    )
    # At the two extreme teaching thresholds one prediction class is empty.
    assert evaluations[0]["negative_predictive_value"] is None
    assert evaluations[-1]["precision"] is None


def parse_args(argv=None):
    parser = argparse.ArgumentParser(
        description="Generate or read-only check the fictional Chapter 10 screening case."
    )
    modes = parser.add_mutually_exclusive_group(required=True)
    modes.add_argument("--check", action="store_true", help="compare generated bytes with files, without writing")
    modes.add_argument("--write", action="store_true", help="explicitly overwrite generated files")
    parser.add_argument("--output-dir", type=Path, help="override output directory (mainly for isolated tests)")
    return parser.parse_args(argv)


def main(argv=None):
    args = parse_args(argv)
    rows, results, artifacts = build_artifacts()
    validate_invariants(rows, results)
    root = Path(__file__).resolve().parents[1]
    output_dir = args.output_dir or root / "public" / "data"

    if args.check:
        mismatches = []
        for name, expected_bytes in artifacts.items():
            path = output_dir / name
            if not path.exists() or path.read_bytes() != expected_bytes:
                mismatches.append(str(path))
        if mismatches:
            raise SystemExit("CHECK FAILED: generated bytes differ: " + ", ".join(mismatches))
        print(f"CHECK PASSED: generated CSV/JSON bytes match; {len(rows)} fictional cases; {len(THRESHOLDS)} thresholds")
        return 0

    output_dir.mkdir(parents=True, exist_ok=True)
    for name, data in artifacts.items():
        (output_dir / name).write_bytes(data)
    print(json.dumps(results, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
