"""
cjavi — Aviator WebSocket Bot for betpawa.ug

Strategy (1.5x Loss-Recovery Ladder):
  Stakes: 120 → 360 → 1080 → 3240 → 9720 (1 level).
  On loss  → advance to next stake.
  On win   → use profit to pay down recorded losses (loss_debt).
             If losses fully recovered, reset to initial stake (120).
             If not, advance to next stake until debt is cleared.
  After each verified win → randomly wait 8, 10 or 12 minutes before next bet.
  Dashboard can pause/resume betting remotely.

Usage:
    pip install playwright
    playwright install chromium
    python cjavi.py
"""

import json
import os
import re
import struct
import sys
import time
import random
import threading
import urllib.request
import urllib.error
from collections import deque
from datetime import datetime
from pathlib import Path
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout

# Remote dashboard URL (hosted on Linkiefy)
# NOTE: This bot is configured to use the hosted JSON files via the dashboard
# endpoint only (no local `cjavi_state.json` / status JSON writes).
DASHBOARD_URL = "https://www.linkiefy.com/cjavi.php"
DASHBOARD_KEY = "cjavi_secret_2026"                     # <-- Shared auth key


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  CONFIGURATION                                                      ║
# ╚══════════════════════════════════════════════════════════════════════╝

# Accounts — bot switches between them after N verified wins
ACCOUNTS = [
    {"mobile": "750918940",  "password": "Favour@2016"},
    {"mobile": "788866776",  "password": "Favour@2016"},
]

LOGIN_URL    = "https://www.betpawa.ug/login"
AVIATOR_URL  = "https://www.betpawa.ug/casino?gameId=aviator&filter=all"

# Each level has a cashout multiplier target and a list of escalating stakes.
# We now use a single 1.5x ladder that recovers recorded losses (loss_debt).
LEVELS = [
    {"cashout": 1.5,
     "stakes": [600, 1800, 5400, 16200, 51000]},
]

# For compatibility, recovery uses the same ladder as normal mode.
RECOVERY_LEVELS = LEVELS

# Switch accounts after this many VERIFIED wins (not phantom wins)
SWITCH_ACCOUNTS_AFTER_WINS = 25

# Wait times (in minutes) for different pauses
# Switch-account waits previously 20/30/40, now 2/30/40
PRE_SWITCH_ACCOUNT_WAIT_MINUTES = [2, 30, 40]
# Wait after a verified win before the next bet
PRE_WIN_WAIT_MINUTES = [6, 8, 10]


def _wait_minutes(page, minutes, label):
    total_seconds = minutes * 60
    log(f"  >> Waiting {minutes} minutes before {label}...", "WARN")
    try:
        for _ in range(total_seconds):
            page.wait_for_timeout(1000)  # 1 second per tick
    except Exception:
        time.sleep(total_seconds)
    log(f"  >> Wait complete. Continuing: {label}.")


def wait_before_recovery(page):
    """Wait a random 20, 30, or 40 minutes before entering recovery mode."""
    minutes = random.choice(PRE_SWITCH_ACCOUNT_WAIT_MINUTES)
    _wait_minutes(page, minutes, "entering recovery mode")


def wait_before_account_switch(page):
    """Wait a random 20, 30, or 40 minutes before switching accounts."""
    minutes = random.choice(PRE_SWITCH_ACCOUNT_WAIT_MINUTES)
    _wait_minutes(page, minutes, "switching accounts")


def wait_after_verified_win(page):
    """Randomly wait 8, 10, or 12 minutes after a verified win before betting again."""
    minutes = random.choice(PRE_WIN_WAIT_MINUTES)
    _wait_minutes(page, minutes, "next bet after win")


# Dashboard command file (written by PHP, read by bot)
PAUSE_CMD_FILE     = Path(__file__).parent / "cjavi_pause.json"
CMD_FILE           = Path(__file__).parent / "cjavi_command.json"


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  LOGGING                                                            ║
# ╚══════════════════════════════════════════════════════════════════════╝

LOG_BUFFER = deque(maxlen=150)

def log(msg, level="INFO"):
    ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
    line = f"[{ts}] [{level}] {msg}"
    print(line)
    LOG_BUFFER.append(line)

def log_section(title):
    print()
    border = "=" * 60
    header = f"  {border}\n  ||  {title:<54}  ||\n  {border}"
    print(header)
    LOG_BUFFER.append(header)
    print()

def log_divider():
    print()
    log("─" * 56)


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  DASHBOARD STATUS WRITER                                            ║
# ╚══════════════════════════════════════════════════════════════════════╝

# Round history — last 100 rounds stored in memory
round_history = []
MAX_HISTORY = 200

# Per-account balance tracking: { "***8940": {"start": 1000, "current": 1200}, ... }
account_balances = {}

def check_dashboard_command():
    """Check if dashboard sent a command. Returns command string or None."""
    for fpath in [CMD_FILE, PAUSE_CMD_FILE]:
        try:
            if fpath.exists():
                with open(fpath, "r") as f:
                    cmd = json.load(f)
                fpath.unlink(missing_ok=True)
                action = cmd.get("command", "")
                if action == "pause":
                    if not strat.dashboard_paused:
                        strat.dashboard_paused = True
                        log("  [DASHBOARD] Pause command received", "INFO")
                elif action == "resume":
                    if strat.dashboard_paused:
                        strat.dashboard_paused = False
                        log("  [DASHBOARD] Resume command received", "INFO")
                elif action in ("stop", "restart"):
                    log(f"  [DASHBOARD] {action.upper()} command received", "WARN")
                    return action
        except Exception:
            pass
    return None


def write_status(strat, balance=None, cycle_balance=None,
                 session_start_balance=None, current_round=0,
                 last_result=None, last_mult=None, ws_connected=False,
                 bot_state="running", extra=None):
    """POST bot status to the remote PHP dashboard (no local file writes)."""
    try:
        levels = strat.active_levels
        data = {
            "timestamp": datetime.now().isoformat(),
            "bot_state": bot_state,  # running, paused, dashboard_paused, stopped, error
            "level": strat.level_idx + 1,
            "total_levels": len(levels),
            "stake_index": strat.stake_idx + 1,
            "stakes_in_level": strat.stakes_in_level,
            "current_stake": strat.current_stake,
            "current_cashout": strat.current_cashout,
            "position": strat.position_str(),
            "total_bets": strat.total_bets,
            "total_wins": strat.total_wins,
            "total_losses": strat.total_losses,
            "session_profit": round(strat.session_profit, 2),
            "total_wagered": round(strat.total_wagered, 2),
            "loss_debt": round(strat.loss_debt, 2),
            "wins_since_switch": strat.wins_since_switch,
            "highest_level": strat.highest_level + 1,
            "highest_stake": strat.highest_stake,
            "biggest_win_profit": round(strat.biggest_win_profit, 2),
            "balance": round(balance, 2) if balance else None,
            "cycle_balance": round(cycle_balance, 2) if cycle_balance else None,
            "session_start_balance": round(session_start_balance, 2) if session_start_balance else None,
            "current_round": current_round,
            "last_result": last_result,
            "last_mult": round(last_mult, 2) if last_mult else None,
            "ws_connected": ws_connected,
            "paused": strat.paused or strat.dashboard_paused,
            "dashboard_paused": strat.dashboard_paused,
            "pause_reason": strat.pause_reason if strat.paused else
                           ("Paused from dashboard" if strat.dashboard_paused else None),
            "stop_loss": None,
            "current_account": f"***{get_current_account()['mobile'][-4:]}",
            "account_balances": account_balances,
            "round_history": round_history[-50:],
            # Recovery mode info
            "recovery_mode": strat.recovery_mode,
            "recovery_profit": round(strat.recovery_profit, 2) if strat.recovery_mode else None,
            "recovery_account": strat.recovery_account,
            "mode": strat.mode_str(),
            "logs": list(LOG_BUFFER)[-100:],
        }
        if extra:
            data.update(extra)

        # POST to remote PHP dashboard (non-blocking via thread)
        def _post():
            try:
                payload = json.dumps({"key": DASHBOARD_KEY, "data": data}).encode("utf-8")
                req = urllib.request.Request(
                    DASHBOARD_URL,
                    data=payload,
                    headers={"Content-Type": "application/json"},
                    method="POST"
                )
                resp = urllib.request.urlopen(req, timeout=5)
                # Check if dashboard sent a command back
                try:
                    resp_data = json.loads(resp.read().decode())
                    cmd = resp_data.get("command")
                    if cmd == "pause" and not strat.dashboard_paused:
                        strat.dashboard_paused = True
                        log("  [DASHBOARD] Pause command received", "INFO")
                    elif cmd == "resume" and strat.dashboard_paused:
                        strat.dashboard_paused = False
                        log("  [DASHBOARD] Resume command received", "INFO")
                    elif cmd in ("stop", "restart"):
                        with open(CMD_FILE, "w") as cf:
                            json.dump({"command": cmd, "time": datetime.now().isoformat()}, cf)
                except Exception:
                    pass
            except Exception:
                pass
        threading.Thread(target=_post, daemon=True).start()

    except Exception:
        pass


