#!/usr/bin/env python3
"""Draft 2020-12, exact-state, and negative self-test validator."""
from __future__ import annotations
import copy, json, sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent


def fail(rule: str, cid: str, detail: str = "") -> None:
    raise ValueError(f"{cid}: [{rule}]" + (f" {detail}" if detail else ""))


def keyed(items, key):
    return {item[key]: item for item in items}


def assert_equal(rule, cid, actual, expected):
    if actual != expected:
        fail(rule, cid, f"expected {expected!r}, got {actual!r}")


def event_assertions(event):
    common = {"rollback", "provider_calls"}
    if event["type"] == "create_purchase":
        return common | {"canonical_purchase", "currency", "owner", "quantity", "amount", "tax", "jurisdiction", "registration"}
    if event["type"] == "payment_webhook":
        return common | {"canonical_purchase", "provider_event", "provider_object", "payload_hash", "currency", "owner", "quantity", "amount", "tax", "jurisdiction", "signature", "claim", "effect"}
    if event["type"] == "refund_webhook":
        return common | {"canonical_purchase", "provider_event", "provider_object", "payload_hash", "currency", "owner", "quantity", "amount", "tax", "jurisdiction", "signature", "claim", "effect", "cumulative_allocation"}
    if event["type"] == "operator_adjustment":
        return common | {"authorization", "payload_hash", "audit", "effect"}
    if event["type"] == "conversion_accept":
        return common | {"owner", "amount", "effect"}
    if event["type"] == "cycle_failed":
        return common | {"effect"}
    if event["type"] == "register_jurisdiction":
        return common | {"registration", "authorization"}
    raise ValueError(event["type"])


