#!/usr/bin/env python3
"""Independently check this article's fixed two-winner settlement study.

Python 3.10+, standard library only. Run beside the study files, or pass
--directory PATH. This script does not import or execute generate.py.
It checks arithmetic and file consistency, not cards, rules, or staff rulings.
"""

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


FIELDS = ["button", "unit", "pot", "amount", "first", "base", "remainder", "award_A", "award_B"]
PROFILE = "TDA 2024 v1.0; two tied winners in single-board Holdem"


def require(condition, message):
    if not condition:
        raise ValueError(message)


def integer(value, positive=True):
    return type(value) is int and (value > 0 if positive else value >= 0)


def same(actual, expected, label):
    # JSON encoding catches bool-versus-int and extra-field discrepancies.
    require(json.dumps(actual, sort_keys=True) == json.dumps(expected, sort_keys=True), label)


def expected_result(data):
    require(type(data) is dict, "inputs must be an object")
    same(sorted(data), sorted(["schema", "profile", "seats", "contributions", "folded", "main_eligible", "pots", "buttons", "units"]), "input fields")
    require(type(data["schema"]) is int and data["schema"] == 1, "schema")
    require(data["profile"] == PROFILE, "unsupported rule profile")
    seats = data["seats"]
    require(type(seats) is list and len(seats) == 5 and all(type(s) is str for s in seats), "five named seats required")
    require(len(set(seats)) == len(seats) and set(seats) == set("ABCDE"), "unique study seats")
    same(seats, list("ABCDE"), "fixed clockwise study order")
    contributions = data["contributions"]
    require(type(contributions) is dict and set(contributions) == set(seats), "contribution seats")
    require(all(integer(value) for value in contributions.values()), "positive whole contributions required")
    require(max(contributions.values()) <= 100000, "study contribution limit")
    folded = data["folded"]
    require(type(folded) is list and all(type(s) is str for s in folded), "folded list")
    require(len(set(folded)) == len(folded) and set(folded) <= set(seats), "folded seats")
    buttons, units, pots = data["buttons"], data["units"], data["pots"]
    require(type(buttons) is list and all(type(s) is str for s in buttons), "buttons list")
    require(len(buttons) == len(set(buttons)) and set(buttons) == set(seats), "each button once")
    require(type(units) is list and units and all(integer(u) for u in units), "positive integer units")
    require(len(set(units)) == len(units), "duplicate units")
    require(type(pots) is list and len(pots) == 2, "exactly two established pots")
    for pot in pots:
        require(type(pot) is dict and set(pot) == {"id", "amount", "winners"}, "pot fields")
        require(integer(pot["amount"]), "positive pot amount")
        require(type(pot["winners"]) is list and len(pot["winners"]) == 2, "exactly two tied winners required")
        require(all(type(s) is str for s in pot["winners"]), "winner names")
        require(set(pot["winners"]) == {"A", "B"}, "study tied winners must be A and B")
        require(not (set(pot["winners"]) & set(folded)), "folded winner")
        require(all(pot["amount"] % unit == 0 for unit in units), "pot cannot be expressed in permitted units")
    same([p["id"] for p in pots], ["main", "side"], "pot order")
    same(data["main_eligible"], [s for s in seats if s not in folded], "main eligibility")

    # Independent construction: visit each face-value contribution height,
    # then merge adjacent slices whose live eligible players are identical.
    layers = []
    for height in range(1, max(contributions.values()) + 1):
        donors = [s for s in seats if contributions[s] >= height]
        require(len(donors) >= 2, "uncalled excess is outside this study")
        eligible = [s for s in donors if s not in folded]
        require(eligible, "pot has no live eligible hand")
        if layers and layers[-1]["eligible"] == eligible:
            layers[-1]["amount"] += len(donors)
        else:
            layers.append({"eligible": eligible, "amount": len(donors)})
    require(len(layers) == 2, "contributions do not create the two study pots")
    same(layers[0]["eligible"], data["main_eligible"], "constructed main eligibility")
    same(layers[1]["eligible"], ["A", "B"], "constructed side eligibility")
    same([p["amount"] for p in pots], [p["amount"] for p in layers], "constructed pot amounts")
    total = sum(contributions.values())
    require(total == sum(p["amount"] for p in pots), "contributions must reconcile")

    rows, totals = [], []
    for button in buttons:
        split = seats.index(button) + 1
        clockwise = seats[split:] + seats[:split]
        for unit in units:
            combined = {"A": 0, "B": 0}
            for pot in pots:
                order = [s for s in clockwise if s in pot["winners"]]
                awards = {"A": 0, "B": 0}
                # Deal one legal denomination at a time, alternating winners.
                # This is independent of a quotient/remainder payout formula.
                for chip in range(pot["amount"] // unit):
                    awards[order[chip % 2]] += unit
                base = min(awards.values())
                remainder = max(awards.values()) - base
                require(sum(awards.values()) == pot["amount"], "per-pot conservation")
                require(remainder in (0, unit), "per-pot award difference")
                rows.append({"button": button, "unit": unit, "pot": pot["id"], "amount": pot["amount"], "first": order[0], "base": base, "remainder": remainder, "award_A": awards["A"], "award_B": awards["B"]})
                for player in combined:
                    combined[player] += awards[player]
            require(sum(combined.values()) == total, "whole-hand conservation")
            totals.append({"button": button, "unit": unit, **combined})
    return {"schema": 1, "main": layers[0]["amount"], "side": layers[1]["amount"], "total": total, "rows": rows, "totals": totals}


def expected_csv(rows):
    buffer = io.StringIO(newline="")
    writer = csv.DictWriter(buffer, fieldnames=FIELDS, lineterminator="\n")
    writer.writeheader()
    writer.writerows(rows)
    return buffer.getvalue().encode("utf-8")


def rejected(action, label):
    try:
        action()
    except (ValueError, TypeError, KeyError):
        return
    raise ValueError("negative control was accepted: " + label)


def controls(data, expected):
    count = 0
    mutations = [
        (lambda d: d["units"].append(True), "boolean denomination"),
        (lambda d: d["units"].append(0), "zero denomination"),
        (lambda d: d["units"].append(30), "non-dividing denomination"),
        (lambda d: d["pots"][0]["winners"].append("C"), "three tied winners"),
        (lambda d: d["pots"][0].update(winners=["A", "A"]), "duplicate winner"),
        (lambda d: d["folded"].append("A"), "folded winner"),
        (lambda d: d["contributions"].update(C=-100), "negative contribution"),
        (lambda d: d["pots"][0].update(amount=600), "unreconciled pot"),
        (lambda d: d["buttons"].append("E"), "duplicate button"),
        (lambda d: d.update(profile="unverified house rule"), "different profile"),
    ]
    for mutate, label in mutations:
        bad = copy.deepcopy(data)
        mutate(bad)
        rejected(lambda: expected_result(bad), label)
        count += 1
    for mutate, label in [
        (lambda d: d["rows"][0].update(award_A=200, award_B=300), "reversed priority"),
        (lambda d: d["totals"][0].update(A=700, B=700), "merged-pot awards"),
        (lambda d: d.update(schema=True), "boolean output schema"),
        (lambda d: d["rows"].reverse(), "reordered rows"),
    ]:
        bad = copy.deepcopy(expected)
        mutate(bad)
        rejected(lambda: same(bad, expected, label), label)
        count += 1
    return count


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--directory", type=Path, default=Path(__file__).resolve().parent)
    args = parser.parse_args()
    directory = args.directory.resolve()
    data = json.loads((directory / "inputs.json").read_text(encoding="utf-8"))
    expected = expected_result(data)
    # Literal oracles intentionally bind this checker to the published study.
    same(data["contributions"], {"A": 500, "B": 500, "C": 100, "D": 100, "E": 200}, "fixed contributions")
    same(data["buttons"], ["E", "A", "B", "C", "D"], "fixed button order")
    same(data["units"], [100, 50, 25], "fixed denomination controls")
    require(len(expected["rows"]) == 30 and len(expected["totals"]) == 15, "study row counts")
    index = {(r["button"], r["unit"]): (r["A"], r["B"]) for r in expected["totals"]}
    same(index[("E", 100)], (800, 600), "literal main fixture")
    same(index[("A", 100)], (600, 800), "literal changed-button fixture")
    for button in data["buttons"]:
        for unit in (50, 25):
            same(index[(button, unit)], (700, 700), "literal smaller-unit control")
    actual = json.loads((directory / "results.json").read_text(encoding="utf-8"))
    same(actual, expected, "results.json differs from independent calculation")
    actual_csv = (directory / "settlements.csv").read_bytes()
    require(actual_csv == expected_csv(expected["rows"]), "settlements.csv bytes differ (UTF-8, LF)")
    tampered_csv = actual_csv.replace(b"300", b"301", 1)
    require(tampered_csv != actual_csv, "CSV tamper control changed a value")
    rejected(lambda: require(tampered_csv == expected_csv(expected["rows"]), "tampered CSV"), "tampered CSV")
    negative_count = controls(data, expected) + 1
    print(f"PASS: 30 pot settlements, 15 totals, contribution layers, CSV bytes, 12 literal oracles, {negative_count} negative controls")


if __name__ == "__main__":
    try:
        main()
    except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as error:
        print("FAIL: " + str(error), file=sys.stderr)
        sys.exit(1)
