"""Six measurements on one epidemic, all deterministic (seeded RNG, integer
rounds, no wall clock).

  0. O(log n) rounds to reach everybody — true, and the least interesting
     thing here.
  1. One seed, three arrow directions, replayed over the identical contact
     schedule: the residual (nodes that still don't know) round by round.
  2. Rounds to reach 25/50/75/90/99/100% of the cluster.
  3. Where the messages actually go, charged to the coverage band the round
     started in.
  4. Why: push divides the residual by e every round, pull squares it.
  5. Boundary A — the same comparison against a coverage target below 100%.
  6. Boundary B — fanout, and lazy push against push-pull on both axes.

Run: `python3 demo.py`  (about a minute)
"""

import math
import statistics as st
import sys

from gossip import band_costs, callers, first_round_at, schedule, simulate

N = 1024
SEEDS = 200
BOUNDARY_SEEDS = 300
FANOUT_SEEDS = 100
MAX_R = 40
FRACS = (0.25, 0.5, 0.75, 0.9, 0.99, 1.0)
BANDS = ((0, .25), (.25, .5), (.5, .75), (.75, .9), (.9, .99), (.99, 1.0))
MODES = ("push", "pull", "pushpull")


def rule(title):
    print(f"\n=== {title} ===\n")
    sys.stdout.flush()


def run_all(n, seeds, fanout=1, max_r=MAX_R, modes=MODES):
    """Every mode replayed over one schedule per seed. Returns
    {mode: [curve, ...]}; asserts every run converged inside the cap."""
    out = {m: [] for m in modes}
    for s in range(seeds):
        sched = schedule(n, fanout, max_r, s)
        for m in modes:
            res = simulate(n, sched, mode=m)
            assert res["converged"], (n, m, s, res["rounds"])
            out[m].append(res["curve"])
    return out


# -- 0 -------------------------------------------------------------------


def scaling():
    rule(f"0. the claim everybody predicts correctly ({SEEDS} seeds, push, "
         f"fanout 1)")
    print("      n   r100(mean)   r100/log2(n)   log2 n + ln n   residue")
    for n in (8, 32, 128, 512, 1024):
        rounds = []
        for s in range(SEEDS):
            res = simulate(n, schedule(n, 1, MAX_R, s), mode="push")
            assert res["converged"]
            rounds.append(res["rounds"])
        mean = st.mean(rounds)
        demers = math.log2(n) + math.log(n)
        print(f"  {n:5d}   {mean:10.2f}   {mean / math.log2(n):12.3f}   "
              f"{demers:13.2f}   {mean - demers:7.2f}")
        sys.stdout.flush()
    print("\n  Flat. O(log n) is confirmed and there is no toy in it: the")
    print("  last two columns are Demers' exact push formula log2(n) +")
    print("  ln(n) + O(1), and the O(1) this round model adds to it.")


# -- 1 -------------------------------------------------------------------


def traces():
    rule("1. seed 0, one schedule, three directions: nodes still ignorant")
    sched = schedule(N, 1, MAX_R, 0)
    for mode in MODES:
        res = simulate(N, sched, mode=mode)
        left = [N - c for c in res["curve"]]
        print(f"  {mode:9s} " + " ".join(str(v) for v in left))
    print("\n  Push sits at ONE ignorant node for three consecutive rounds")
    print("  while 1023 nodes gossip. Pull goes 165 -> 22 -> 1 -> 0.")


# -- 2 -------------------------------------------------------------------