def add_round_to_history(round_num, result, stake, cashout_target,
                          actual_mult, profit, balance, level, stake_idx,
                          cashout_info=None, position_str=None):
    """Add a round to the history buffer."""
    entry = {
        "round": round_num,
        "time": datetime.now().strftime("%H:%M:%S"),
        "result": result,  # "win", "loss", "phantom", "error"
        "stake": stake,
        "cashout_target": cashout_target,
        "actual_mult": round(actual_mult, 2) if actual_mult else 0,
        "profit": round(profit, 2),
        "balance": round(balance, 2) if balance else None,
        "level": level + 1,
        "stake_index": stake_idx + 1,
        "cashout_button": cashout_info.get("button_clicked", False) if cashout_info else False,
        "cashout_refresh": cashout_info.get("refresh_done", False) if cashout_info else False,
        "position": position_str if position_str else f"L{level+1}/S{stake_idx+1}",
    }
    round_history.append(entry)
    if len(round_history) > MAX_HISTORY:
        round_history.pop(0)


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  GAME STATE (thread-safe — WS callbacks fire during Playwright ops) ║
# ╚══════════════════════════════════════════════════════════════════════╝

class GameState:
    def __init__(self):
        self._lock = threading.Lock()
        self.multiplier = 0.0
        self.round_active = False
        self.bet_placed = False
        self.crashed = False
        self.betting_open = False
        self.round_start_time = None

        # WS health
        self.ws_connected = False
        self.ws_msg_count = 0
        self.ws_multiplier_working = False
        self.last_ws_time = time.time()

        # Crash tracking
        self.crash_count = 0
        self.last_crash_value = 0.0

    def reset_round(self):
        with self._lock:
            self.multiplier = 0.0
            self.round_active = False
            self.bet_placed = False
            self.crashed = False
            self.betting_open = False
            self.round_start_time = None

    def set_multiplier(self, val):
        with self._lock:
            self.multiplier = val

    def get_multiplier(self):
        with self._lock:
            return self.multiplier


state = GameState()


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  STRATEGY TRACKER                                                   ║
# ║  Tracks level/stake position, stats, highest values, pauses         ║
# ╚══════════════════════════════════════════════════════════════════════╝

STATE_SAVE_FILE = Path(__file__).parent / "cjavi_state.json"

class Strategy:
    def __init__(self):
        self.level_idx = 0
        self.stake_idx = 0
        # Outstanding losses not yet recovered by future wins
        self.loss_debt = 0.0
        # Count VERIFIED wins since last account switch
        self.wins_since_switch = 0
        self.total_bets = 0
        self.total_wins = 0
        self.total_losses = 0
        self.session_profit = 0.0
        self.total_wagered = 0.0
        self.highest_level = 0
        self.highest_stake = 0.0
        self.biggest_win_profit = 0.0
        self.paused = False
        self.pause_reason = ""
        self.cycle_balance = 0.0
        # Recovery mode
        self.recovery_mode = False
        self.recovery_profit = 0.0       # Profit accumulated in recovery
        self.recovery_account = None     # Account key that entered recovery
        # Dashboard pause
        self.dashboard_paused = False

    @property
    def active_levels(self):
        """Return the correct levels array based on mode."""
        return RECOVERY_LEVELS if self.recovery_mode else LEVELS

    def _state_dict(self):
        return {
            "level_idx": self.level_idx,
            "stake_idx": self.stake_idx,
            "loss_debt": self.loss_debt,
            "wins_since_switch": self.wins_since_switch,
            "total_bets": self.total_bets,
            "total_wins": self.total_wins,
            "total_losses": self.total_losses,
            "session_profit": self.session_profit,
            "total_wagered": self.total_wagered,
            "highest_level": self.highest_level,
            "highest_stake": self.highest_stake,
            "biggest_win_profit": self.biggest_win_profit,
            "cycle_balance": self.cycle_balance,
            "recovery_mode": self.recovery_mode,
            "recovery_profit": self.recovery_profit,
            "recovery_account": self.recovery_account,
            "dashboard_paused": self.dashboard_paused,
            "round_num": getattr(self, '_round_num', 0),
            "account_idx": current_account_idx,
            "account_balances": account_balances,
            "round_history": round_history[-50:],
            "saved_at": datetime.now().isoformat(),
        }

    def save_state(self):
        """Save strategy state to remote dashboard server only."""
        data = self._state_dict()
        # Remote save (non-blocking)
        def _remote_save():
            try:
                payload = json.dumps({"key": DASHBOARD_KEY, "state": data}).encode("utf-8")
                req = urllib.request.Request(
                    DASHBOARD_URL,
                    data=payload,
                    headers={"Content-Type": "application/json"},
                    method="POST"
                )
                urllib.request.urlopen(req, timeout=8)
            except Exception:
                pass
        threading.Thread(target=_remote_save, daemon=True).start()

    def _load_remote_state(self):
        """Try to fetch state from remote dashboard. Returns dict or None."""
        try:
            url = f"{DASHBOARD_URL}?api=state&key={DASHBOARD_KEY}"
            req = urllib.request.Request(url, method="GET")
            resp = urllib.request.urlopen(req, timeout=10)
            raw = resp.read().decode()
            data = json.loads(raw)
            if data and isinstance(data, dict) and "level_idx" in data:
                return data
        except Exception as e:
            log(f"  Could not fetch remote state: {e}", "WARN")
        return None

    def load_state(self):
        """Load strategy state from remote dashboard only (no local file reads)."""
        data = self._load_remote_state()
        if not data:
            return False

        try:
            self.level_idx = data.get("level_idx", 0)
            self.stake_idx = data.get("stake_idx", 0)
            self.loss_debt = data.get("loss_debt", 0.0) or 0.0
            self.wins_since_switch = data.get("wins_since_switch", 0) or 0
            self.total_bets = data.get("total_bets", 0)
            self.total_wins = data.get("total_wins", 0)
            self.total_losses = data.get("total_losses", 0)
            self.session_profit = data.get("session_profit", 0.0)
            self.total_wagered = data.get("total_wagered", 0.0)
            self.highest_level = data.get("highest_level", 0)
            self.highest_stake = data.get("highest_stake", 0.0)
            self.biggest_win_profit = data.get("biggest_win_profit", 0.0)
            self.cycle_balance = data.get("cycle_balance", 0.0)
            self.recovery_mode = data.get("recovery_mode", False)
            self.recovery_profit = data.get("recovery_profit", 0.0)
            self.recovery_account = data.get("recovery_account", None)
            self.dashboard_paused = data.get("dashboard_paused", False)
            # Restore round history
            saved_history = data.get("round_history", [])
            if saved_history:
                round_history.clear()
                round_history.extend(saved_history)
            # Validate indices against active levels
            levels = self.active_levels
            if self.level_idx >= len(levels):
                self.level_idx = 0
                self.stake_idx = 0
            elif self.stake_idx >= len(levels[self.level_idx]["stakes"]):
                self.stake_idx = 0
            self._round_num = data.get("round_num", 0)
            # Restore account index and balances
            global current_account_idx, account_balances
            current_account_idx = data.get("account_idx", 0) % len(ACCOUNTS)
            saved_bals = data.get("account_balances", {})
            if saved_bals:
                account_balances.update(saved_bals)
            self.paused = False
            self.pause_reason = ""
            acct = get_current_account()
            mode_str = "RECOVERY" if self.recovery_mode else "NORMAL"
            log(f"  Resumed from saved state: {self.position_str()} [{mode_str}]")
            log(f"  Account: ***{acct['mobile'][-4:]}")
            log(f"  Session: {self.total_wins}W / {self.total_losses}L  |  "
                f"P/L: {self.session_profit:+.1f} UGX")
            if self.recovery_mode:
                log(f"  Recovery profit: {self.recovery_profit:+.1f} UGX")
            if self.dashboard_paused:
                log(f"  Dashboard pause is ACTIVE")
            return True
        except Exception as e:
            log(f"  Failed to load state: {e}", "WARN")
            return False

    def clear_saved_state(self):
        """Clear saved state remotely (no local file deletes)."""
        # Clear remote state by saving an empty object
        try:
            payload = json.dumps({"key": DASHBOARD_KEY, "state": None}).encode("utf-8")
            req = urllib.request.Request(
                DASHBOARD_URL, data=payload,
                headers={"Content-Type": "application/json"}, method="POST"
            )
            urllib.request.urlopen(req, timeout=5)
            log("  Remote state cleared")
        except Exception:
            pass

    @property
    def current_level(self):
        return self.active_levels[self.level_idx]

    @property
    def current_stake(self):
        return self.current_level["stakes"][self.stake_idx]

    @property
    def current_cashout(self):
        return self.current_level["cashout"]

    @property
    def stakes_in_level(self):
        return len(self.current_level["stakes"])

    def enter_recovery(self):
        """Enter recovery mode after all normal levels exhausted."""
        self.recovery_mode = True
        self.recovery_profit = 0.0
        self.recovery_account = f"***{get_current_account()['mobile'][-4:]}"
        self.level_idx = 0
        self.stake_idx = 0
        log(f"  >>> ENTERING RECOVERY MODE <<<")
        log(f"  Recovery account: {self.recovery_account}")
        log(f"  Target: recover loss debt (debt={self.loss_debt:.0f} UGX)")
        log(f"  Starting at Recovery L1/S1: {self.current_stake} UGX @ "
            f"{self.current_cashout:.0f}x")

    def exit_recovery(self):
        """Exit recovery mode — losses recovered. Back to normal L1."""
        log(f"  >>> RECOVERY COMPLETE <<<")
        log(f"  Loss debt recovered. Recovery profit: {self.recovery_profit:+.1f} UGX")
        self.recovery_mode = False
        self.recovery_profit = 0.0
        self.recovery_account = None
        self.level_idx = 0
        self.stake_idx = 0

    def record_win(self, stake, cashout_mult):
        """Record a verified win. Returns profit."""
        profit = stake * (cashout_mult - 1)
        self.session_profit += profit
        self.total_wagered += stake
        self.total_bets += 1
        self.total_wins += 1
        self.wins_since_switch += 1
        if stake > self.highest_stake:
            self.highest_stake = stake
        if profit > self.biggest_win_profit:
            self.biggest_win_profit = profit
        # Track recovery profit
        if self.recovery_mode:
            self.recovery_profit += profit
        return profit

    def record_loss(self, stake):
        """Record a loss."""
        self.session_profit -= stake
        self.total_wagered += stake
        self.total_bets += 1
        self.total_losses += 1
        self.loss_debt += stake
        if stake > self.highest_stake:
            self.highest_stake = stake
        if self.level_idx > self.highest_level:
            self.highest_level = self.level_idx
        # Track recovery losses too
        if self.recovery_mode:
            self.recovery_profit -= stake

    def advance_after_loss(self):
        """
        Move to next stake. If level exhausted, move to next level.
        Returns: "next_stake", "next_level", "exhausted", or "recovery_exhausted"
        """
        levels = self.active_levels
        self.stake_idx += 1
        if self.stake_idx >= len(levels[self.level_idx]["stakes"]):
            # Level exhausted — try next level
            if self.level_idx + 1 < len(levels):
                self.level_idx += 1
                self.stake_idx = 0
                if self.level_idx > self.highest_level:
                    self.highest_level = self.level_idx
                return "next_level"
            else:
                if self.recovery_mode:
                    return "recovery_exhausted"
                else:
                    return "exhausted"
        return "next_stake"

    def reset_after_win(self):
        """Reset to L1/S1 after a verified win (in current mode)."""
        self.level_idx = 0
        self.stake_idx = 0

    def pause(self, reason):
        self.paused = True
        self.pause_reason = reason

    def position_str(self):
        """Short position string: L1/S3 or R1/S3 for recovery."""
        prefix = "R" if self.recovery_mode else "L"
        return f"{prefix}{self.level_idx + 1}/S{self.stake_idx + 1}"

    def mode_str(self):
        """Current mode string for display."""
        if self.recovery_mode:
            return f"RECOVERY (debt {self.loss_debt:.0f} UGX)"
        return "NORMAL"

    def detail_lines(self):
        """Detailed status lines for logging."""
        levels = self.active_levels
        level = self.current_level
        mode = "RECOVERY" if self.recovery_mode else "NORMAL"
        lines = [
            f"  Mode         : {mode}",
            f"  Position     : {mode[0]}{self.level_idx + 1}/{len(levels)}  |  "
            f"Stake {self.stake_idx + 1}/{len(level['stakes'])}",
            f"  Current Bet  : {self.current_stake} UGX  |  "
            f"Cashout target: >= {self.current_cashout:.0f}x",
            f"  Loss debt    : {self.loss_debt:.1f} UGX",
            f"  Wins to swap : {self.wins_since_switch}/{SWITCH_ACCOUNTS_AFTER_WINS}",
            f"  Session      : {self.total_wins}W / {self.total_losses}L  "
            f"({self.total_bets} bets)",
            f"  Profit       : {self.session_profit:+.1f} UGX  |  "
            f"Wagered: {self.total_wagered:.1f} UGX",
            f"  Highest      : Level {self.highest_level + 1}  |  "
            f"Stake {self.highest_stake:.1f} UGX  |  "
            f"Best win: +{self.biggest_win_profit:.1f} UGX",
        ]
        if self.recovery_mode:
            lines.append(
                f"  Recovery P/L : {self.recovery_profit:+.1f} UGX"
            )
        return lines


