#!/usr/bin/env python3
"""BLACKJACK | THE MONERO TABLE — offline fairness checker.

Usage: python3 blackjack-fairness-checker.py proof.json --pre-deal pre-deal.json
Get your round proof from History, and use the pre-deal record saved before play.
Python standard library only. No uploads, account number or internet needed.
Omit --pre-deal to reproduce the proof without checking independently saved inputs.
The v1 rules below are frozen from the published engine for standalone replay.
"""
import argparse
import copy
import hashlib
import hmac
import json
import os
from pathlib import Path
import re
import sys


# Downloads fill in the originating table's public URL; it is never opened automatically.
TABLE_URL = "http://monerotable.com/"


RULES = {
    "decks": 6, "blackjack_payout": "6:5", "dealer_hits_soft_17": True,
    "double_totals": [10, 11], "double_after_split": False,
    "max_hands": 2, "hit_split_aces": False, "surrender": False,
    "dealer_peeks": True,
}


RANKS = ("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K")


SUITS = ("spades", "hearts", "diamonds", "clubs")


def value(cards):
    """Return (best total, is_soft), reducing 11-valued aces until needed.

    A hand is soft only if at least one ace still counts as 11. Busted hands
    retain a total above 21; this function does not decide their outcome.
    """
    total, aces = 0, 0
    for card in cards:
        rank = card["rank"]
        total += 11 if rank == "A" else 10 if rank in ("J", "Q", "K") else int(rank)
        aces += rank == "A"
    while total > 21 and aces:
        total -= 10
        aces -= 1
    return total, aces > 0


def hand(cards, bet, split=False):
    """Construct an unsettled hand; bet and payout are atomic XMR integers."""
    return {"cards": cards, "bet": bet, "status": "playing", "split": split, "outcome": None, "payout": 0}


def draw(state):
    """Remove the next card from the private shoe, mutating state in place."""
    if not state["shoe"]:
        raise ValueError("The shoe is exhausted.")
    return state["shoe"].pop(0)


def settle(state):
    """Set all hand outcomes and gross returns, then mark the round settled.

    Mutates state without touching the database or account balance. A gross
    return includes the wager: push=1x, win=2x, natural=2.2x, loss/bust=0.
    Split two-card 21 is never a natural. Call only after a natural is detected
    or the dealer's turn is complete.
    """
    dealer_total = value(state["dealer"])[0]
    dealer_blackjack = len(state["dealer"]) == 2 and dealer_total == 21
    for h in state["hands"]:
        total = value(h["cards"])[0]
        natural = len(h["cards"]) == 2 and total == 21 and not h["split"]
        if total > 21:
            outcome, payout = "bust", 0
        elif dealer_blackjack:
            outcome, payout = ("push", h["bet"]) if natural else ("loss", 0)
        elif natural:
            outcome, payout = "blackjack", h["bet"] + h["bet"] * 6 // 5
        elif dealer_total > 21 or total > dealer_total:
            outcome, payout = "win", h["bet"] * 2
        elif total == dealer_total:
            outcome, payout = "push", h["bet"]
        else:
            outcome, payout = "loss", 0
        h.update(status="done", outcome=outcome, payout=payout)
    state["phase"] = "settled"


def deal(bet, shoe):
    """Return (private state, ordered events) after the alternating first deal.

    The supplied shoe is copied and consumed from its front. Naturals settle immediately, before any
    player can place a split or double wager. The caller reserves the stake.
    """
    state = {"shoe": copy.deepcopy(shoe), "dealer": [], "hands": [hand([], bet)], "active_hand": 0, "phase": "player"}
    events = []
    for recipient in ("player", "dealer", "player", "dealer"):
        card = draw(state)
        (state["dealer"] if recipient == "dealer" else state["hands"][0]["cards"]).append(card)
        events.append({"kind": "card_dealt", "recipient": recipient, "card": card, "hole": recipient == "dealer" and len(state["dealer"]) == 2})
    if value(state["dealer"])[0] == 21 or value(state["hands"][0]["cards"])[0] == 21:
        settle(state)
    return state, events