def coverage(curves):
    rule(f"2. rounds to reach a given fraction of {N} nodes ({SEEDS} seeds)")
    print("  mode     " + "".join(f"{int(f * 100):>8d}%" for f in FRACS))
    means = {}
    for mode in MODES:
        vals = [[first_round_at(c, N, f) for c in curves[mode]]
                for f in FRACS]
        means[mode] = [st.mean(v) for v in vals]
        print(f"  {mode:9s}" + "".join(f"{m:9.2f}" for m in means[mode]))
    ratio = [a / b for a, b in zip(means["push"], means["pull"])]
    print("  push/pull" + "".join(f"{r:9.3f}" for r in ratio))
    half, whole = means["push"][1], means["push"][5]
    print(f"\n  push reaches half the cluster in {half:.2f} rounds and needs")
    print(f"  {whole - half:.2f} MORE for the last node — "
          f"{100 * (whole - half) / whole:.1f}% of the wall clock spent")
    print("  after half the cluster already knows.")
    return means


# -- 3 -------------------------------------------------------------------


def cost(curves):
    rule(f"3. where the {SEEDS}-seed message budget goes (lazy push, "
         f"fanout 1)")
    calls, gained = band_costs(curves["push"], N, 1, BANDS)
    total = sum(calls.values())
    print(f"  {'band':>12s} {'calls':>9s} {'% of all':>9s} {'nodes':>8s} "
          f"{'calls/node':>11s}")
    for b in BANDS:
        label = f"{int(b[0] * 100)}-{int(b[1] * 100)}%"
        print(f"  {label:>12s} {calls[b] / SEEDS:9.1f} "
              f"{100 * calls[b] / total:8.1f}% {gained[b] / SEEDS:8.2f} "
              f"{calls[b] / gained[b]:11.1f}")
    print(f"  {'TOTAL':>12s} {total / SEEDS:9.1f}   100.0% "
          f"{sum(gained.values()) / SEEDS:8.2f}")
    late = calls[(.9, .99)] + calls[(.99, 1.0)]
    late_n = (gained[(.9, .99)] + gained[(.99, 1.0)]) / SEEDS
    first = calls[(0, .25)] / gained[(0, .25)]
    last = calls[(.99, 1.0)] / gained[(.99, 1.0)]
    print(f"\n  rounds that began at >=90% coverage burn "
          f"{100 * late / total:.1f}% of all messages")
    print(f"  to inform {late_n:.1f} of {N - 1} nodes "
          f"({100 * late_n / (N - 1):.1f}% of the cluster).")
    print(f"  Last 1% of nodes: {last:.1f} calls each. First 25%: "
          f"{first:.1f}. That is {last / first:.0f}x.")


# -- 4 -------------------------------------------------------------------


def why(curves):
    rule("4. why: two different decay laws for the residual")
    miss = 1 - 1 / (N - 1)
    stuck = miss ** (N - 1)
    print(f"  One node of {N} is left. The other {N - 1} each push to a "
          f"uniform peer:")
    print(f"    P(a given call misses it) = 1 - 1/{N - 1} = {miss:.6f}")
    print(f"    P(all {N - 1} miss)        = {miss:.6f}^{N - 1} = "
          f"{stuck:.4f}   (1/e = {1 / math.e:.4f})")
    print(f"    expected rounds stuck at 1 = 1/{1 - stuck:.4f} = "
          f"{1 / (1 - stuck):.3f}   (given it reaches residual 1)")
    for mode in MODES:
        occ = [sum(1 for v in c[:-1] if N - v == 1) for c in curves[mode]]
        hit = [o for o in occ if o]
        print(f"    measured {mode:9s} {len(hit):3d}/{SEEDS} runs pass "
              f"through residual 1, mean {st.mean(hit):.3f} rounds there")
    print("\n  Under pull that last node places its own call and hits a")
    print(f"  knower with probability {N - 1}/{N - 1} = 1.000: one round,")
    print("  every time, which is exactly what the pull row measures.")
    print("\n  Pooled residual transitions s -> s', bucketed by s:")
    head = ("s", "push s'", "s'/s", "pull s'", "pred s*s/n")
    print("\n  %12s %10s %8s %10s %12s" % head)
    for lo, hi in ((4, 8), (8, 16), (16, 32), (32, 64), (64, 128),
                   (128, 256), (256, 512)):
        cell = {}
        for mode in ("push", "pull"):
            cell[mode] = [(N - c[r], N - c[r + 1]) for c in curves[mode]
                          for r in range(len(c) - 1) if lo <= N - c[r] < hi]
        pa = st.mean(a for a, _ in cell["push"])
        pb = st.mean(b for _, b in cell["push"])
        qa = st.mean(a for a, _ in cell["pull"])
        qb = st.mean(b for _, b in cell["pull"])
        print(f"  [{lo:4d},{hi:5d}) {pb:10.2f} {pb / pa:8.3f} {qb:10.2f} "
              f"{qa * qa / N:12.2f}")
    print(f"\n  push divides the residual by a constant (1/e = "
          f"{1 / math.e:.3f}): ln(n) rounds.")
    print("  pull squares it (s' tracks s*s/n): log log n rounds.")