strat = Strategy()


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  BINARY WEBSOCKET PROTOCOL DECODER                                  ║
# ╚══════════════════════════════════════════════════════════════════════╝

_PAT_X          = b'\x00\x01\x78\x07'
_PAT_CRASHX     = b'\x00\x06crashX\x07'
_PAT_STATE_ID   = b'\x00\x0anewStateId\x04'
_PAT_BALANCE    = b'\x00\x0anewBalance\x07'
_PAT_BET        = b'\x00\x03bet\x07'
_PAT_CODE       = b'\x00\x04code\x04'


def decode_game_binary(data):
    result = {}
    if not data or len(data) < 10:
        return result
    if data[0] != 0x80:
        return result

    idx = data.find(_PAT_X)
    if idx >= 0 and idx + 4 + 8 <= len(data):
        mult = struct.unpack('>d', data[idx + 4: idx + 12])[0]
        if 0.5 <= mult <= 100000.0:
            result['x'] = round(mult, 2)

    idx = data.find(_PAT_CRASHX)
    if idx >= 0 and idx + 9 + 8 <= len(data):
        crash = struct.unpack('>d', data[idx + 9: idx + 17])[0]
        if 1.0 <= crash <= 100000.0:
            result['crashX'] = round(crash, 2)

    idx = data.find(_PAT_STATE_ID)
    if idx >= 0 and idx + 12 + 4 <= len(data):
        state_id = struct.unpack('>I', data[idx + 12: idx + 16])[0]
        if state_id in (1, 2, 3):
            result['newStateId'] = state_id

    idx = data.find(_PAT_BALANCE)
    if idx >= 0 and idx + 12 + 8 <= len(data):
        bal = struct.unpack('>d', data[idx + 12: idx + 20])[0]
        result['newBalance'] = round(bal, 2)

    idx = data.find(_PAT_BET)
    if idx >= 0 and idx + 5 + 8 <= len(data):
        bet_amt = struct.unpack('>d', data[idx + 5: idx + 13])[0]
        result['bet'] = round(bet_amt, 2)

    return result


def process_decoded_binary(decoded):
    if not decoded:
        return

    state.last_ws_time = time.time()

    if 'x' in decoded:
        state.set_multiplier(decoded['x'])
        state.round_active = True
        state.ws_multiplier_working = True

    if 'crashX' in decoded:
        crash_val = decoded['crashX']
        state.crashed = True
        state.round_active = False
        state.betting_open = False
        state.crash_count += 1
        state.last_crash_value = crash_val
        log(f"  [WS] CRASH at {crash_val:.2f}x  (observed: {state.crash_count})")

    if 'newStateId' in decoded:
        sid = decoded['newStateId']
        if sid == 1:
            state.betting_open = True
            state.round_active = False
            state.set_multiplier(0.0)
        elif sid == 2:
            state.round_active = True
            state.betting_open = False
            state.round_start_time = time.time()


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  SOCKJS / STOMP PARSER                                              ║
# ╚══════════════════════════════════════════════════════════════════════╝

def unwrap_sockjs(raw):
    if not raw:
        return []
    if raw == "o" or raw == "h":
        return []
    if raw.startswith("a["):
        try:
            return [m for m in json.loads(raw[1:]) if isinstance(m, str)]
        except json.JSONDecodeError:
            return []
    return [] if raw.startswith("c[") else [raw]


def parse_stomp_frame(raw):
    if not raw or "\n" not in raw:
        return None
    parts = raw.split("\n\n", 1)
    if len(parts) < 2:
        return None
    lines = parts[0].split("\n")
    command = lines[0].strip()
    headers = {}
    for line in lines[1:]:
        if ":" in line:
            k, v = line.split(":", 1)
            headers[k.strip()] = v.strip()
    return command, headers, parts[1].rstrip("\x00")


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  WEBSOCKET INTERCEPTION                                             ║
# ╚══════════════════════════════════════════════════════════════════════╝

def _payload_to_string(payload):
    if isinstance(payload, str):
        return payload
    if isinstance(payload, bytes):
        try:
            return payload.decode("utf-8")
        except UnicodeDecodeError:
            return None
    if isinstance(payload, dict):
        inner = payload.get("payload", payload.get("data", ""))
        if isinstance(inner, str):
            return inner
        if isinstance(inner, bytes):
            try:
                return inner.decode("utf-8")
            except UnicodeDecodeError:
                return None
    return None


