#!/usr/bin/env python3
"""
The Credit Note Gate — the seven checks a credit note must pass before it exists.
Part of "The Engine Room" on simranjaiswal.in.

    python3 engine.py                # reference run, seed 42, writes results.json
    python3 engine.py --auto-limit 2000 --manager-limit 25000 --dup-window 30 --freq 2 --goodwill-cap 0.5
    python3 engine.py --no-gate      # every request approved: shows the leak

Everything here is deterministic. The synthetic book and the request stream are generated from the
printed rules below with a tiny 32-bit PRNG (mulberry32) that the in-browser engine on the case page
implements bit-for-bit, so the page's reference run reproduces this file's results.json exactly.
No real customer data is involved.

Stdlib only.
"""
import argparse, json, math, os

# ----------------------------------------------------------------------------- deterministic randomness
class RNG:
    """mulberry32 — identical to the JavaScript port on the case page."""
    M = 0xFFFFFFFF
    def __init__(self, seed): self.a = seed & self.M
    def random(self):
        self.a = (self.a + 0x6D2B79F5) & self.M
        t = self.a
        t = ((t ^ (t >> 15)) * (1 | t)) & self.M
        t = ((t + (((t ^ (t >> 7)) * (61 | t)) & self.M)) & self.M) ^ t
        return ((t ^ (t >> 14)) & self.M) / 4294967296
    def normal(self, mu=0.0, sd=1.0):
        u = self.random() or 1e-12; v = self.random()
        return mu + sd * math.sqrt(-2 * math.log(u)) * math.cos(6.283185307179586 * v)

def rnd(x): return int(math.floor(x + 0.5))                 # Math.round for x >= 0
def r1(x): return math.floor(x * 10 + 0.5) / 10             # printed hours to 1 dp
def r2(x): return math.floor(x * 100 + 0.5) / 100           # shares and rates to 2 dp

# ----------------------------------------------------------------------------- the printed world
DAYS = 90                # one quarter
N_INV = 1500             # the invoice book the requests point at
N_CUST = 60
N_REQ = 500              # credit-note requests over the quarter
REQUESTERS = [("R01", "sales"), ("R02", "sales"), ("R03", "sales"), ("R04", "sales"), ("R05", "sales"), ("R06", "sales"),
              ("R07", "service"), ("R08", "service"), ("R09", "service"), ("R10", "finance")]
REASONS = ["PRICING_ERROR", "SHORT_SHIPPED", "QUALITY", "GOODWILL", "DUPLICATE_BILLING"]
REASON_P = {"PRICING_ERROR": 0.30, "SHORT_SHIPPED": 0.25, "QUALITY": 0.20, "GOODWILL": 0.15, "DUPLICATE_BILLING": 0.10}
GRANT_P = {"PRICING_ERROR": 0.9, "SHORT_SHIPPED": 0.9, "QUALITY": 0.8, "DUPLICATE_BILLING": 0.95, "GOODWILL": 0.5}
SLA_P = 0.85             # the approver answers within 24 h with this probability, else the request escalates
P_REF = 0.92             # requests that reference an invoice
P_REASON = 0.78          # requests that carry a reason code
P_DUP = 0.06             # exact duplicates of an earlier request
P_PART = 0.08            # invoices already part-credited when the quarter opens (remaining = 70%)
# THE LEAK: goodwill is 15% of reasons overall, but three times as common from sales as from anyone else.
#   0.15 = 0.6 * 3g + 0.4 * g  ->  g = 0.15 / 2.2
GW_OTHER = 0.15 / 2.2
GW_SALES = 3 * GW_OTHER

def reason_weights(role):
    """Cumulative reason weights for a requester's role, in the fixed order of REASONS."""
    gw = GW_SALES if role == "sales" else GW_OTHER
    scale = (1 - gw) / 0.85                              # the four other codes share what goodwill leaves
    out = []
    for r in REASONS: out.append(gw if r == "GOODWILL" else REASON_P[r] * scale)
    return out

def pick_reason(rng, role):
    w = reason_weights(role); r = rng.random(); acc = 0.0
    for i in range(len(REASONS)):
        acc += w[i]
        if r < acc: return REASONS[i]
    return REASONS[-1]