# -- 5 -------------------------------------------------------------------


def boundary_coverage():
    rule(f"5. boundary A: push/pull, by coverage target "
         f"({BOUNDARY_SEEDS} seeds)")
    print("      n" + "".join(f"{int(f * 100):>8d}%" for f in FRACS))
    for n in (8, 32, 128, 1024):
        curves = run_all(n, BOUNDARY_SEEDS, modes=("push", "pull"))
        row = []
        for f in FRACS:
            a = st.mean(first_round_at(c, n, f) for c in curves["push"])
            b = st.mean(first_round_at(c, n, f) for c in curves["pull"])
            row.append(a / b)
        print(f"  {n:5d}" + "".join(f"{r:9.3f}" for r in row))
        sys.stdout.flush()
    print("\n  At 50% coverage push is FASTER at every size (0.852-0.977).")
    print("  At 75% the two are within 2.2% either way (0.991-1.021) and")
    print("  which side of 1.0 the crossing lands on flips with n. Only at")
    print("  100% does pull win clearly. If the SLO is 90% of nodes, the")
    print("  direction of the arrow is worth 6-19%; at 50% it is negative.")


# -- 6 -------------------------------------------------------------------


def boundary_fanout():
    rule(f"6. boundary B: fanout, honest accounting ({FANOUT_SEEDS} seeds)")
    print(f"  {'mode':10s} {'lazy':6s} {'fanout':>7s} {'rounds':>8s} "
          f"{'calls':>9s}")
    for mode, lazy, fanouts in (("push", True, (1, 2, 3, 5)),
                                ("push", False, (1,)),
                                ("pushpull", False, (1, 2, 3))):
        for fanout in fanouts:
            cap = {1: MAX_R, 2: 25, 3: 20, 5: 15}[fanout]
            rounds, calls = [], []
            for s in range(FANOUT_SEEDS):
                sched = schedule(N, fanout, cap, s)
                res = simulate(N, sched, mode=mode, lazy=lazy)
                assert res["converged"], (mode, fanout, s)
                rounds.append(res["rounds"])
                calls.append(res["calls"])
            print(f"  {mode:10s} {str(lazy):6s} {fanout:7d} "
                  f"{st.mean(rounds):8.2f} {st.mean(calls):9.0f}")
            sys.stdout.flush()
    print("\n  Lazy push at fanout 3 beats push-pull at fanout 1 on BOTH")
    print("  axes, so 'push-pull is free' is false. And fanout 1 -> 5 makes")
    print("  push 3x faster for a fifth more messages: finishing sooner")
    print("  cancels almost all of the extra per-round cost.")


if __name__ == "__main__":
    print(f"n={N}  fanout=1  synchronous rounds  anti-entropy  "
          f"max_rounds={MAX_R}")
    print(f"charging: naive = {callers('push', N, 1)} calls in every round; "
          f"lazy = {callers('push', N, 1, lazy=True)} in a round that starts "
          f"with 1 node informed")
    scaling()
    traces()
    main_curves = run_all(N, SEEDS)
    coverage(main_curves)
    cost(main_curves)
    why(main_curves)
    boundary_coverage()
    boundary_fanout()