def allowed_actions(state, balance):
    """List moves for the active hand using spendable balance in atomic units.

    Affordability matters for split and double, each requiring another stake.
    An empty list means the round has already settled.
    """
    if state["phase"] != "player":
        return []
    h = state["hands"][state["active_hand"]]
    actions = ["hit", "stand"]
    if len(h["cards"]) == 2 and not h["split"] and value(h["cards"])[0] in RULES["double_totals"] and balance >= h["bet"]:
        actions.append("double")
    if len(state["hands"]) == 1 and len(h["cards"]) == 2 and h["cards"][0]["rank"] == h["cards"][1]["rank"] and balance >= h["bet"]:
        actions.append("split")
    return actions


def advance(state, events):
    """Select the next playing hand or run the dealer and settle, in place.

    Append reveal/draw events to the caller's list. The dealer hits soft 17,
    but need not draw if every player hand has already busted.
    """
    if any(h["status"] == "playing" for h in state["hands"]):
        state["active_hand"] = next(i for i, h in enumerate(state["hands"]) if h["status"] == "playing")
        return
    events.append({"kind": "dealer_revealed", "cards": copy.deepcopy(state["dealer"])})
    if any(value(h["cards"])[0] <= 21 for h in state["hands"]):
        while True:
            total, soft = value(state["dealer"])
            if total > 17 or (total == 17 and not soft):
                break
            card = draw(state)
            state["dealer"].append(card)
            events.append({"kind": "card_dealt", "recipient": "dealer", "card": card})
    settle(state)


def act(original, action, balance):
    """Return (new private state, additional stake, events) for one valid move.

    Copy the input before changing it. The service layer must reserve the
    additional stake and credit any settlement within its account transaction.
    Raises ValueError for a move not listed by allowed_actions().
    """
    state = copy.deepcopy(original)
    if action not in allowed_actions(state, balance):
        raise ValueError("That action is not available for this hand.")
    h = state["hands"][state["active_hand"]]
    extra_bet = h["bet"] if action in {"double", "split"} else 0
    events = [{"kind": action, "hand": state["active_hand"], "extra_bet": extra_bet}]
    if action == "split":
        first, second = h["cards"]
        state["hands"] = [hand([first, draw(state)], h["bet"], True), hand([second, draw(state)], h["bet"], True)]
        for index, split_hand in enumerate(state["hands"]):
            events.append({"kind": "card_dealt", "recipient": "player", "hand": index, "card": split_hand["cards"][1]})
            if first["rank"] == "A" or value(split_hand["cards"])[0] == 21:
                split_hand["status"] = "stood"
    elif action == "stand":
        h["status"] = "stood"
    else:
        if action == "double":
            h["bet"] *= 2
        card = draw(state)
        h["cards"].append(card)
        events.append({"kind": "card_dealt", "recipient": "player", "hand": state["active_hand"], "card": card})
        total = value(h["cards"])[0]
        if action == "double" or total >= 21:
            h["status"] = "bust" if total > 21 else "stood"
    advance(state, events)
    return state, extra_bet, events


ALGORITHM = 'blackjack-fair-v1'
ATOMIC = 10**12


def canonical(value):
    """Canonical compact JSON; also distinguishes booleans from integer values."""
    return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False)


def xmr(amount):
    """Format exact atomic integers without floats, matching the round proof."""
    sign = '-' if amount < 0 else ''
    whole, fraction = divmod(abs(amount), ATOMIC)
    tail = f'{fraction:012d}'.rstrip('0')
    return f'{sign}{whole}' + (f'.{tail}' if tail else '')