# ----------------------------------------------------------------------------- the synthetic book + stream
def build_world(seed):
    rng = RNG(seed)
    invoices = []
    for i in range(N_INV):
        cust = int(rng.random() * N_CUST)
        amount = rnd(math.exp(rng.normal(10.2, 0.8)))
        amount = min(300000, max(3000, amount))
        day = int(rng.random() * DAYS)
        owner = int(rng.random() * len(REQUESTERS))      # whose book the invoice sits in (drives the goodwill cap)
        remaining = amount; part = False
        if rng.random() < P_PART: remaining = int(amount * 0.7); part = True
        invoices.append({"id": "INV-%04d" % (i + 1), "cust": cust, "amount": amount, "day": day, "owner": owner, "remaining": remaining, "part": part})
    requests = []
    for k in range(N_REQ):
        dup = k > 0 and rng.random() < P_DUP
        if dup:                                           # the same ask again, within ten days
            base = requests[int(rng.random() * k)]
            requester = int(rng.random() * len(REQUESTERS))
            day = min(DAYS - 1, base["day"] + int(rng.random() * 10))
            requests.append({"k": k, "id": "CN-%04d" % (k + 1), "day": day, "requester": requester, "inv": base["inv"], "amount": base["amount"], "reason": base["reason"], "dup_of": base["id"]})
            continue
        requester = int(rng.random() * len(REQUESTERS))
        role = REQUESTERS[requester][1]
        if rng.random() < P_REF:
            inv = int(rng.random() * N_INV)
            I = invoices[inv]
            day = I["day"] + int(rng.random() * (DAYS - I["day"]))          # on or after the invoice day
            share = min(1.0, max(0.02, rng.normal(0.35, 0.3)))
            amount = int(I["amount"] * share)
        else:
            inv = None
            day = int(rng.random() * DAYS)
            amount = rnd(math.exp(rng.normal(9.5, 0.8)))
            amount = min(300000, max(1000, amount))
        reason = pick_reason(rng, role) if rng.random() < P_REASON else ""
        requests.append({"k": k, "id": "CN-%04d" % (k + 1), "day": day, "requester": requester, "inv": inv, "amount": amount, "reason": reason, "dup_of": None})
    return invoices, requests

# ----------------------------------------------------------------------------- the gate
def tier_of(amount, auto_limit, manager_limit):
    if amount <= auto_limit: return "AUTO"
    if amount <= manager_limit: return "MANAGER"
    return "FINANCE_HEAD"

def run_gate(seed, invoices, requests, auto_limit, manager_limit, dup_window, freq_n, cap, gate):
    """Requests are processed in day order (ties by arrival); every decision is final on the day it is made."""
    rng2 = RNG(seed + 1)                                  # the approvers, so every policy sees the same stream
    order = sorted(requests, key=lambda q: (q["day"], q["k"]))
    inv_value = [0] * len(REQUESTERS)                     # each requester's book: Σ invoice amounts in their name
    for I in invoices: inv_value[I["owner"]] += I["amount"]
    gw_credited = [0] * len(REQUESTERS)                   # goodwill already credited this quarter, per requester
    seen = []                                             # every processed request, for the duplicate and frequency guards
    log = []
    for Q in order:
        I = invoices[Q["inv"]] if Q["inv"] is not None else None
        Q["cust"] = I["cust"] if I else None
        # flags are computed for every request, gate or no gate, so the leak can be measured either way
        Q["f_no_invoice"] = I is None
        Q["f_over"] = (I is not None) and Q["amount"] > I["remaining"]
        dup = False
        if I is not None:
            for P in seen:
                if P["inv"] == Q["inv"] and abs(P["amount"] - Q["amount"]) <= 0.02 * P["amount"] and Q["day"] - P["day"] <= dup_window:
                    dup = True; break
        Q["f_dup"] = dup
        Q["tier"] = "NONE"; Q["hours"] = 0; Q["reached_tier"] = False; Q["code"] = ""
        if not gate:                                      # NO GATE: whatever is asked is credited
            Q["decision"] = "APPROVED"
            if I is not None: I["remaining"] -= Q["amount"]
        elif Q["f_no_invoice"]:
            Q["decision"] = "REFUSED"; Q["code"] = "NO_INVOICE"                       # G1
        elif Q["f_over"]:
            Q["decision"] = "REFUSED"; Q["code"] = "OVER_REMAINING"                   # G2
        elif Q["reason"] == "":
            Q["decision"] = "HELD"; Q["code"] = "NO_REASON"                           # G3 · back to the requester
        elif Q["f_dup"]:
            Q["decision"] = "REFUSED"; Q["code"] = "DUPLICATE"                        # G4
        else:
            n30 = 0                                                                   # G5 · credit notes this customer received in the last 30 days
            for P in seen:
                if P["cust"] == Q["cust"] and credited(P) and Q["day"] - P["day"] < 30: n30 += 1
            if n30 + 1 >= freq_n:
                Q["decision"] = "HELD"; Q["code"] = "FREQUENCY"
            else:
                Q["reached_tier"] = True
                Q["tier"] = tier_of(Q["amount"], auto_limit, manager_limit)           # G6 · who may say yes
                r = Q["requester"]
                if Q["reason"] == "GOODWILL" and gw_credited[r] + Q["amount"] > cap * inv_value[r]:
                    Q["decision"] = "HELD"; Q["code"] = "GOODWILL_CAP"                # G7 · over the requester's goodwill budget
                elif Q["tier"] == "AUTO":
                    Q["decision"] = "AUTO"
                else:                                                                 # the approver at that tier
                    answered = rng2.random() < SLA_P
                    Q["hours"] = 1 + int(rng2.random() * 24) if answered else 24 + int(rng2.random() * 48)
                    granted = rng2.random() < GRANT_P[Q["reason"]]
                    if answered: Q["decision"] = "APPROVED" if granted else "DECLINED"
                    else: Q["decision"] = "ESCALATED"; Q["code"] = "GRANTED" if granted else "DECLINED"
                if Q["decision"] == "AUTO" or Q["decision"] == "APPROVED" or (Q["decision"] == "ESCALATED" and Q["code"] == "GRANTED"):
                    I["remaining"] -= Q["amount"]                                     # the ledger moves; later requests see it
                    if Q["reason"] == "GOODWILL": gw_credited[r] += Q["amount"]
        seen.append(Q)
        if len(log) < 14 or Q["code"] in ("DUPLICATE", "GOODWILL_CAP") and len(log) < 22:
            log.append("%s · day %2d · %s %-7s · %s · %s · %-17s → %s%s" % (Q["id"], Q["day"], REQUESTERS[Q["requester"]][0], REQUESTERS[Q["requester"]][1], I["id"] if I else "no invoice", fmt(Q["amount"]), Q["reason"] or "(no reason)", Q["decision"], (" · " + Q["code"]) if Q["code"] else ""))
    return order, inv_value, gw_credited, log