def setup_ws_interception(page):
    def on_ws(ws):
        url = ws.url
        is_game = "bluebox" in url.lower()
        tag = "GAME" if is_game else "OTHER"
        log(f"  WebSocket [{tag}] connected: {url[:100]}...")
        if is_game:
            state.ws_connected = True

        def on_received(payload):
            state.ws_msg_count += 1
            state.last_ws_time = time.time()

            raw_bytes = None
            if isinstance(payload, bytes):
                raw_bytes = payload
            elif isinstance(payload, dict):
                inner = payload.get("payload", payload.get("data", b""))
                if isinstance(inner, bytes):
                    raw_bytes = inner

            if raw_bytes is not None:
                if is_game:
                    decoded = decode_game_binary(raw_bytes)
                    if decoded:
                        process_decoded_binary(decoded)
                return

            raw = _payload_to_string(payload)
            if not raw or raw in ("o", "h"):
                return
            for msg in unwrap_sockjs(raw):
                parse_stomp_frame(msg)

        def on_close():
            log(f"  WebSocket [{tag}] DISCONNECTED", "WARN")
            if is_game:
                state.ws_connected = False

        ws.on("framereceived", on_received)
        ws.on("close", on_close)

    page.on("websocket", on_ws)
    log("WebSocket interception enabled")


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  HUMAN-LIKE HELPERS                                                 ║
# ╚══════════════════════════════════════════════════════════════════════╝

def human_delay(lo=0.15, hi=0.5):
    time.sleep(random.uniform(lo, hi))

def human_type(locator, text, lo_ms=50, hi_ms=140):
    locator.press_sequentially(text, delay=random.randint(lo_ms, hi_ms))


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  LOGIN                                                              ║
# ╚══════════════════════════════════════════════════════════════════════╝

# ── Account rotation tracker ──
current_account_idx = 0

def get_current_account():
    """Return the current account credentials."""
    return ACCOUNTS[current_account_idx % len(ACCOUNTS)]

def switch_account():
    """Switch to the next account in the rotation."""
    global current_account_idx
    current_account_idx = (current_account_idx + 1) % len(ACCOUNTS)
    acct = ACCOUNTS[current_account_idx]
    log(f"  >> Switched to account: ***{acct['mobile'][-4:]}")
    return acct


def logout(page):
    """Logout from betpawa by clearing cookies — 100% reliable."""
    log("  Logging out (clearing session)...")
    try:
        page.context.clear_cookies()
        page.goto(LOGIN_URL, wait_until="domcontentloaded", timeout=30000)
        human_delay(2, 3)
        log("  Logged out — session cleared.")
        return True
    except Exception as e:
        log(f"  Logout error: {e}", "WARN")
        try:
            page.context.clear_cookies()
        except Exception:
            pass
        return True  # cookies cleared = logged out regardless


def login(page, mobile=None, password=None):
    """Login to betpawa with given or current account credentials."""
    if mobile is None or password is None:
        acct = get_current_account()
        mobile = acct["mobile"]
        password = acct["password"]

    log(f"Logging in as ***{mobile[-4:]}...")
    page.goto(LOGIN_URL, wait_until="domcontentloaded", timeout=60000)
    try:
        page.wait_for_load_state("load", timeout=20000)
    except Exception:
        pass
    human_delay(2, 4)

    mobile_selectors = [
        "input[type='tel']",
        "input[type='text'][inputmode='tel']",
        "input[name='phone']", "input[name='mobile']",
        "input[placeholder*='phone' i]", "input[placeholder*='mobile' i]",
        "input[placeholder*='number' i]", "form input[type='text']",
    ]
    mobile_input = None
    for attempt in range(3):
        for sel in mobile_selectors:
            try:
                loc = page.locator(sel).first
                if loc.count() > 0 and loc.is_visible(timeout=3000):
                    mobile_input = loc
                    log(f"  Found mobile input: {sel}")
                    break
            except Exception:
                continue
        if mobile_input:
            break
        log(f"  Mobile input not found (attempt {attempt + 1}/3), retrying...", "WARN")
        human_delay(2, 4)
    if not mobile_input:
        raise RuntimeError("Could not find mobile/phone input on login page.")

    mobile_input.click(timeout=10000)
    human_delay(0.2, 0.5)
    mobile_input.fill("")
    human_type(mobile_input, mobile, 60, 150)
    human_delay(0.3, 0.7)

    pw_input = page.locator("input[type='password']").first
    pw_input.click(timeout=10000)
    human_delay(0.15, 0.4)
    pw_input.fill("")
    human_type(pw_input, password, 50, 130)
    human_delay(0.4, 0.9)

    pw_input.press("Enter")
    time.sleep(random.uniform(2.5, 4))

    if "/login" in page.url.lower():
        for sel in ["input[type='submit'][value='Log In']", "input[value='Log In']",
                    "button:has-text('Log In')", "button:has-text('Login')", "[type='submit']"]:
            try:
                btn = page.locator(sel).first
                if btn.count() > 0 and btn.is_visible(timeout=2000):
                    btn.click(timeout=5000)
                    break
            except Exception:
                continue
        time.sleep(random.uniform(2.5, 4))

    try:
        page.wait_for_selector("text=DEPOSIT", timeout=20000)
    except PlaywrightTimeout:
        try:
            page.wait_for_selector("text=Deposit", timeout=5000)
        except PlaywrightTimeout:
            page.wait_for_url(lambda u: "/login" not in u.lower(), timeout=15000)
    log(f"Login successful! (***{mobile[-4:]})")


def login_with_retries(page, mobile=None, password=None,
                       max_failures_before_cooldown=5,
                       cooldown_seconds=3720):  # 62 minutes
    """
    Wrapper around login() that keeps retrying on failure.
    After `max_failures_before_cooldown` consecutive failures, it waits
    `cooldown_seconds` before continuing to retry.
    """
    failures_in_streak = 0
    attempt = 0

    while True:
        attempt += 1
        try:
            log(f"Login attempt #{attempt} (streak failures: {failures_in_streak})")
            login(page, mobile=mobile, password=password)
            if failures_in_streak > 0:
                log(f"Login succeeded after {failures_in_streak} consecutive failures")
            return True
        except Exception as e:
            failures_in_streak += 1
            log(f"Login attempt #{attempt} failed: {e}", "WARN")

            # After N consecutive failures, wait before trying again
            if failures_in_streak >= max_failures_before_cooldown:
                log(f"{failures_in_streak} consecutive login failures. "
                    f"Cooling down for {cooldown_seconds // 60} minutes before retrying...",
                    "WARN")
                # Use Playwright wait if possible so we don't block the event loop too harshly
                try:
                    for _ in range(cooldown_seconds):
                        page.wait_for_timeout(1000)
                except Exception:
                    # Fallback to time.sleep if page is not usable
                    time.sleep(cooldown_seconds)
                failures_in_streak = 0
            else:
                # Short delay between normal failed attempts
                try:
                    page.wait_for_timeout(3000)
                except Exception:
                    time.sleep(3)


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  NAVIGATE TO AVIATOR                                                ║
# ╚══════════════════════════════════════════════════════════════════════╝

IFRAME_SELECTORS = [
    "iframe[src*='spribegaming.com/aviator']",
    "iframe[src*='spribegaming.com']",
    "iframe[src*='aviator']",
    "iframe.game-iframe",
    "iframe[class*='game']",
    "iframe",
]


def switch_account_and_relogin(page):
    """
    Full account switch: logout → switch account → login → navigate to Aviator.
    Returns (success, new_frame).
    """
    log_section("SWITCHING ACCOUNT")

    # Reset WS state for fresh connection
    state.ws_connected = False
    state.ws_msg_count = 0
    state.crashed = False
    state.round_active = False
    state.betting_open = False

    # Logout
    logout(page)
    human_delay(1, 2)

    # Switch to next account
    new_acct = switch_account()

    # Login with new account (with retry + cooldown after repeated failures)
    try:
        login_with_retries(page, mobile=new_acct["mobile"], password=new_acct["password"])
        human_delay(1, 2)
    except Exception as e:
        log(f"  Login to new account failed after retries: {e}", "ERROR")
        return False, None

    # Navigate to Aviator
    try:
        frame = navigate_to_aviator(page)
    except Exception as e:
        log(f"  Navigation to Aviator failed: {e}", "ERROR")
        return False, None

    # Wait for WS to reconnect
    log("  Waiting for WebSocket on new account...")
    state.last_ws_time = time.time()
    t0 = time.time()
    while time.time() - t0 < 30:
        if state.ws_connected and state.ws_msg_count > 0:
            break
        page.wait_for_timeout(500)

    if not state.ws_connected:
        log("  WS not connected on new account — will retry", "WARN")

    # Wait for game to stabilize
    t0 = time.time()
    while time.time() - t0 < 30:
        if state.crashed or state.betting_open:
            break
        if wait_for_bet_button(frame, timeout=2):
            break
        page.wait_for_timeout(500)

    acct = get_current_account()
    log(f"  Account switch complete! Now on ***{acct['mobile'][-4:]}")
    return True, frame


def navigate_to_aviator(page):
    log("Navigating to Aviator game...")
    page.goto(AVIATOR_URL, wait_until="domcontentloaded", timeout=30000)
    try:
        page.wait_for_load_state("load", timeout=15000)
    except Exception:
        pass
    human_delay(4, 6)

    iframe_loc = page.locator("iframe[src*='spribegaming.com/aviator']").first
    iframe_loc.wait_for(state="visible", timeout=25000)
    log(f"  Found iframe: iframe[src*='spribegaming.com/aviator']")
    frame = page.frame_locator("iframe[src*='spribegaming.com/aviator']").first
    frame.locator("button.btn-success.bet").wait_for(state="visible", timeout=20000)
    log("Aviator game loaded and ready!")
    return frame


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  BALANCE                                                            ║
# ╚══════════════════════════════════════════════════════════════════════╝