def validate_semantics(case):
    cid, initial, expected = case["caseId"], case["initial"], case["expected"]
    for collection, key in (("purchases","purchaseId"),("receipts","eventId"),("claims","objectId"),("cycles","cycleId"),("adjustments","adjustmentId"),("refundStates","purchaseId"),("operatorAuthorizations","authorizationId"),("ledgerBefore","key")):
        values=[x[key] for x in initial[collection]]
        if len(values)!=len(set(values)): fail("unique-initial",cid,collection)
    for collection, key in (("purchases","purchaseId"),("receipts","eventId"),("claims","objectId"),("cycles","cycleId"),("adjustments","adjustmentId"),("refundStates","purchaseId")):
        values=[x[key] for x in expected["state"][collection]]
        if len(values)!=len(set(values)): fail("unique-expected",cid,collection)
    if len({x["idempotencyKey"] for x in initial["adjustments"]}) != len(initial["adjustments"]): fail("adjustment-idempotency-unique",cid)
    if int(initial["evaluationAt"]) != int(initial["databaseNow"]): fail("authoritative-time",cid)

    regs={(r["jurisdiction"],r["version"]):r for r in initial["registrations"]}
    for p in initial["purchases"]+expected["state"]["purchases"]:
        if int(p["itemSubtotalCents"]) != int(p["quantity"])*int(p["unitAmountCents"]): fail("purchase-subtotal",cid)
        r=regs.get((p["jurisdiction"],p["registrationVersion"]))
        if not r: fail("registration-binding",cid)
        if r["authorizationSourceVersion"] != p["registrationAuthorizationVersion"]: fail("registration-auth-binding",cid)

    bindings={}
    for r in initial["receipts"]+expected["state"]["receipts"]:
        b=(r["payloadHash"],r["objectId"])
        if r["eventId"] in bindings and bindings[r["eventId"]]!=b: fail("receipt-binding",cid)
        bindings[r["eventId"]]=b
    receipt_objects={r["objectId"] for r in expected["state"]["receipts"]}
    purchase_ids={p["purchaseId"] for p in expected["state"]["purchases"]}
    for c in expected["state"]["claims"]:
        if c["purchaseId"] not in purchase_ids: fail("claim-purchase",cid)
        if c["objectId"] not in receipt_objects: fail("claim-receipt",cid)

    before=sum(int(x["delta"]) for x in initial["ledgerBefore"])
    appends=expected["ledgerAppends"]
    if len({x["key"] for x in appends})!=len(appends): fail("ledger-key-unique",cid)
    net=before+sum(int(x["delta"]) for x in appends)
    assert_equal("ledger-net",cid,int(expected["ledgerNet"]),net)
    assert_equal("spendable",cid,int(expected["spendableBalance"]),max(0,net))
    assert_equal("deficit",cid,int(expected["deficit"]),max(0,-net))
    if expected["outcome"] in {"rejected","noop","reconcile"} and appends: fail("rollback-ledger",cid)

    state_refunds=keyed(expected["state"]["refundStates"],"purchaseId")
    initial_refunds=keyed(initial["refundStates"],"purchaseId")
    for event in case["events"]:
        assert_equal("assertion-coverage",cid,set(expected["assertions"]),set().union(*(event_assertions(e) for e in case["events"])))
        if expected["providerCalls"] != "0": fail("provider-call-count",cid,"fixture events are inbound or persistence-only")
        if event["type"] in {"payment_webhook","refund_webhook"}:
            p=keyed(initial["purchases"],"purchaseId").get(event["purchaseId"])
            if expected["outcome"] in {"accepted","noop"} and not p: fail("webhook-purchase",cid)
            if p:
                for f in ("ownerId","quantity","currency","jurisdiction","taxAmountCents"):
                    if expected["outcome"] in {"accepted","noop"} and event[f]!=p[f]: fail("webhook-canonical-"+f,cid)
                if expected["outcome"] in {"accepted","noop"} and event["amountCents"]!=p["itemSubtotalCents"]: fail("webhook-canonical-amount",cid)
            if expected["outcome"]=="accepted" and not event["signatureVerified"]: fail("webhook-signature",cid)
        if event["type"]=="refund_webhook" and expected["outcome"]=="accepted":
            p=keyed(initial["purchases"],"purchaseId")[event["purchaseId"]]
            old=initial_refunds.get(event["purchaseId"],{"cumulativeRefundedItemSubtotalCents":"0","reversedCredits":"0"})
            old_c, old_r=int(old["cumulativeRefundedItemSubtotalCents"]),int(old["reversedCredits"])
            cumulative, subtotal, quantity=int(event["cumulativeRefundedItemSubtotalCents"]),int(p["itemSubtotalCents"]),int(p["quantity"])
            if cumulative<old_c: fail("refund-monotonicity",cid)
            if cumulative>subtotal: fail("refund-over-reversal",cid)
            if event["taxRefundedCents"]!="0" and cumulative==old_c: fail("refund-tax-only",cid)
            target=quantity if cumulative==subtotal else quantity*cumulative//subtotal
            if target<old_r or target>quantity: fail("refund-allocation-bounds",cid)
            delta=-(target-old_r)
            reversals=[x for x in appends if x["reason"] in {"payment_refund_reversal","payment_dispute_reversal"}]
            if len(reversals)!=1 or int(reversals[0]["delta"])!=delta: fail("refund-derived-delta",cid)
            s=state_refunds.get(event["purchaseId"])
            if not s or int(s["cumulativeRefundedItemSubtotalCents"])!=cumulative or int(s["reversedCredits"])!=target: fail("refund-exact-state",cid)
        if event["type"]=="create_purchase" and expected["outcome"]=="accepted":
            r=regs.get((event["jurisdiction"],event["registrationVersion"]))
            now=int(initial["evaluationAt"])
            if not r or not (int(r["validFrom"])<=now<int(r["expiresAt"])): fail("registration-window",cid)
            if r and (r["evaluatedAt"]!=initial["evaluationAt"] or r["databaseNow"]!=initial["databaseNow"]): fail("registration-evidence-time",cid)
        if event["type"]=="operator_adjustment":
            if event["delta"] in {"0", "-0"}:
                if expected["outcome"] != "rejected" or expected["auditOutcome"] != "malformed_rejected": fail("adjustment-malformed",cid)
            auth=keyed(initial["operatorAuthorizations"],"authorizationId").get(event["authorizationId"])
            valid=auth and auth["operatorId"]==event["authorizedBy"] and auth["version"]==event["authorizationVersion"] and auth["source"]=="operator_authorization_registry" and auth["sourceVersion"]==event["authorizationSourceVersion"] and int(auth["validFrom"])<=int(event["requestedAt"])<int(auth["expiresAt"]) and event["reasonCode"] in auth["scopes"]
            if expected["outcome"]=="accepted" and not valid: fail("adjustment-authorization",cid)
            same=[a for a in initial["adjustments"] if a["idempotencyKey"]==event["idempotencyKey"]]
            if same:
                same_hash=same[0]["payloadHash"]==event["payloadHash"]
                if same_hash and (expected["outcome"]!="noop" or expected["auditOutcome"]!="replay_same_hash_noop"): fail("adjustment-replay-same",cid)
                if not same_hash and (expected["outcome"]!="rejected" or expected["auditOutcome"]!="replay_different_hash_rejected"): fail("adjustment-replay-conflict",cid)

    winner=expected["winner"]
    if expected["outcome"]=="correlated":
        if not winner: fail("winner-required",cid)
        accepted=[a for a in winner["alternatives"] if a["outcome"]=="accepted"]
        if len(accepted)!=1 or winner["matchingWinner"]!=accepted[0]["label"]: fail("winner-exactly-one",cid)
        if any(a["outcome"]!="accepted" and a["label"]==winner["matchingWinner"] for a in winner["alternatives"]): fail("winner-not-rejected",cid)
        event=next((e for e in case["events"] if e.get("idempotencyKey")==winner["matchingWinner"]),None)
        if not event or accepted[0]["submissionId"]!=event["idempotencyKey"] or accepted[0]["cycleId"]!=event.get("cycleId") or accepted[0]["jobId"]!=winner["jobId"] or accepted[0]["effectKey"]!=winner["effectKey"]: fail("winner-correlation",cid)
        if winner["effectKey"] not in {x["key"] for x in appends}: fail("winner-effect",cid)
    elif winner is not None: fail("winner-unexpected",cid)