def fmt(v):
    """₹ with Indian grouping (12,34,567) — the browser port formats the log the same way."""
    s = str(int(rnd(v)))
    if len(s) <= 3: return "₹" + s
    head, tail, groups = s[:-3], s[-3:], []
    while len(head) > 2: groups.insert(0, head[-2:]); head = head[:-2]
    if head: groups.insert(0, head)
    return "₹" + ",".join(groups) + "," + tail

# ----------------------------------------------------------------------------- the summary
HOLD_CODES = ["NO_REASON", "FREQUENCY", "GOODWILL_CAP"]
REFUSE_CODES = ["NO_INVOICE", "OVER_REMAINING", "DUPLICATE"]
BANDS = [("INSTANT", 0, 0), ("1–8 H", 1, 8), ("9–24 H", 9, 24), ("25–48 H", 25, 48), ("49–72 H", 49, 72)]

def credited(Q): return Q["decision"] in ("AUTO", "APPROVED") or (Q["decision"] == "ESCALATED" and Q["code"] == "GRANTED")
def prevented(Q): return not credited(Q)

def summarise(params, invoices, requests, inv_value, gw_credited, log):
    def n(fn): return sum(1 for q in requests if fn(q))
    def v(fn): return int(sum(q["amount"] for q in requests if fn(q)))
    def block(fn): return {"n": n(fn), "value": v(fn)}
    held = {}; refused = {}
    for c in HOLD_CODES: held[c] = block(lambda q, c=c: q["decision"] == "HELD" and q["code"] == c)
    for c in REFUSE_CODES: refused[c] = block(lambda q, c=c: q["decision"] == "REFUSED" and q["code"] == c)
    held["total"] = block(lambda q: q["decision"] == "HELD"); refused["total"] = block(lambda q: q["decision"] == "REFUSED")
    dec = {"auto": block(lambda q: q["decision"] == "AUTO"), "approved": block(lambda q: q["decision"] == "APPROVED"),
           "declined": block(lambda q: q["decision"] == "DECLINED"), "escalated": block(lambda q: q["decision"] == "ESCALATED"),
           "escalated_granted": block(lambda q: q["decision"] == "ESCALATED" and q["code"] == "GRANTED"),
           "escalated_declined": block(lambda q: q["decision"] == "ESCALATED" and q["code"] == "DECLINED"),
           "held": held, "refused": refused}
    dec["total"] = dec["auto"]["n"] + dec["approved"]["n"] + dec["declined"]["n"] + dec["escalated"]["n"] + held["total"]["n"] + refused["total"]["n"]
    by_reason = []
    for c in REASONS + [""]:
        rows = [q for q in requests if q["reason"] == c]
        cr = [q for q in rows if credited(q)]
        by_reason.append({"reason": c or "NO_REASON", "requests": len(rows), "requested": int(sum(q["amount"] for q in rows)),
                          "credited_n": len(cr), "credited": int(sum(q["amount"] for q in cr)),
                          "held_n": sum(1 for q in rows if q["decision"] == "HELD"), "held": int(sum(q["amount"] for q in rows if q["decision"] == "HELD")),
                          "refused_n": sum(1 for q in rows if q["decision"] == "REFUSED"), "refused": int(sum(q["amount"] for q in rows if q["decision"] == "REFUSED")),
                          "declined_n": sum(1 for q in rows if q["decision"] == "DECLINED" or (q["decision"] == "ESCALATED" and q["code"] == "DECLINED")),
                          "declined": int(sum(q["amount"] for q in rows if q["decision"] == "DECLINED" or (q["decision"] == "ESCALATED" and q["code"] == "DECLINED"))),
                          "approval_rate": r2(len(cr) / len(rows)) if rows else 0.0})
    by_req = []
    for r, R in enumerate(REQUESTERS):
        rows = [q for q in requests if q["requester"] == r]
        gw = [q for q in rows if q["reason"] == "GOODWILL"]
        by_req.append({"id": R[0], "role": R[1], "requests": len(rows), "goodwill": len(gw), "goodwill_share": r2(len(gw) / len(rows)) if rows else 0.0,
                       "requested": int(sum(q["amount"] for q in rows)), "credited": int(sum(q["amount"] for q in rows if credited(q))),
                       "held": int(sum(q["amount"] for q in rows if q["decision"] == "HELD")),
                       "refused": int(sum(q["amount"] for q in rows if q["decision"] == "REFUSED")),
                       "invoiced": inv_value[r], "goodwill_credited": gw_credited[r], "cap": int(params["goodwill_cap_pct"] / 100.0 * inv_value[r])})
    bands = []
    for name, lo, hi in BANDS: bands.append({"band": name, "n": sum(1 for q in requests if lo <= q["hours"] <= hi)})
    decided = [q for q in requests if q["hours"] > 0]
    ttd = {"bands": bands, "gate_instant": sum(1 for q in requests if q["hours"] == 0), "approver_decided": len(decided),
           "mean_hours": r1(sum(q["hours"] for q in decided) / len(decided)) if decided else 0.0, "max_hours": max([q["hours"] for q in decided] + [0])}
    tiers = {"AUTO": n(lambda q: q["tier"] == "AUTO"), "MANAGER": n(lambda q: q["tier"] == "MANAGER"), "FINANCE_HEAD": n(lambda q: q["tier"] == "FINANCE_HEAD")}
    over_credited = int(sum(-I["remaining"] for I in invoices if I["remaining"] < 0))
    leak = {"no_invoice": block(lambda q: q["f_no_invoice"]), "over_remaining": block(lambda q: q["f_over"]), "duplicate": block(lambda q: q["f_dup"]),
            "paid_without_invoice": v(lambda q: q["f_no_invoice"] and credited(q)), "paid_over_remaining": v(lambda q: q["f_over"] and credited(q)),
            "paid_duplicate": v(lambda q: q["f_dup"] and credited(q)), "over_credited": over_credited,
            "goodwill_credited": v(lambda q: q["reason"] == "GOODWILL" and credited(q))}
    sales_gw = sum(x["goodwill"] for x in by_req if x["role"] == "sales"); sales_n = sum(x["requests"] for x in by_req if x["role"] == "sales")
    other_gw = sum(x["goodwill"] for x in by_req if x["role"] != "sales"); other_n = sum(x["requests"] for x in by_req if x["role"] != "sales")
    return {
        "engine": "credit-note-gate", "seed": params["seed"], "params": params,
        "world": {"invoices": N_INV, "customers": N_CUST, "requesters": len(REQUESTERS), "requests": N_REQ, "days": DAYS,
                  "invoiced_value": int(sum(I["amount"] for I in invoices)), "part_credited": sum(1 for I in invoices if I["part"]),
                  "with_invoice": n(lambda q: q["inv"] is not None), "with_reason": n(lambda q: q["reason"] != ""), "duplicates_injected": n(lambda q: q["dup_of"] is not None),
                  "reason_p": REASON_P, "goodwill_p_sales": r2(GW_SALES), "goodwill_p_other": r2(GW_OTHER), "grant_p": GRANT_P, "sla_p": SLA_P,
                  "requested_value": v(lambda q: True)},
        "decisions": dec, "tiers": tiers,
        "credited": v(credited), "prevented": v(prevented),
        "leak": leak, "by_reason": by_reason, "by_requester": by_req,
        "goodwill_share_sales": r2(sales_gw / sales_n) if sales_n else 0.0, "goodwill_share_other": r2(other_gw / other_n) if other_n else 0.0,
        "time_to_decision": ttd, "log": log,
    }