def shuffled_shoe(proof):
    """Turn the two saved seeds and shuffle ID into the exact 312-card draw order.

    HMAC supplies repeatable 32-bit numbers. Rejection sampling discards numbers
    that would make some card positions more likely than others.
    """
    shoe = [{'rank': rank, 'suit': suit} for _ in range(6) for suit in SUITS for rank in RANKS]
    counter, words = 0, iter(())
    for index in range(311, 0, -1):
        size = index + 1
        limit = 2**32 - (2**32 % size)
        while True:
            sample = next(words, None)
            if sample is None:
                message = f"{ALGORITHM}\n{proof['id']}\n{proof['client_seed']}\n{counter}".encode('ascii')
                block = hmac.digest(bytes.fromhex(proof['server_seed']), message, 'sha256')
                words = iter(int.from_bytes(block[offset:offset + 4], 'big') for offset in range(0, 32, 4))
                counter += 1
                sample = next(words)
            if sample < limit:
                break
        chosen = sample % size
        shoe[index], shoe[chosen] = shoe[chosen], shoe[index]
    return shoe


def public_steps(steps):
    """Keep only game fields so the replay can be compared with the saved proof.

    Timestamps and account balances do not determine the shuffle or hand return.
    """
    result = []
    for step in steps:
        kind = step['kind']
        if kind == 'shoe_shuffled':
            fields = ('decks', 'cards', 'inferred')
        elif kind == 'round_created':
            fields = ('amount',)
        elif kind == 'card_dealt':
            result.append({'kind': kind, 'recipient': step['recipient'], 'hand': step.get('hand', 0), 'card': step['card']})
            continue
        elif kind == 'dealer_revealed':
            fields = ('cards',)
        elif kind == 'round_settled':
            fields = ('amount', 'hands', 'dealer', 'outcomes')
        elif kind in {'hit', 'stand', 'double', 'split'}:
            extra = step.get('extra_bet', 0)
            result.append({'kind': kind, 'hand': step['hand'], 'amount': step.get('amount', xmr(-extra) if extra else None)})
            continue
        else:
            raise ValueError('Unknown game step.')
        result.append({'kind': kind, **{field: step[field] for field in fields}})
    return result


def settlement(state):
    """Construct the final compared event from reproduced hands and dealer."""
    return {'kind': 'round_settled', 'amount': xmr(sum(h['payout'] for h in state['hands'])),
            'hands': [h['cards'] for h in state['hands']], 'dealer': state['dealer'],
            'outcomes': [h['outcome'] for h in state['hands']]}


def hex_seed(value):
    """Accept only the protocol's exact lowercase 32-byte hex encoding."""
    return type(value) is str and re.fullmatch(r'[0-9a-f]{64}', value) is not None


