"""
HEAD OR TAIL — payout engine (pure functions, no DB, no I/O).

This is the heart of the margin guarantee. Everything here is deterministic
given its inputs, so it can be unit-tested in isolation.

Resolution order inside a single simulated outcome:
  1. Each bet -> WIN (exact tier), NEAR (5/6-coin, miss by exactly one), or LOSS.
  2. Group bets by (session, round). A group of >= CASHBACK_MIN_TICKETS that is a
     CLEAN SWEEP (every bet strictly LOSS, no WIN and no NEAR) earns cashback.
     (Near-miss and cashback are therefore mutually exclusive within a group.)
  3. Total payout = sum(wins) + sum(near-miss) + sum(cashback).

The chooser simulates all 2^NUM_COINS outcomes, keeps only those whose total
payout <= ceiling (PAYOUT_RATE * pool), and picks one favouring the house.
If NO outcome is safe (oversubscribed round), it picks the least-bad outcome
and returns a scale factor < 1 so that, after scaling every payout, the total
equals exactly the ceiling -> the house always keeps its reserve.
"""

from itertools import product
import random
import config


def _faces():
    return ("HEADS", "TAILS")


def all_outcomes(num_coins):
    """All 2^num_coins possible boards as tuples of 'HEADS'/'TAILS'."""
    return list(product(_faces(), repeat=num_coins))


def resolve_bet(outcome, picks, size, stake, multiplier):
    """
    Return (status, payout) for one bet against one outcome.
    status in {'win','near_miss','loss'}.
    outcome is a tuple indexed 0..num_coins-1.
    picks is [{'coin':1..6,'pick':'HEADS'/'TAILS'}, ...].
    """
    correct = sum(1 for pk in picks if outcome[pk["coin"] - 1] == pk["pick"])
    if correct == size:
        return "win", stake * multiplier
    if size in config.NEAR_MISS_SIZES and correct == size - 1:
        return "near_miss", stake * config.NEAR_MISS_MULT
    return "loss", 0.0


def evaluate_outcome(outcome, bets):
    """
    Evaluate one outcome against all bets.
    bets: list of dicts with keys: ticket_id, session_id, picks, size, stake, multiplier
    Returns: (total_payout, results, cashback)
      results: {ticket_id: (status, payout)}
      cashback: {session_id: amount}
    """
    results = {}
    # group bookkeeping for cashback
    group_members = {}   # session_id -> [ticket_id, ...]
    group_stake = {}     # session_id -> aggregate stake

    for b in bets:
        status, payout = resolve_bet(
            outcome, b["picks"], b["size"], b["stake"], b["multiplier"]
        )
        results[b["ticket_id"]] = (status, payout)
        sid = b["session_id"]
        group_members.setdefault(sid, []).append(b["ticket_id"])
        group_stake[sid] = group_stake.get(sid, 0.0) + b["stake"]

    cashback = {}
    for sid, members in group_members.items():
        if len(members) >= config.CASHBACK_MIN_TICKETS:
            # clean sweep: every member strictly LOSS
            if all(results[tid][0] == "loss" for tid in members):
                cashback[sid] = group_stake[sid] * config.CASHBACK_RATE

    total = sum(p for (_, p) in results.values()) + sum(cashback.values())
    return total, results, cashback


def choose_outcome(bets, pool, num_coins=None, rng=random):
    """
    Pick the board to reveal.

    Returns a dict:
      outcome        : tuple of faces to reveal
      results        : {ticket_id: (status, payout)}  (pre-scale)
      cashback       : {session_id: amount}           (pre-scale)
      raw_payout     : total payout before scaling
      scale          : multiply every payout by this (<1 only when oversubscribed)
      ceiling        : PAYOUT_RATE * pool
      oversubscribed : bool
    """
    if num_coins is None:
        num_coins = config.NUM_COINS
    ceiling = pool * config.PAYOUT_RATE

    evaluated = []
    for o in all_outcomes(num_coins):
        total, results, cashback = evaluate_outcome(o, bets)
        evaluated.append((total, o, results, cashback))

    safe = [e for e in evaluated if e[0] <= ceiling + 1e-9]

    if safe:
        safe.sort(key=lambda e: e[0])               # ascending payout
        if rng.random() < config.HOUSE_BIAS:
            chosen = safe[0]                         # minimise payout -> max house
        else:
            chosen = rng.choice(safe)                # any safe board
        scale = 1.0
        oversubscribed = False
    else:
        evaluated.sort(key=lambda e: e[0])           # least-bad board
        chosen = evaluated[0]
        raw = chosen[0]
        scale = (ceiling / raw) if raw > 0 else 1.0
        oversubscribed = True

    total, outcome, results, cashback = chosen
    return {
        "outcome": outcome,
        "results": results,
        "cashback": cashback,
        "raw_payout": total,
        "scale": scale,
        "ceiling": ceiling,
        "oversubscribed": oversubscribed,
    }


def recalc_live_multipliers(total_pool, liability_by_size):
    """
    Live, displayed multipliers that shrink as liability for a tier grows,
    so the displayed odds already lean toward keeping the round safe.
    Returns {size: multiplier}.
    """
    ceiling = total_pool * config.PAYOUT_RATE
    out = {}
    for size in range(1, config.NUM_COINS + 1):
        base = config.BASE_MULTIPLIERS[size]
        lib = liability_by_size.get(size, 0.0)
        if ceiling <= 0:
            out[size] = base
        elif lib < ceiling:
            ratio = lib / ceiling
            out[size] = max(1.05, round(base * (1.0 - ratio * config.LIABILITY_DAMP), 2))
        else:
            out[size] = 1.05
    return out


def is_perfect_board(outcome):
    """All coins the same face -> jackpot-eligible board."""
    return len(set(outcome)) == 1


# ---------------- self-test ----------------
if __name__ == "__main__":
    # Quick sanity demo: one player buys five 6-coin tickets, all should be
    # resolvable, and the chooser must never exceed the ceiling when a safe
    # board exists.
    sample = []
    for i in range(5):
        sample.append({
            "ticket_id": f"T{i}",
            "session_id": "P1",
            "picks": [{"coin": c, "pick": "HEADS"} for c in range(1, 7)],
            "size": 6,
            "stake": 100.0,
            "multiplier": 60.0,
        })
    pool = 500.0
    pick = choose_outcome(sample, pool)
    print("Ceiling:", pick["ceiling"])
    print("Chosen board:", pick["outcome"])
    print("Raw payout:", pick["raw_payout"], "scale:", pick["scale"],
          "oversubscribed:", pick["oversubscribed"])
    # With everyone on all-HEADS, the safe board is anything that is NOT all-HEADS;
    # cashback should fire (5 clean losses) unless a near-miss occurs.