def negative_self_tests(cases):
    by = {c["caseId"]: c for c in cases}
    probes = [
      ("BL-007", "purchase-subtotal", lambda c: c["expected"]["state"]["purchases"][0].update(itemSubtotalCents="1")),
      ("BL-007", "ledger-net", lambda c: c["expected"].update(ledgerNet="999")),
      ("BL-007", "spendable", lambda c: c["expected"].update(spendableBalance="999")),
      ("BL-007", "deficit", lambda c: c["expected"].update(deficit="1")),
      ("BL-007", "assertion-coverage", lambda c: c["expected"].update(assertions=[])),
      ("BL-007", "provider-call-count", lambda c: c["expected"].update(providerCalls="1")),
      ("BL-007", "webhook-canonical-ownerId", lambda c: c["events"][0].update(ownerId="owner.other")),
      ("BL-007", "webhook-signature", lambda c: c["events"][0].update(signatureVerified=False)),
      ("BL-010", "refund-monotonicity", lambda c: (c["initial"].update(refundStates=[{"purchaseId":"purchase.p1","cumulativeRefundedItemSubtotalCents":"600","reversedCredits":"1"}]))),
      ("BL-010", "refund-over-reversal", lambda c: c["events"][0].update(cumulativeRefundedItemSubtotalCents="1501")),
      ("BL-010", "refund-tax-only", lambda c: (c["initial"].update(refundStates=[{"purchaseId":"purchase.p1","cumulativeRefundedItemSubtotalCents":"500","reversedCredits":"1"}]), c["events"][0].update(taxRefundedCents="1"))),
      ("BL-010", "refund-derived-delta", lambda c: (c["expected"]["ledgerAppends"][0].update(delta="-2"), c["expected"].update(ledgerNet="1",spendableBalance="1"))),
      ("BL-010", "refund-exact-state", lambda c: c["expected"]["state"]["refundStates"][0].update(reversedCredits="2")),
      ("BL-001", "registration-window", lambda c: c["initial"].update(evaluationAt="1000",databaseNow="1000")),
      ("BL-001", "registration-evidence-time", lambda c: c["initial"]["registrations"][0].update(evaluatedAt="201")),
      ("BL-018", "adjustment-authorization", lambda c: c["initial"].update(operatorAuthorizations=[])),
      ("BL-018", "adjustment-malformed", lambda c: (c["events"][0].update(delta="0"), c["expected"].update(outcome="accepted",auditOutcome="accepted"))),
      ("BL-020", "adjustment-replay-same", lambda c: c["expected"].update(outcome="accepted")),
      ("BL-025", "adjustment-replay-conflict", lambda c: c["expected"].update(auditOutcome="unauthorized_rejected")),
      ("BL-022", "winner-exactly-one", lambda c: c["expected"]["winner"].update(matchingWinner="request.b")),
      ("BL-022", "winner-correlation", lambda c: c["expected"]["winner"]["alternatives"][0].update(jobId="job.other")),
      ("BL-022", "winner-effect", lambda c: (c["expected"]["winner"].update(effectKey="ledger.other"), c["expected"]["winner"]["alternatives"][0].update(effectKey="ledger.other"))),
    ]
    for case_id, rule, mutate in probes:
        specimen=copy.deepcopy(by[case_id]); mutate(specimen)
        try: validate_semantics(specimen)
        except ValueError as e:
            if f"[{rule}]" not in str(e): fail("self-test-wrong-rule","SELFTEST",f"{rule}: {e}")
        else: fail("self-test-did-not-fire","SELFTEST",rule)


def main():
    try:
        from jsonschema import Draft202012Validator, FormatChecker
    except ImportError:
        print("BLOCKED: jsonschema dependency required",file=sys.stderr); return 2
    try:
        schema=json.loads((ROOT/"case.schema.json").read_text())
        cases=json.loads((ROOT/"cases.json").read_text())
        Draft202012Validator.check_schema(schema)
        v=Draft202012Validator(schema,format_checker=FormatChecker())
        for i,c in enumerate(cases):
            errors=sorted(v.iter_errors(c),key=lambda e:list(e.absolute_path))
            if errors: fail("schema",c.get("caseId",str(i)),"; ".join(e.message for e in errors))
            validate_semantics(c)
        negative_self_tests(cases)
    except (OSError,json.JSONDecodeError,ValueError) as e:
        print(f"FAILED: {e}",file=sys.stderr); return 1
    print(f"PASS: Draft 2020-12 + FormatChecker + semantic invariants + negative self-tests ({len(cases)} cases)")
    return 0

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