def verify(detail, pre_deal=None):
    """Check original inputs, rebuild the cards, and replay the finished round.

    Return a plain-English match result or raise ValueError on a mismatch. With
    no pre-deal record, reproduction cannot establish the original inputs.
    """
    proof = detail.get('fairness')
    if not isinstance(proof, dict) or detail.get('status') != 'settled' or not proof.get('server_seed'):
        raise ValueError('Finish the round first. Older rounds have no proof.')
    if proof.get('algorithm') != ALGORITHM or canonical(proof.get('rules')) != canonical(RULES):
        raise ValueError('Unsupported rules or shuffle version.')
    if (type(proof.get('id')) is not str or
            re.fullmatch(r'[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', proof['id']) is None or
            not all(hex_seed(proof.get(name)) for name in ('server_seed', 'client_seed', 'commitment')) or
            type(detail.get('steps')) is not list or len(detail['steps']) > 650):
        raise ValueError('Incomplete or invalid proof.')
    message = f"{ALGORITHM}\n{proof['id']}\n{proof['server_seed']}\n{canonical(proof['rules'])}"
    fingerprint = hashlib.sha256(message.encode('ascii')).hexdigest()
    if fingerprint != proof['commitment']:
        raise ValueError('The revealed key does not match the shuffle fingerprint.')
    if pre_deal is not None:
        if not isinstance(pre_deal, dict) or not all(hex_seed(pre_deal.get(name)) for name in ('commitment', 'client_seed')):
            raise ValueError('Incomplete pre-deal record.')
        if pre_deal['commitment'] != fingerprint or pre_deal['client_seed'] != proof['client_seed']:
            raise ValueError('The proof does not match your saved pre-deal record.')
    bets = {'0.01': 10_000_000_000, '0.025': 25_000_000_000, '0.05': 50_000_000_000}
    initial = proof.get('initial_bet')
    if type(initial) is not str or initial not in bets:
        raise ValueError('Invalid initial wager.')
    state, events = deal(bets[initial], shuffled_shoe(proof))
    expected = [{'kind': 'shoe_shuffled', 'decks': 6, 'cards': 312, 'inferred': False},
                {'kind': 'round_created', 'amount': xmr(-bets[initial])}, *events]
    if state['phase'] == 'settled':
        expected.append(settlement(state))
    for step in detail['steps']:
        if step['kind'] in {'hit', 'stand', 'double', 'split'}:
            state, _, events = act(state, step['kind'], ATOMIC)
            expected.extend(events)
            if state['phase'] == 'settled':
                expected.append(settlement(state))
    if (state['phase'] != 'settled' or canonical(public_steps(expected)) != canonical(public_steps(detail['steps'])) or
            detail.get('bet') != xmr(sum(h['bet'] for h in state['hands'])) or
            detail.get('payout') != xmr(sum(h['payout'] for h in state['hands']))):
        raise ValueError('Cards, decisions, wagers or return do not match the reproduced round.')
    return ('Verified: saved inputs, cards, decisions, wagers and return match.' if pre_deal is not None else
            'Cards, wagers and return match. Add --pre-deal with the record saved before play to check the original inputs.')


def load_json(path, limit):
    """Read bounded local JSON data only; no execution, network or application state."""
    with Path(path).open('rb') as source:
        content = source.read(limit + 1)
    if len(content) > limit:
        raise ValueError('Input file is too large.')
    return json.loads(content.decode('utf-8'))


def main():
    """CLI: exit zero for a reproduced proof, one for invalid/mismatched input."""
    parser = argparse.ArgumentParser(prog='blackjack-fairness-checker.py',
        description='BLACKJACK | THE MONERO TABLE\nCheck your round offline. No packages or account number needed.',
        epilog='Example: python3 blackjack-fairness-checker.py proof.json --pre-deal pre-deal.json\n'
               'Get the proof from History and use your actual downloaded filenames.\n'
               + (f'Back to the table: {TABLE_URL}' if TABLE_URL else 'The downloaded version includes a link back to your table.'),
        formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument('proof', help='finished round proof JSON from History')
    parser.add_argument('--pre-deal', metavar='RECORD', help='optional JSON record saved before playing')
    args = parser.parse_args()
    print_brand()
    try:
        pre_deal = load_json(args.pre_deal, 4096) if args.pre_deal else None
        print(verify(load_json(args.proof, 1_000_000), pre_deal))
    except (OSError, ValueError, TypeError, KeyError, IndexError, AttributeError, RecursionError) as error:
        print(f'Could not verify: {error}', file=sys.stderr)
        return 1
    return 0


def print_brand():
    """Show the table identity/link; use green/cream only in a color-enabled terminal.

    Write to stderr so stdout keeps its plain verification result for scripts.
    The URL is informational: this checker never opens a browser or connection.
    """
    heading = 'BLACKJACK | THE MONERO TABLE\nOffline fairness checker'
    if sys.stderr.isatty() and 'NO_COLOR' not in os.environ:
        heading = '\033[38;2;215;223;154mBLACKJACK | THE MONERO TABLE\033[0m\n\033[38;2;236;238;225mOffline fairness checker\033[0m'
    print(heading, file=sys.stderr)
    if TABLE_URL:
        print(f'Back to the table: {TABLE_URL}', file=sys.stderr)
    print('Your files stay on this device.\n', file=sys.stderr)


if __name__ == '__main__':
    sys.exit(main())