def run(seed=42, auto_limit=5000, manager_limit=50000, dup_window=10, freq=3, goodwill_cap=1.0, gate=True):
    params = {"seed": seed, "auto_limit": auto_limit, "manager_limit": manager_limit, "dup_window_days": dup_window, "freq_threshold": freq, "goodwill_cap_pct": goodwill_cap, "gate": bool(gate)}
    invoices, requests = build_world(seed)
    order, inv_value, gw_credited, log = run_gate(seed, invoices, requests, auto_limit, manager_limit, dup_window, freq, goodwill_cap / 100.0, gate)
    return summarise(params, invoices, order, inv_value, gw_credited, log)

if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--seed", type=int, default=42)
    ap.add_argument("--auto-limit", type=int, default=5000, help="AUTO-approve at or below this amount (₹)")
    ap.add_argument("--manager-limit", type=int, default=50000, help="MANAGER up to this amount (₹); above it FINANCE_HEAD")
    ap.add_argument("--dup-window", type=int, default=10, help="duplicate guard window in days")
    ap.add_argument("--freq", type=int, default=3, help="the customer's Nth credit in 30 days is held")
    ap.add_argument("--goodwill-cap", type=float, default=1.0, help="a requester's goodwill credits may not exceed this %% of their invoiced value")
    ap.add_argument("--no-gate", action="store_true", help="approve everything (shows the leak)")
    ap.add_argument("--out", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "results.json"))
    a = ap.parse_args()
    res = run(a.seed, a.auto_limit, a.manager_limit, a.dup_window, a.freq, a.goodwill_cap, not a.no_gate)
    with open(a.out, "w") as f: json.dump(res, f, indent=1)
    w = res["world"]; d = res["decisions"]
    print("world: %d invoices (%s) · %d customers · %d requests over %d days · %d with an invoice · %d with a reason · %d injected duplicates · requested %s" % (w["invoices"], fmt(w["invoiced_value"]), w["customers"], w["requests"], w["days"], w["with_invoice"], w["with_reason"], w["duplicates_injected"], fmt(w["requested_value"])))
    print("decisions: auto %d · approved %d · declined %d · escalated %d (%d granted / %d declined) · held %d (no reason %d, frequency %d, goodwill cap %d) · refused %d (no invoice %d, over remaining %d, duplicate %d) · total %d" % (
        d["auto"]["n"], d["approved"]["n"], d["declined"]["n"], d["escalated"]["n"], d["escalated_granted"]["n"], d["escalated_declined"]["n"],
        d["held"]["total"]["n"], d["held"]["NO_REASON"]["n"], d["held"]["FREQUENCY"]["n"], d["held"]["GOODWILL_CAP"]["n"],
        d["refused"]["total"]["n"], d["refused"]["NO_INVOICE"]["n"], d["refused"]["OVER_REMAINING"]["n"], d["refused"]["DUPLICATE"]["n"], d["total"]))
    print("money: credited %s · prevented %s (refused %s + declined %s + held %s)" % (fmt(res["credited"]), fmt(res["prevented"]), fmt(d["refused"]["total"]["value"]), fmt(d["declined"]["value"] + d["escalated_declined"]["value"]), fmt(d["held"]["total"]["value"])))
    print("tiers: auto %d · manager %d · finance head %d · mean %.1f h to an approver's answer · goodwill share sales %.2f vs others %.2f" % (res["tiers"]["AUTO"], res["tiers"]["MANAGER"], res["tiers"]["FINANCE_HEAD"], res["time_to_decision"]["mean_hours"], res["goodwill_share_sales"], res["goodwill_share_other"]))
    for r in res["by_reason"]: print("  %-18s %3d requests · %s asked · %s credited · approval rate %.2f" % (r["reason"], r["requests"], fmt(r["requested"]), fmt(r["credited"]), r["approval_rate"]))
    if not res["params"]["gate"]: print("leak: paid without invoice %s · paid over remaining %s · paid duplicates %s · credited beyond the invoice %s" % (fmt(res["leak"]["paid_without_invoice"]), fmt(res["leak"]["paid_over_remaining"]), fmt(res["leak"]["paid_duplicate"]), fmt(res["leak"]["over_credited"])))
    print("wrote", a.out)
