#!/usr/bin/env python3
"""
The Chase List — the morning list. A ranking of open invoices turned into a day's work for four
collectors, run for ten working days and raced against two naive lists. Part of "The Engine Room"
on simranjaiswal.in.

    python3 engine.py                                   # reference run, seed 42, writes results.json
    python3 engine.py --seed 7 --capacity 24 --cooldown 5 --courtesy 250000

Everything here is deterministic. The synthetic book is 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)

N_INV = 900            # open invoices on the morning of day 0
N_CUST = 120
DAYS = 10              # working days simulated (day 0 .. day 9)
SERIES_DAYS = 15       # cash landed is tracked to day 14; the headline is cash landed by day 10
N_COLL = 4             # collectors; collector 0 is the senior
COLLECTORS = ["SENIOR", "COLLECTOR 2", "COLLECTOR 3", "COLLECTOR 4"]
COURTESY_WINDOW = 3    # not-yet-due invoices enter only if due within 3 days (and big enough)
PAY_WINDOW = 5         # a contact that resolves pays on day t + 1 .. t + 5
PROMISE_DAYS = 5       # a promise-to-pay falls due 5 days after the contact
PROMISE_P = 0.3        # share of the non-paying remainder that promises
BALANCE_TOL = 0.15     # each collector's ₹ within 15% of the mean
POLICIES = ["CHASE_LIST", "BIGGEST_FIRST", "OLDEST_FIRST"]

def rnd(x): return int(math.floor(x + 0.5))                      # Math.round for positive x
def r3(x): return math.floor(x * 1000 + 0.5) / 1000
def r2(x): return math.floor(x * 100 + 0.5) / 100
def r1(x): return math.floor(x * 10 + 0.5) / 10

# ----------------------------------------------------------------------------- the model, printed
def p_slip(late_rate, dpd):
    """P(this invoice slips again) = clipped logistic of 0.9 x late_rate + 0.015 x days_past_due - 0.5."""
    z = 0.9 * late_rate + 0.015 * dpd - 0.5
    return min(0.98, max(0.02, 1.0 / (1.0 + math.exp(-z))))

# ----------------------------------------------------------------------------- the synthetic book
def build_book(seed):
    """120 customers, 900 open invoices, as the morning of day 0 finds them."""
    rng = RNG(seed)
    customers = []
    for c in range(N_CUST):
        late_rate = math.sqrt(rng.random()) * 0.8                # prior late rate, beta-ish, 0..0.8
        priority = rng.random() < 0.10                            # a priority account: the senior calls
        after10 = rng.random() < 0.20                             # "do not call before 10:00"
        last_contact = int(rng.random() * 20)                     # days since we last spoke, at day 0
        promise_due = None
        if rng.random() < 0.20:                                   # an open promise to pay ...
            promise_due = int(rng.random() * 7) - 3               # ... due between day -3 and day 3
        customers.append({"id": "C%03d" % (c + 1), "late_rate": late_rate, "priority": priority, "after10": after10,
                          "last_contact_day": -last_contact, "promise_due": promise_due})
    invoices = []
    for i in range(N_INV):
        cust = int(rng.random() * N_CUST)
        amount = rnd(math.exp(rng.normal(10.3, 0.8)))
        amount = min(500000, max(5000, amount))
        dpd = int(rng.random() * 90) - 10                         # negative = not yet due
        invoices.append({"id": "INV-%04d" % (i + 1), "cust": cust, "amount": amount, "dpd0": dpd})
    return customers, invoices

# ----------------------------------------------------------------------------- the engine
def simulate(policy, customers, invoices, seed, cap, cooldown, courtesy):
    """Ten mornings. Build the list, split it across the collectors, make the contacts, land the cash.
    One rng draw per contact, in assignment order; a second draw (the landing day) only when it pays."""
    rng = RNG(seed + 1)
    cust = [dict(c) for c in customers]
    inv = [dict(v, pay_day=None) for v in invoices]
    landed = [0] * SERIES_DAYS
    contacts = 0; paid_n = 0; promises_made = 0; promise_follow = 0; promise_kept = 0
    rollover = []
    coll = [{"name": COLLECTORS[k], "contacts": 0, "value": 0, "priority": 0, "after10": 0} for k in range(N_COLL)]
    log = []; day1 = None
    for t in range(DAYS):
        # 1. who is eligible this morning: overdue, or a courtesy call (due within 3 days, above the threshold)
        elig = []
        for v in inv:
            if v["pay_day"] is not None: continue
            d = v["dpd0"] + t
            if d > 0 or (d >= -COURTESY_WINDOW and v["amount"] > courtesy):
                elig.append(v)
        # 2. the list: one line per customer (CHASE_LIST) or one line per invoice (the naive lists)
        lines = []
        if policy == "CHASE_LIST":
            by_c = {}
            for v in elig:
                c = cust[v["cust"]]
                e = by_c.get(v["cust"])
                if e is None:
                    e = {"cust": v["cust"], "inv": [], "value": 0, "score": 0.0, "wsum": 0.0, "dpd": -999,
                         "promise": c["promise_due"] is not None and c["promise_due"] <= t, "priority": c["priority"], "after10": c["after10"]}
                    by_c[v["cust"]] = e; lines.append(e)
                ps = p_slip(c["late_rate"], v["dpd0"] + t)
                e["inv"].append(v); e["value"] += v["amount"]; e["score"] += v["amount"] * ps; e["wsum"] += v["amount"] * ps
                e["dpd"] = max(e["dpd"], v["dpd0"] + t)
            for e in lines: e["pslip"] = e["wsum"] / e["value"]
            before = len(lines)
            lines = [e for e in lines if e["promise"] or (t - cust[e["cust"]]["last_contact_day"]) >= cooldown]   # cool-down
            lines.sort(key=lambda e: (0 if e["promise"] else 1, -e["score"], cust[e["cust"]]["id"]))
        else:
            for v in elig:
                c = cust[v["cust"]]; ps = p_slip(c["late_rate"], v["dpd0"] + t)
                lines.append({"cust": v["cust"], "inv": [v], "value": v["amount"], "score": v["amount"] * ps, "pslip": ps, "dpd": v["dpd0"] + t,
                              "promise": c["promise_due"] is not None and c["promise_due"] <= t, "priority": c["priority"], "after10": c["after10"]})
            before = len(lines)
            if policy == "BIGGEST_FIRST": lines.sort(key=lambda e: (-e["value"], e["inv"][0]["id"]))
            else: lines.sort(key=lambda e: (-e["dpd"], -e["value"], e["inv"][0]["id"]))
        # 3. capacity: who calls whom today
        assigned = []; remaining = [cap] * N_COLL; running = [0] * N_COLL
        if policy == "CHASE_LIST":
            pool = []
            for e in lines:                                       # priority accounts to the senior first
                if e["priority"] and remaining[0] > 0:
                    e["coll"] = 0; remaining[0] -= 1; running[0] += e["value"]; assigned.append(e)
                else:
                    pool.append(e)
            for e in pool:                                        # then greedy: the collector with the lowest running ₹
                k = -1
                for j in range(N_COLL):
                    if remaining[j] > 0 and (k < 0 or running[j] < running[k]): k = j
                if k < 0: e["coll"] = None; continue
                e["coll"] = k; remaining[k] -= 1; running[k] += e["value"]; assigned.append(e)
        else:
            for i, e in enumerate(lines):                         # plain round-robin, no balancing
                if i < cap * N_COLL: e["coll"] = i % N_COLL; running[i % N_COLL] += e["value"]; assigned.append(e)
                else: e["coll"] = None
        rolled = len(lines) - len(assigned)
        rollover.append(rolled)
        if t == 0:
            day1 = {"open": len(inv), "eligible": len(elig), "lines": before, "after_cooldown": len(lines), "assigned": len(assigned), "rolled": rolled,
                    "promise_lines": sum(1 for e in lines if e["promise"]), "top": [row(e, cust, t) for e in lines[:12]]}
        # 4. the contacts, in assignment order
        paid_today = 0; value_today = 0; prom_today = 0
        for e in assigned:
            c = cust[e["cust"]]; k = e["coll"]
            contacts += 1; coll[k]["contacts"] += 1; coll[k]["value"] += e["value"]
            if e["priority"]: coll[k]["priority"] += 1
            if e["after10"]: coll[k]["after10"] += 1
            follow = c["promise_due"] is not None and c["promise_due"] <= t
            if follow: promise_follow += 1
            p_pay = 0.35 + 0.4 * (1 - e["pslip"])                 # printed: P(pay within 5 days)
            u = rng.random()
            if u < p_pay:
                land = t + 1 + int(rng.random() * PAY_WINDOW)
                for v in e["inv"]:
                    v["pay_day"] = land; landed[land] += v["amount"]; paid_n += 1; paid_today += 1; value_today += v["amount"]
                if follow: promise_kept += 1
                c["promise_due"] = None
            elif u < p_pay + PROMISE_P * (1 - p_pay):
                c["promise_due"] = t + PROMISE_DAYS; promises_made += 1; prom_today += 1
            else:
                c["promise_due"] = None                           # a follow-up that neither pays nor re-promises is broken
            c["last_contact_day"] = t
        log.append("DAY %d · %d lines · %d contacted (senior %d) · %d rolled · %d invoices worth %d scheduled · %d promises · landed today %d"
                   % (t + 1, len(lines), len(assigned), coll[0]["contacts"], rolled, paid_today, value_today, prom_today, landed[t]))
    cum = []; s = 0
    for d in range(SERIES_DAYS): s += landed[d]; cum.append(s)
    total = sum(x["value"] for x in coll); mean = total / N_COLL
    for x in coll: x["dev_pct"] = r1((x["value"] / mean - 1) * 100) if mean > 0 else 0.0
    balance_ok = all(abs(x["value"] / mean - 1) <= BALANCE_TOL for x in coll) if mean > 0 else False
    cash10 = cum[10]
    return {"cash_by_day": cum, "cash_day10": cash10, "cash_total": cum[SERIES_DAYS - 1], "contacts": contacts,
            "contacts_per_lakh": r2(contacts / (cash10 / 1e5)) if cash10 > 0 else 0.0,
            "promises_made": promises_made, "promise_followups": promise_follow, "promise_kept": promise_kept,
            "rollover_by_day": rollover, "invoices_paid": paid_n, "invoices_open": len(inv) - paid_n,
            "collectors": coll, "balance_mean": rnd(mean), "balance_ok": balance_ok, "day1": day1, "log": log}

def row(e, cust, t):
    c = cust[e["cust"]]
    if e["promise"]: why = "promise follow-up, %d d past due" % e["dpd"]
    elif e["priority"]: why = "priority account, %d d past due" % e["dpd"]
    elif e["dpd"] <= 0: why = "courtesy call, due in %d d" % (-e["dpd"])
    else: why = "%d d past due" % e["dpd"]
    if c["after10"]: why += ", after 10:00"
    return {"cust": c["id"], "n": len(e["inv"]), "value": e["value"], "pslip": r3(e["pslip"]), "score": rnd(e["score"]), "dpd": e["dpd"],
            "why": why, "collector": COLLECTORS[e["coll"]] if e.get("coll") is not None else "tomorrow"}

def run(seed=42, capacity=18, cooldown=3, courtesy=100000):
    params = {"seed": seed, "sim_seed": seed + 1, "capacity_per_collector": capacity, "collectors": N_COLL, "cooldown_days": cooldown,
              "courtesy_threshold": courtesy, "courtesy_window_days": COURTESY_WINDOW, "pay_window_days": PAY_WINDOW,
              "promise_days": PROMISE_DAYS, "promise_p": PROMISE_P, "balance_tol": BALANCE_TOL, "days": DAYS}
    customers, invoices = build_book(seed)
    policies = {p: simulate(p, customers, invoices, seed, capacity, cooldown, courtesy) for p in POLICIES}
    CL, BF, OF = policies["CHASE_LIST"], policies["BIGGEST_FIRST"], policies["OLDEST_FIRST"]
    return {
        "engine": "chase-list", "seed": seed, "params": params,
        "book": {"customers": len(customers), "invoices": len(invoices), "value": sum(v["amount"] for v in invoices),
                 "overdue_day0": sum(1 for v in invoices if v["dpd0"] > 0), "priority_accounts": sum(1 for c in customers if c["priority"]),
                 "open_promises": sum(1 for c in customers if c["promise_due"] is not None), "after10": sum(1 for c in customers if c["after10"])},
        "policies": policies,
        "comparison": {"extra_cash_vs_biggest": CL["cash_day10"] - BF["cash_day10"], "extra_cash_vs_oldest": CL["cash_day10"] - OF["cash_day10"],
                       "contacts_saved_vs_biggest": BF["contacts"] - CL["contacts"]},
    }

def fmt(v): return "₹{:,}".format(int(round(v)))

if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--seed", type=int, default=42); ap.add_argument("--capacity", type=int, default=18)
    ap.add_argument("--cooldown", type=int, default=3); ap.add_argument("--courtesy", type=int, default=100000)
    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.capacity, a.cooldown, a.courtesy)
    with open(a.out, "w") as f: json.dump(res, f, indent=1)
    b = res["book"]
    print("book: %d invoices, %d customers, %s open, %d overdue on day 0, %d priority accounts, %d open promises" % (b["invoices"], b["customers"], fmt(b["value"]), b["overdue_day0"], b["priority_accounts"], b["open_promises"]))
    for p in POLICIES:
        P = res["policies"][p]
        print("  %-14s cash by day 10 %s · contacts %d (%.2f per lakh) · paid %d invoices · promises kept %d/%d · balance %s" % (p, fmt(P["cash_day10"]), P["contacts"], P["contacts_per_lakh"], P["invoices_paid"], P["promise_kept"], P["promise_followups"], "ok" if P["balance_ok"] else "OFF"))
    c = res["comparison"]
    print("extra cash by day 10 vs BIGGEST FIRST %s · vs OLDEST FIRST %s · contacts saved %d" % (fmt(c["extra_cash_vs_biggest"]), fmt(c["extra_cash_vs_oldest"]), c["contacts_saved_vs_biggest"]))
    print("wrote", a.out)