def get_balance(frame):
    try:
        text = frame.locator("span.balance-amount").first.text_content(timeout=3000)
        if text:
            cleaned = text.strip().replace(",", "").replace(" ", "")
            return float(re.sub(r"[^\d.]", "", cleaned) or "0")
    except Exception:
        pass
    return None


def get_balance_reliable(frame, page, retries=3, delay_ms=1500):
    for attempt in range(retries):
        if attempt > 0:
            page.wait_for_timeout(delay_ms)
        bal = get_balance(frame)
        if bal is not None and bal > 0:
            return bal
    return get_balance(frame)


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  PLACE BET                                                          ║
# ╚══════════════════════════════════════════════════════════════════════╝

def place_bet(frame, amount):
    try:
        bet_input = frame.locator("input[inputmode='decimal'][type='text']").first
        bet_input.wait_for(state="visible", timeout=8000)

        deadline = time.time() + 10
        while time.time() < deadline:
            try:
                if bet_input.is_enabled():
                    break
            except Exception:
                pass
            time.sleep(0.1)

        bet_input.click(timeout=5000)
        human_delay(0.05, 0.15)
        bet_input.fill("")

        # Send clean stake string:
        # - integers as "25"
        # - decimals as "1.01" (2dp)
        try:
            is_int = float(amount).is_integer()
        except Exception:
            is_int = False
        bet_str = str(int(round(amount))) if is_int else f"{float(amount):.2f}"
        bet_input.fill(bet_str)

        human_delay(0.1, 0.2)

        bet_btn = frame.locator("button.btn-success.bet").first
        bet_btn.click(timeout=6000)

        state.bet_placed = True
        return True

    except Exception as e:
        log(f"  Bet placement failed: {e}", "ERROR")
        return False


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  CASHOUT (dual: click button + refresh)                             ║
# ╚══════════════════════════════════════════════════════════════════════╝

CASHOUT_SELECTORS = [
    "button.btn-warning.cashout",
    "button.cashout",
    "button.btn-warning",
    "[class*='cashout'] button",
    "button[class*='cashout']",
    "button[class*='cash-out']",
    "button:has-text('CASH OUT')",
    "button:has-text('Cashout')",
    "button:has-text('Cash Out')",
]


def click_cashout_button(frame):
    # Attempt 1: Try each selector with Playwright click (force=True, no scroll)
    for sel in CASHOUT_SELECTORS:
        try:
            btn = frame.locator(sel).first
            if btn.count() > 0 and btn.is_visible(timeout=300):
                btn.click(timeout=800, force=True)
                log(f"  >>> Cashout button clicked via selector: {sel}")
                return True
        except Exception:
            continue

    # Attempt 2: JavaScript click fallback (works even if button is obscured)
    try:
        js_clicked = frame.locator("html").evaluate("""() => {
            const selectors = [
                'button.btn-warning.cashout',
                'button.cashout',
                'button.btn-warning',
                'button[class*="cashout"]',
                'button[class*="cash-out"]'
            ];
            for (const sel of selectors) {
                const btn = document.querySelector(sel);
                if (btn && btn.offsetParent !== null) {
                    btn.click();
                    return sel;
                }
            }
            const allBtns = document.querySelectorAll('button');
            for (const btn of allBtns) {
                const txt = (btn.textContent || '').toLowerCase();
                if ((txt.includes('cash') || txt.includes('cashout')) && btn.offsetParent !== null) {
                    btn.click();
                    return 'text:' + txt.trim().substring(0, 30);
                }
            }
            return null;
        }""")
        if js_clicked:
            log(f"  >>> Cashout button clicked via JS: {js_clicked}")
            return True
    except Exception:
        pass

    log(f"  >>> Cashout button NOT FOUND by any method")
    return False


def cashout(page, frame, multiplier_at_cashout):
    """Dual cashout: try button click + page refresh. Returns (new_frame, cashout_info)."""
    cashout_info = {"button_clicked": False, "refresh_done": False}

    # First attempt — immediate button click
    clicked = click_cashout_button(frame)
    if clicked:
        cashout_info["button_clicked"] = True
        # Give the server a moment to process the button cashout
        try:
            page.wait_for_timeout(300)
        except Exception:
            pass
    else:
        # Retry once after a short wait (button may appear with delay on VPS)
        try:
            page.wait_for_timeout(200)
        except Exception:
            pass
        clicked = click_cashout_button(frame)
        if clicked:
            cashout_info["button_clicked"] = True
        else:
            log(f"  >>> Cashout button not found after 2 attempts — using refresh only")

    log(f"  >>> REFRESHING PAGE at {multiplier_at_cashout:.2f}x — backup cashout")
    page.reload(wait_until="domcontentloaded", timeout=30000)
    cashout_info["refresh_done"] = True
    try:
        page.wait_for_load_state("load", timeout=15000)
    except Exception:
        pass
    human_delay(2, 4)

    page.locator("iframe[src*='spribegaming.com/aviator']").first.wait_for(
        state="visible", timeout=25000)
    new_frame = page.frame_locator("iframe[src*='spribegaming.com/aviator']").first
    new_frame.locator("button.btn-success.bet").wait_for(state="visible", timeout=20000)
    return new_frame, cashout_info


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  WAIT FOR BET BUTTON                                                ║
# ╚══════════════════════════════════════════════════════════════════════╝

def wait_for_bet_button(frame, timeout=120):
    start = time.time()
    while time.time() - start < timeout:
        try:
            btn = frame.locator("button.btn-success.bet").first
            if btn.count() > 0 and btn.is_visible(timeout=500):
                text = (btn.text_content(timeout=500) or "").strip().lower()
                if "bet" in text:
                    try:
                        if not btn.is_disabled(timeout=300):
                            return True
                    except Exception:
                        pass
        except Exception:
            pass
        time.sleep(0.3)
    return False


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  WAIT FOR CRASH                                                     ║
# ╚══════════════════════════════════════════════════════════════════════╝

def wait_for_crash(page, count=1):
    initial = state.crash_count
    target = initial + count
    seen = 0
    start = time.time()

    log(f"  Waiting for {count} crash before betting...")

    while state.crash_count < target:
        if not state.ws_connected:
            log("  WebSocket disconnected!", "WARN")
            return False
        if time.time() - state.last_ws_time > 60:
            log("  No WS data for 60s!", "WARN")
            return False
        if time.time() - start > 300:
            log("  Timeout waiting for crash (5 min)", "WARN")
            return False

        new_seen = state.crash_count - initial
        if new_seen > seen:
            seen = new_seen
            log(f"  Crash observed ({state.last_crash_value:.2f}x) — fresh round starting!")

        page.wait_for_timeout(200)

    return True


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  CHECK WEBSOCKET HEALTH + AUTO-RECOVERY                             ║
# ╚══════════════════════════════════════════════════════════════════════╝

MAX_WS_RECOVERY_ATTEMPTS = 3

def check_ws_health():
    """Returns True if WS is healthy. Does NOT pause on failure."""
    if not state.ws_connected:
        return False
    stale = time.time() - state.last_ws_time
    if stale > 45:
        return False
    return True


def recover_ws(page):
    """
    Attempt to recover a broken WebSocket by reloading the page
    and re-navigating to the Aviator game.
    Returns (success: bool, new_frame).
    """
    for attempt in range(1, MAX_WS_RECOVERY_ATTEMPTS + 1):
        log(f"  >> WS Recovery attempt {attempt}/{MAX_WS_RECOVERY_ATTEMPTS}...", "WARN")

        try:
            # Reset WS state
            state.ws_connected = False
            state.ws_msg_count = 0
            state.last_ws_time = time.time()
            state.crashed = False
            state.round_active = False
            state.betting_open = False

            # Reload page
            log(f"     Reloading page...")
            page.reload(wait_until="domcontentloaded", timeout=30000)
            page.wait_for_timeout(3000)

            # Re-find Aviator iframe
            log(f"     Looking for Aviator iframe...")
            frame = None
            IFRAME_SELECTORS = [
                "iframe[src*='spribegaming.com/aviator']",
                "iframe[src*='spribegaming.com']",
                "iframe[src*='aviator']",
                "iframe.game-iframe",
                "iframe",
            ]
            for sel in IFRAME_SELECTORS:
                try:
                    loc = page.locator(sel).first
                    if loc.is_visible(timeout=5000):
                        frame = page.frame_locator(sel).first
                        log(f"     Found iframe: {sel}")
                        break
                except Exception:
                    continue

            if not frame:
                log(f"     Iframe not found, retrying...", "WARN")
                page.wait_for_timeout(5000)
                continue

            # Wait for WS to reconnect
            log(f"     Waiting for WebSocket to reconnect...")
            t0 = time.time()
            while time.time() - t0 < 30:
                if state.ws_connected and state.ws_msg_count > 0:
                    break
                page.wait_for_timeout(500)

            if not state.ws_connected:
                log(f"     WS still disconnected after 30s", "WARN")
                continue

            # Wait for game to stabilize
            log(f"     WS reconnected! Waiting for game to stabilize...")
            t0 = time.time()
            while time.time() - t0 < 30:
                if state.crashed or state.betting_open:
                    break
                if wait_for_bet_button(frame, timeout=2):
                    break
                page.wait_for_timeout(500)

            log(f"  >> WS Recovery SUCCESSFUL! Resuming bot.", "INFO")
            return True, frame

        except Exception as e:
            log(f"     Recovery attempt {attempt} failed: {e}", "ERROR")
            page.wait_for_timeout(5000)

    log(f"  >> WS Recovery FAILED after {MAX_WS_RECOVERY_ATTEMPTS} attempts.", "ERROR")
    return False, None


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  MONITOR ROUND                                                      ║
# ║  Watches WS multiplier. Cashout target is dynamic per level.        ║
# ║  Returns: ("win", multiplier, new_frame)                            ║
# ║           ("loss", multiplier, frame)                                ║
# ║           ("error", 0.0, frame)                                     ║
# ╚══════════════════════════════════════════════════════════════════════╝

def monitor_round(page, frame, cashout_target):
    """Monitor round. Cashout at >= cashout_target."""

    # Phase 1: Wait for takeoff
    log("  Waiting for plane to take off...")
    t0 = time.time()
    started = False

    while time.time() - t0 < 60:
        if state.round_active:
            mult = state.get_multiplier()
            if mult >= 1.0:
                started = True
                log(f"  Plane flying! {mult:.2f}x  (target: {cashout_target:.0f}x)")
                break
        if state.crashed:
            log("  Plane crashed before takeoff!", "WARN")
            return ("loss", state.get_multiplier(), frame, None)
        page.wait_for_timeout(50)

    if not started:
        log("  Timeout waiting for takeoff (60s)", "WARN")
        return ("error", 0.0, frame, None)

    # Phase 2: Monitor multiplier
    log(f"  Monitoring... cashout target: >= {cashout_target:.0f}x")
    t0 = time.time()
    last_log = 0.0

    while True:
        current = state.get_multiplier()

        # CASHOUT
        if current >= cashout_target:
            new_frame, cashout_info = cashout(page, frame, current)
            return ("win", current, new_frame, cashout_info)

        # Log milestones — every 1x for high targets, more frequent near target
        step = 5.0 if cashout_target > 20 else 1.0
        if current >= 1.0 and current >= last_log + step:
            pct = (current / cashout_target) * 100
            log(f"    {current:.2f}x  ({pct:.0f}% of target)")
            last_log = current

        # Crash
        if state.crashed:
            return ("loss", current, frame, None)

        # Timeout
        if time.time() - t0 > 300:
            log("  Round timeout (5 min)", "WARN")
            return ("error", 0.0, frame, None)

        page.wait_for_timeout(20)


# ╔══════════════════════════════════════════════════════════════════════╗
# ║  MAIN BOT LOOP                                                      ║
# ╚══════════════════════════════════════════════════════════════════════╝

def _run_once():
    # ── Startup Banner ──
    total_normal = sum(s for lvl in LEVELS for s in lvl["stakes"])
    total_recov = sum(s for lvl in RECOVERY_LEVELS for s in lvl["stakes"])
    print()
    print("  " + "=" * 62)
    print("  ||                                                          ||")
    print("  ||   cjavi — Aviator Multi-Level Bot + Recovery             ||")
    print("  ||                                                          ||")
    print(f"  ||   Normal Levels: {len(LEVELS)}  |  Recovery Levels: {len(RECOVERY_LEVELS)}")
    print(f"  ||   Normal risk:   {total_normal:.0f} UGX")
    print(f"  ||   Recovery risk: {total_recov:.0f} UGX")
    print(f"  ||   Recovery target: recover recorded losses (loss debt)")
    print("  ||   Stop-loss: DISABLED")
    print("  ||                                                          ||")
    print("  " + "=" * 62)
    print()

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=False)
        context = browser.new_context(
            viewport={"width": 1280, "height": 800}, locale="en-US")
        page = context.new_page()
        setup_ws_interception(page)

        try:
            # Login (with retry + cooldown after repeated failures)
            login_with_retries(page)
            human_delay(1, 2)

            # Navigate
            frame = navigate_to_aviator(page)
            session_start_balance = get_balance(frame)
            if session_start_balance:
                log(f"Starting balance: {session_start_balance:.2f} UGX")

            # Record initial balance for this account
            acct_key = f"***{get_current_account()['mobile'][-4:]}"
            if acct_key not in account_balances:
                account_balances[acct_key] = {
                    "start": round(session_start_balance, 2) if session_start_balance else 0,
                    "current": round(session_start_balance, 2) if session_start_balance else 0,
                }
            else:
                # Update current balance on reconnect
                if session_start_balance:
                    account_balances[acct_key]["current"] = round(session_start_balance, 2)

            # Try to resume from saved state
            resumed = strat.load_state()
            if resumed:
                cycle_balance = strat.cycle_balance if strat.cycle_balance > 0 else (session_start_balance or 0)
                log(f"  Cycle balance (restored): {cycle_balance:.0f} UGX")
            else:
                # Fresh start — cycle_balance = current balance
                cycle_balance = session_start_balance or 0

            # Wait for WS
            log("Waiting for WebSocket connection...")
            page.wait_for_timeout(5000)
            if state.ws_connected:
                log(f"WebSocket connected! ({state.ws_msg_count} messages)")
            else:
                log("WebSocket not detected yet", "WARN")

            # Wait for stable state
            log("Waiting for game to stabilize...")
            t0 = time.time()
            while time.time() - t0 < 60:
                if state.crashed or state.betting_open:
                    break
                if wait_for_bet_button(frame, timeout=2):
                    break
                page.wait_for_timeout(500)

            log_section("GAME SESSION STARTED")

            # ═══════════════════════════════════════════
            #  MAIN LOOP
            # ═══════════════════════════════════════════
            round_num = getattr(strat, '_round_num', 0)

            while True:

                # ── Check dashboard commands ──
                dash_cmd = check_dashboard_command()
                if dash_cmd == "stop":
                    log_section("STOPPED FROM DASHBOARD")
                    strat._round_num = round_num
                    strat.save_state()
                    write_status(
                        strat, balance=get_balance(frame),
                        cycle_balance=cycle_balance,
                        session_start_balance=session_start_balance,
                        current_round=round_num,
                        ws_connected=state.ws_connected,
                        bot_state="stopped"
                    )
                    log("Bot stopped by dashboard command. Exiting cleanly.")
                    sys.exit(0)
                elif dash_cmd == "restart":
                    log_section("RESTARTING FRESH FROM DASHBOARD")
                    strat.clear_saved_state()
                    round_history.clear()
                    account_balances.clear()
                    write_status(
                        strat, balance=get_balance(frame),
                        bot_state="restarting"
                    )
                    log("State cleared. Bot restarting fresh.")
                    sys.exit(1)

                # ── Dashboard paused? (wait loop — don't exit, just sleep) ──
                if strat.dashboard_paused:
                    if not getattr(strat, '_dash_pause_logged', False):
                        log_section("PAUSED FROM DASHBOARD")
                        log("  Waiting for resume command from dashboard...")
                        strat._dash_pause_logged = True
                        write_status(
                            strat, balance=get_balance(frame),
                            cycle_balance=cycle_balance,
                            session_start_balance=session_start_balance,
                            current_round=round_num,
                            ws_connected=state.ws_connected,
                            bot_state="dashboard_paused"
                        )
                    page.wait_for_timeout(3000)
                    dash_cmd = check_dashboard_command()
                    if dash_cmd == "stop":
                        log("Bot stopped by dashboard command during pause.")
                        strat._round_num = round_num
                        strat.save_state()
                        sys.exit(0)
                    elif dash_cmd == "restart":
                        log("Bot restarting fresh by dashboard command during pause.")
                        strat.clear_saved_state()
                        round_history.clear()
                        account_balances.clear()
                        sys.exit(1)
                    if strat.dashboard_paused:
                        continue
                    else:
                        strat._dash_pause_logged = False
                        log_section("RESUMED FROM DASHBOARD")

                # ── Paused? (unrecoverable: stop-loss / exhausted) ──
                if strat.paused:
                    log_section("BOT PAUSED")
                    log(f"  REASON: {strat.pause_reason}", "PAUSE")
                    print()
                    for line in strat.detail_lines():
                        log(line)
                    bal = get_balance(frame)
                    if bal:
                        log(f"  Balance    : {bal:.0f} UGX")
                    print()
                    strat._round_num = round_num
                    strat.save_state()
                    write_status(
                        strat, balance=bal, cycle_balance=cycle_balance,
                        session_start_balance=session_start_balance,
                        current_round=round_num,
                        ws_connected=state.ws_connected,
                        bot_state="paused"
                    )
                    log("Bot stopped. Will auto-restart via service.")
                    break

                # ── WS health — auto-recover if disconnected ──
                if not check_ws_health():
                    reason = "DISCONNECTED" if not state.ws_connected else "STALE"
                    log(f"  WebSocket {reason} — pausing bets, attempting recovery...", "WARN")

                    strat.cycle_balance = cycle_balance
                    strat._round_num = round_num
                    strat.save_state()

                    recovery_attempt = 0
                    while True:
                        recovery_attempt += 1
                        log(f"  WS recovery attempt #{recovery_attempt}...", "WARN")

                        write_status(
                            strat, balance=get_balance(frame) if frame else None,
                            cycle_balance=cycle_balance,
                            session_start_balance=session_start_balance,
                            current_round=round_num, ws_connected=False,
                            bot_state="recovering",
                            extra={"error": f"WebSocket {reason} — recovery attempt #{recovery_attempt}"}
                        )

                        success, new_frame = recover_ws(page)
                        if success and new_frame:
                            frame = new_frame
                            log(f"  Recovery SUCCESSFUL on attempt #{recovery_attempt}!")
                            log(f"  Continuing from {strat.position_str()}")
                            break

                        log(f"  Recovery attempt #{recovery_attempt} failed.", "ERROR")
                        log(f"  Waiting 3 minutes before next attempt...", "WARN")

                        write_status(
                            strat, cycle_balance=cycle_balance,
                            session_start_balance=session_start_balance,
                            current_round=round_num, ws_connected=False,
                            bot_state="recovering",
                            extra={"error": f"WebSocket {reason} — retry #{recovery_attempt} failed. "
                                            f"Next retry in 3 min."}
                        )

                        for _ in range(36):
                            page.wait_for_timeout(5000)
                            dc = check_dashboard_command()
                            if dc == "stop":
                                log("Stop command during recovery. Exiting.")
                                strat.save_state()
                                sys.exit(0)
                            elif dc == "restart":
                                log("Restart command during recovery. Restarting.")
                                strat.save_state()
                                sys.exit(1)
                    continue


                round_num += 1
                current_stake = strat.current_stake
                current_cashout = strat.current_cashout

                # ─── Round Header ───
                log_divider()
                log(f"ROUND #{round_num}  [{strat.position_str()}]")
                log(f"")
                for line in strat.detail_lines():
                    log(line)
                print()

                # ── Step 1: Wait for betting phase (bet every round) ──
                state.reset_round()
                log(f"  Waiting for betting phase...")

                bet_ready = False
                t0 = time.time()
                while time.time() - t0 < 120:
                    if state.betting_open:
                        if wait_for_bet_button(frame, timeout=5):
                            bet_ready = True
                            break
                    if wait_for_bet_button(frame, timeout=2):
                        if not state.round_active or state.get_multiplier() < 1.0:
                            bet_ready = True
                            break
                        else:
                            page.wait_for_timeout(1000)
                            continue
                    page.wait_for_timeout(300)

                if not bet_ready:
                    log("  Timeout — refreshing page", "WARN")
                    page.reload(wait_until="domcontentloaded", timeout=30000)
                    human_delay(3, 5)
                    try:
                        page.locator("iframe[src*='spribegaming.com/aviator']").first.wait_for(
                            state="visible", timeout=20000)
                        frame = page.frame_locator(
                            "iframe[src*='spribegaming.com/aviator']").first
                        frame.locator("button.btn-success.bet").wait_for(
                            state="visible", timeout=15000)
                    except Exception:
                        log("  Reload failed", "ERROR")
                        page.wait_for_timeout(5000)
                    continue

                # ── Step 3: Place bet ──
                # Capture balance BEFORE bet for phantom detection
                balance_before_bet = get_balance(frame) or 0
                if balance_before_bet > 0:
                    log(f"  Pre-bet balance: {balance_before_bet:.0f} UGX")

                log(f"  Placing bet: {current_stake} UGX  "
                    f"[{strat.position_str()}]  "
                    f"(cashout target: {current_cashout:.0f}x)")
                human_delay(0.2, 0.5)

                if not place_bet(frame, current_stake):
                    log("  Bet failed — retrying", "ERROR")
                    page.wait_for_timeout(5000)
                    continue

                log(f"  BET PLACED: {current_stake} UGX")

                # Reset for monitoring
                state.crashed = False
                state.round_active = False
                state.set_multiplier(0.0)

                # ── Step 5: Monitor round ──
                result, mult, frame, cashout_info = monitor_round(page, frame, current_cashout)

                # ═══════════════════════════════════════
                #  PROCESS RESULT
                # ═══════════════════════════════════════
                print()

                if result == "win":
                    # Verify with balance — compare against PRE-BET balance
                    balance_after = get_balance_reliable(frame, page, retries=3, delay_ms=1500)
                    phantom = False

                    if balance_before_bet > 0 and balance_after is not None:
                        log(f"  Balance check: pre-bet {balance_before_bet:.0f} → "
                            f"now {balance_after:.0f} UGX")
                        if balance_after <= balance_before_bet:
                            phantom = True

                    if phantom:
                        # Override result so dashboard/history show phantom, not win
                        result = "phantom"
                        log(f"  !!! PHANTOM CASHOUT !!!", "WARN")
                        log(f"  Refreshed at {mult:.2f}x but balance did not increase.")
                        log(f"  Pre-bet: {balance_before_bet:.0f} → "
                            f"Now: {balance_after:.0f} UGX")
                        log(f"  Recording as LOSS (-{current_stake} UGX)")

                        strat.record_loss(current_stake)
                        action = strat.advance_after_loss()

                        if action == "next_stake":
                            log(f"  >> Next stake: {strat.current_stake} UGX "
                                f"[{strat.position_str()}]")
                        elif action == "next_level":
                            print()
                            log(f"  >> Level exhausted! Moving to {strat.position_str()} "
                                f"(cashout {strat.current_cashout:.0f}x, "
                                f"starting stake {strat.current_stake} UGX)")
                        elif action == "exhausted":
                            # Normal levels exhausted → wait 20/30/40 min then enter recovery
                            wait_before_recovery(page)
                            strat.enter_recovery()
                            # Reset cycle_balance to current balance for recovery
                            bal_now = get_balance(frame) or balance_after or 0
                            cycle_balance = bal_now
                            strat.cycle_balance = cycle_balance
                            log(f"  >> Cycle balance reset to {cycle_balance:.0f} UGX for recovery")
                        elif action == "recovery_exhausted":
                            strat.pause(
                                f"All {len(RECOVERY_LEVELS)} RECOVERY levels EXHAUSTED.\n"
                                f"         Recovery profit: {strat.recovery_profit:+.0f} UGX\n"
                                f"         Session profit: {strat.session_profit:+.0f} UGX.")

                    else:
                        # ── GENUINE WIN ──
                        profit = strat.record_win(current_stake, mult)
                        payout = current_stake * mult

                        log(f"  +++ WIN (VERIFIED) +++")
                        log(f"  Cashed out at {mult:.2f}x")
                        log(f"  Bet: {current_stake} UGX  -->  "
                            f"Payout: {payout:.1f} UGX  (+{profit:.1f} UGX)")
                        if balance_after:
                            log(f"  Balance: {balance_after:.0f} UGX "
                                f"(pre-bet: {balance_before_bet:.0f})")

                        # Apply win profit toward outstanding loss debt.
                        debt_before = strat.loss_debt
                        if debt_before > 0:
                            strat.loss_debt = max(0.0, strat.loss_debt - profit)
                            log(f"  >> Debt: {debt_before:.1f} → {strat.loss_debt:.1f} UGX")

                        losses_recovered = (strat.loss_debt <= 0.0001)

                        if losses_recovered:
                            # Losses recovered → reset to initial stake (normal mode)
                            if strat.recovery_mode:
                                strat.exit_recovery()
                                log("  >> Losses recovered. Exiting recovery → back to L1/S1")
                            else:
                                strat.reset_after_win()
                                log(f"  >> Losses recovered. Reset to L1/S1 ({strat.current_stake} UGX)")
                        else:
                            # Losses NOT recovered → advance to next stake until recovered
                            action = strat.advance_after_loss()
                            if action == "next_stake":
                                log(f"  >> Losses not recovered. Next stake: {strat.current_stake} UGX "
                                    f"[{strat.position_str()}]")
                            elif action == "next_level":
                                print()
                                log(f"  >> Losses not recovered. Level exhausted → {strat.position_str()}")
                                log(f"     Cashout: {strat.current_cashout:.1f}x  |  "
                                    f"Starting stake: {strat.current_stake} UGX")
                            elif action == "exhausted":
                                wait_before_recovery(page)
                                strat.enter_recovery()
                                bal_now = get_balance(frame) or balance_after or 0
                                cycle_balance = bal_now
                                strat.cycle_balance = cycle_balance
                                log(f"  >> Cycle balance reset to {cycle_balance:.0f} UGX for recovery")
                            elif action == "recovery_exhausted":
                                strat.pause(
                                    f"All {len(RECOVERY_LEVELS)} RECOVERY levels EXHAUSTED.\n"
                                    f"         Loss debt remaining: {strat.loss_debt:.0f} UGX\n"
                                    f"         Recovery profit: {strat.recovery_profit:+.0f} UGX\n"
                                    f"         Session profit: {strat.session_profit:+.0f} UGX.")

                        # After any verified win, wait before the next bet
                        wait_after_verified_win(page)

                        # Switch accounts only after N verified wins
                        if strat.wins_since_switch >= SWITCH_ACCOUNTS_AFTER_WINS:
                            # Save state before switching
                            strat.save_state()

                            # Record final balance of current account before switching
                            acct_before = get_current_account()
                            acct_before_key = f"***{acct_before['mobile'][-4:]}"
                            if balance_after and acct_before_key in account_balances:
                                account_balances[acct_before_key]["current"] = round(balance_after, 2)

                            log(f"  >> Switching from {acct_before_key} to next account "
                                f"(after {strat.wins_since_switch} wins)...")

                            write_status(
                                strat, balance=balance_after, cycle_balance=cycle_balance,
                                session_start_balance=session_start_balance,
                                current_round=round_num, last_result="win",
                                last_mult=mult, ws_connected=state.ws_connected,
                                bot_state="running",
                                extra={"switching_account": True, "switch_reason": "wins_threshold"}
                            )

                            wait_before_account_switch(page)
                            success, new_frame = switch_account_and_relogin(page)
                            if success and new_frame:
                                frame = new_frame
                                strat.wins_since_switch = 0
                                # Read new account balance as new cycle balance
                                new_bal = get_balance(frame)
                                cycle_balance = new_bal or 0
                                strat.cycle_balance = cycle_balance
                                session_start_balance = new_bal or session_start_balance

                                # Record new account's balance
                                acct_now = get_current_account()
                                acct_now_key = f"***{acct_now['mobile'][-4:]}"
                                if acct_now_key not in account_balances:
                                    account_balances[acct_now_key] = {
                                        "start": round(new_bal, 2) if new_bal else 0,
                                        "current": round(new_bal, 2) if new_bal else 0,
                                    }
                                else:
                                    if new_bal:
                                        account_balances[acct_now_key]["current"] = round(new_bal, 2)

                                log(f"  >> Now on {acct_now_key}  "
                                    f"|  Balance: {cycle_balance:.0f} UGX")
                                strat.save_state()
                            else:
                                log(f"  >> Account switch failed — continuing on same account", "WARN")
                                cycle_balance = balance_after if balance_after else get_balance(frame) or 0
                                strat.cycle_balance = cycle_balance

                elif result == "loss":
                    strat.record_loss(current_stake)

                    log(f"  --- LOSS ---")
                    log(f"  Crashed at {mult:.2f}x  (needed {current_cashout:.0f}x)")
                    log(f"  Lost: {current_stake} UGX")

                    action = strat.advance_after_loss()

                    if action == "next_stake":
                        log(f"  >> Next stake: {strat.current_stake} UGX "
                            f"[{strat.position_str()}]")
                    elif action == "next_level":
                        print()
                        log(f"  >> Level exhausted! Moving to {strat.position_str()}")
                        log(f"     Cashout: {strat.current_cashout:.0f}x  |  "
                            f"Starting stake: {strat.current_stake} UGX")
                    elif action == "exhausted":
                        # Normal levels exhausted → wait 20/30/40 min then enter recovery mode
                        wait_before_recovery(page)
                        strat.enter_recovery()
                        bal_now = get_balance(frame) or 0
                        cycle_balance = bal_now
                        strat.cycle_balance = cycle_balance
                        log(f"  >> Cycle balance reset to {cycle_balance:.0f} UGX for recovery")
                    elif action == "recovery_exhausted":
                        strat.pause(
                            f"All {len(RECOVERY_LEVELS)} RECOVERY levels EXHAUSTED.\n"
                            f"         Recovery profit: {strat.recovery_profit:+.0f} UGX\n"
                            f"         Total wagered: {strat.total_wagered:.1f} UGX\n"
                            f"         Session profit: {strat.session_profit:+.0f} UGX.")

                elif result == "error":
                    strat.record_loss(current_stake)
                    log(f"  --- ERROR (counted as loss) ---")
                    log(f"  Lost: {current_stake} UGX")

                    action = strat.advance_after_loss()
                    if action == "next_stake":
                        log(f"  >> Next: {strat.current_stake} UGX [{strat.position_str()}]")
                    elif action == "next_level":
                        log(f"  >> Level exhausted → {strat.position_str()}")
                    elif action == "exhausted":
                        wait_before_recovery(page)
                        strat.enter_recovery()
                        bal_now = get_balance(frame) or 0
                        cycle_balance = bal_now
                        strat.cycle_balance = cycle_balance
                        log(f"  >> Cycle balance reset to {cycle_balance:.0f} UGX for recovery")
                    elif action == "recovery_exhausted":
                        strat.pause(
                            f"All RECOVERY levels exhausted.\n"
                            f"         Session: {strat.session_profit:+.0f} UGX")

                # ── Post-round summary ──
                print()
                bal = get_balance(frame)

                # Update current account balance tracker
                acct_key = f"***{get_current_account()['mobile'][-4:]}"
                if bal and acct_key in account_balances:
                    account_balances[acct_key]["current"] = round(bal, 2)
                log(f"  ┌─────────────────────────────────────────────────")
                log(f"  │ Mode         : {strat.mode_str()}")
                log(f"  │ Balance      : {f'{bal:.0f}' if bal else '???'} UGX")
                log(f"  │ Session P/L  : {strat.session_profit:+.1f} UGX")
                log(f"  │ Record       : {strat.total_wins}W / {strat.total_losses}L "
                    f"({strat.total_bets} bets)")
                if not strat.paused:
                    log(f"  │ Next bet     : {strat.current_stake} UGX "
                        f"[{strat.position_str()}] @ {strat.current_cashout:.0f}x")
                log(f"  │ Highest      : Level {strat.highest_level + 1}  |  "
                    f"Stake {strat.highest_stake:.1f} UGX  |  "
                    f"Best win: +{strat.biggest_win_profit:.1f}")
                if strat.recovery_mode:
                    log(f"  │ Recovery     : {strat.recovery_profit:+.1f} UGX  |  "
                        f"Debt: {strat.loss_debt:.1f} UGX")
                log(f"  └─────────────────────────────────────────────────")

                # ── Dashboard update ──
                rnd_profit = 0
                if result == "win":
                    rnd_profit = current_stake * (mult - 1)
                elif result in ("loss", "error", "phantom"):
                    rnd_profit = -current_stake

                add_round_to_history(
                    round_num, result, current_stake, current_cashout,
                    mult, rnd_profit, bal,
                    strat.level_idx, strat.stake_idx,
                    cashout_info=cashout_info,
                    position_str=strat.position_str()
                )
                write_status(
                    strat, balance=bal, cycle_balance=cycle_balance,
                    session_start_balance=session_start_balance,
                    current_round=round_num,
                    last_result=result, last_mult=mult,
                    ws_connected=state.ws_connected,
                    bot_state="paused" if strat.paused else "running"
                )

                human_delay(1, 2)

        except KeyboardInterrupt:
            log_section("STOPPED BY USER (Ctrl+C)")
            for line in strat.detail_lines():
                log(line)
            bal = get_balance(frame) if 'frame' in dir() else None
            if bal:
                log(f"  Final balance: {bal:.0f} UGX")
            strat.cycle_balance = cycle_balance if 'cycle_balance' in dir() else 0
            strat._round_num = round_num if 'round_num' in dir() else 0
            strat.save_state()
            write_status(strat, balance=bal, bot_state="stopped",
                         current_round=round_num if 'round_num' in dir() else 0,
                         ws_connected=False)
        except Exception as e:
            log_section("FATAL ERROR")
            log(f"  {e}", "ERROR")
            import traceback
            traceback.print_exc()
            print()
            for line in strat.detail_lines():
                log(line)
            strat.cycle_balance = cycle_balance if 'cycle_balance' in dir() else 0
            strat._round_num = round_num if 'round_num' in dir() else 0
            strat.save_state()
            write_status(strat, bot_state="error",
                         extra={"error": str(e)},
                         ws_connected=False)
            raise
        finally:
            try:
                browser.close()
            except Exception:
                pass
            log("Browser closed.")


def run():
    """Main entry — wraps _run_once in a retry loop for fatal errors."""
    error_count = 0
    while True:
        try:
            _run_once()
            break
        except SystemExit:
            raise
        except KeyboardInterrupt:
            break
        except Exception as e:
            error_count += 1
            log_section(f"FATAL ERROR — RETRY #{error_count}")
            log(f"  {e}", "ERROR")
            log(f"  Bot will retry in 3 minutes...", "WARN")
            write_status(strat, bot_state="error",
                         extra={"error": f"Fatal error #{error_count}: {str(e)[:100]}. Retrying in 3 min."},
                         ws_connected=False)
            for _ in range(36):
                time.sleep(5)
                for fpath in [CMD_FILE, PAUSE_CMD_FILE]:
                    try:
                        if fpath.exists():
                            cmd = json.load(open(fpath))
                            fpath.unlink(missing_ok=True)
                            if cmd.get("command") == "stop":
                                log("Stop command received during error recovery. Exiting.")
                                sys.exit(0)
                    except Exception:
                        pass


if __name__ == "__main__":
    run()
