#!/usr/bin/env python3
"""Backend do Aviator Signals — recebe crashes do snippet DevTools e serve o dashboard."""

import html
import json
import math
import os
import queue
import ssl
import time
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from collections import deque
from threading import Lock

import db
import version as _version
import updater
import license as _license
import cloud_sync
import cenario as _cenario
import balance_reader as _balance

PORT = 8766
CERT_FILE = os.path.join(os.path.dirname(__file__), 'cert.pem')
KEY_FILE = os.path.join(os.path.dirname(__file__), 'key.pem')
STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')

state = {
    'crashes': deque(maxlen=2000),  # janela rolante pra cálculos estatísticos
    'crashes_total': 0,              # contador cumulativo desde o start (sem perder contagem)
    'signals': deque(maxlen=500),
    'session_start': time.time(),
    'last_ws_msg_at': None,
    'ws_connected': False,
    'ws_msgs_received': 0,
}
lock = Lock()

# ============================================================
# AUTO-CLICK (isolado do sistema principal)
# ============================================================
autoclick = {
    'config': {},
    'stats': {
        'bets_today': 0,
        'pnl_today': 0.0,
        'loss_streak': 0,
        'last_reset': time.time(),
    },
    'paused_until': 0.0,  # timestamp unix; 0 = sem parada técnica
    'pause_reason': '',
    # Stats de "shadow bets" (simulação silenciosa) durante parada técnica.
    # São zeradas ao iniciar uma nova parada e a cada janela de 8 sinais.
    # `shadow_results` guarda o detalhe de cada sinal pra UI mostrar.
    'shadow_results': [],  # [{ts, panel, target, hit, crash}]
    'shadow_stats': {
        0: {'bets': 0, 'wins': 0, 'losses': 0},
        1: {'bets': 0, 'wins': 0, 'losses': 0},
    },
    # Estado de cooldown por painel (reportado pelo daemon); {remaining, kind}
    'panel_cooldowns': {
        0: {'remaining': 0, 'kind': None},
        1: {'remaining': 0, 'kind': None},
    },
    # Stats POR PAINEL (0 e 1). Inclui histórico da IA pra aprendizado.
    'panels_stats': {
        0: {'wins': 0, 'losses': 0, 'ia_approves': 0, 'ia_wins': 0, 'ia_losses': 0},
        1: {'wins': 0, 'losses': 0, 'ia_approves': 0, 'ia_wins': 0, 'ia_losses': 0},
    },
    # Rastreia última decisão da IA por painel (pra creditar win/loss quando o evento chegar)
    'ia_last_approval': {0: False, 1: False},
    'events': deque(maxlen=50),
    # 1.17.0 — calibração inicial ao ligar bot
    'calibration': {'remaining': 0, 'total': 0},
    # 1.17.0 — parada técnica simples: aguarda N rodadas e libera
    'tech_pause_start_total': 0,
    'tech_pause_total_rounds': 20,
    # 1.17.0 — incrementa a cada apply do cenário; UI usa pra detectar e refrescar form
    'cfg_revision': 0,
}
autoclick_lock = Lock()

# ============================================================
# CENÁRIO (1.17.0) — análise + gestão de risco
# ============================================================
cenario_state = {
    'config': {
        'enabled': True,         # auto-análise ON/OFF
        'interval': 100,         # rodar a cada N novas rodadas
        'bankroll': 0.0,         # saldo atual (auto-detectado ou manual)
        'target_profit': 0.0,    # objetivo do dia
        # 1.17.0 — gestão automática por variação de saldo
        'auto_apply_on_balance_change': False,
        'balance_change_threshold': 0.20,  # 20%
        # 1.17.0 — gestão automática por intervalo de rodadas
        'auto_apply_interval_rounds': 20,  # auto-aplica a cada N rodadas da plataforma
    },
    'last_analysis': None,       # dict do último resultado
    'last_run_at': 0.0,
    'last_run_total': 0,         # api_total na última análise
    'last_applied_bankroll': 0.0,  # saldo no momento do último apply auto
    'last_applied_total': 0,     # api_total no último apply auto (gatilho rodadas)
    'pending_popup': None,       # análise não-vista pendente (popup queue)
}
cenario_lock = Lock()

CENARIO_CFG_PATH = os.path.join(os.path.dirname(__file__), 'data', 'cenario.json')

# ============================================================
# SCHEDULE (1.19.0) — descoberta de janelas ótimas
# ============================================================
schedule_state = {
    'config': {
        'enabled': False,
        'window_days': 28,
        'edge_threshold_pp': 5.0,
        'min_samples': 30,
    },
    'cells': [],          # lista de {dow, hour, target, n, hits, hit_rate, edge_pp}
    'recomputed_at': 0.0,
    'in_window': False,   # hora atual está em janela boa?
    'top_targets_now': [],  # targets disponíveis na hora atual
}
schedule_lock = Lock()
SCHEDULE_CFG_PATH = os.path.join(os.path.dirname(__file__), 'data', 'schedule.json')

# 1.20.0 — Strategy presets engine.
# Quando active_code != None, o daemon sobrescreve P0/P1 com o stake/target da
# rodada atual do schedule. Win em qualquer painel reseta round_idx; loss em
# ambos avança round_idx. Estouro do schedule = parada técnica.
strategy_state = {
    'active_code': None,         # ex: 'MAXIMUS10' ou None (desligado)
    'current_round_idx': 0,      # 0-indexed; rodada atual do schedule
    'wins': 0,                   # wins desta run
    'losses': 0,                 # losses desta run
    'started_at': 0.0,           # ts unix
    'stake_unit': 1.0,           # multiplicador (1.0 = 1 unidade do JSON = R$1)
    'catalog': None,             # carregado lazy de static/strategies.json
}
strategy_lock = Lock()
STRATEGY_CFG_PATH = os.path.join(os.path.dirname(__file__), 'data', 'strategy.json')
STRATEGY_CATALOG_PATH = os.path.join(os.path.dirname(__file__), 'static', 'strategies.json')


def _load_strategy_catalog():
    try:
        with open(STRATEGY_CATALOG_PATH, 'r', encoding='utf-8') as f:
            cat = json.load(f)
        with strategy_lock:
            strategy_state['catalog'] = cat
        n = len(cat.get('strategies', []))
        print(f'strategy catalog: {n} estratégias carregadas')
    except Exception as e:
        print(f'warn: falha load strategy catalog: {e}')


def _strategy_find(code):
    with strategy_lock:
        cat = strategy_state.get('catalog')
    if not cat:
        return None
    for s in cat.get('strategies', []):
        if s.get('code') == code:
            return s
    return None


def _load_strategy_cfg():
    try:
        if os.path.exists(STRATEGY_CFG_PATH):
            with open(STRATEGY_CFG_PATH, 'r', encoding='utf-8') as f:
                saved = json.load(f)
            if isinstance(saved, dict):
                with strategy_lock:
                    for k in ('active_code', 'current_round_idx', 'wins',
                              'losses', 'started_at', 'stake_unit'):
                        if k in saved:
                            strategy_state[k] = saved[k]
                print(f'strategy state restaurado: code={saved.get("active_code")} idx={saved.get("current_round_idx")}')
    except Exception as e:
        print(f'warn: falha load strategy state: {e}')


def _save_strategy_cfg():
    try:
        os.makedirs(os.path.dirname(STRATEGY_CFG_PATH), exist_ok=True)
        with strategy_lock:
            payload = {k: strategy_state[k] for k in
                       ('active_code', 'current_round_idx', 'wins',
                        'losses', 'started_at', 'stake_unit')}
        with open(STRATEGY_CFG_PATH, 'w', encoding='utf-8') as f:
            json.dump(payload, f, indent=2)
    except Exception as e:
        print(f'warn: falha save strategy state: {e}')


def strategy_get_panels_override():
    """Devolve {0:{stake,target}, 1:{stake,target}} se estratégia ativa,
    senão None. Usado pelo daemon ao montar panels_cfg."""
    with strategy_lock:
        code = strategy_state.get('active_code')
        idx = strategy_state.get('current_round_idx', 0)
        unit = float(strategy_state.get('stake_unit', 1.0) or 1.0)
    if not code:
        return None
    s = _strategy_find(code)
    if not s:
        return None
    sched = s.get('schedule') or []
    if not sched:
        return None
    if idx >= len(sched):
        # estouro: clampa na última (daemon detecta via /api/strategy/state)
        idx = len(sched) - 1
    row = sched[idx]
    return {
        0: {
            'stake': float(row.get('b1_stake', 1)) * unit,
            'target': float(row.get('b1_target', 4.0)),
        },
        1: {
            'stake': float(row.get('b2_stake', 1)) * unit,
            'target': float(row.get('b2_target', 10.0)),
        },
    }


# 1.21.0 — Tail detector (padrão N-gram pós-10x).
# Análise empírica em 211k crashes (validada em sequências contíguas):
# após F (≥10x), padrão das 2 anteriores prediz P(próx ≥10x):
#   DDF: 68.25% (edge +59pp), BBF: 60.62%, CCF: 59.43%, AAF: 48.06%, F: 27.79%
# Sinal vale APENAS pra próxima rodada — cada crash novo:
#   1. resolve sinal pendente (se havia)
#   2. avalia se cria novo sinal pra rodada seguinte
TAIL_PATTERNS = {
    'DDF': {'p_target': 0.6825, 'edge_pp': 59.02},
    'BBF': {'p_target': 0.6062, 'edge_pp': 51.40},
    'CCF': {'p_target': 0.5943, 'edge_pp': 50.20},
    'AAF': {'p_target': 0.4806, 'edge_pp': 38.84},
    'F':   {'p_target': 0.2779, 'edge_pp': 18.56},  # mais fraco; opt-in
}
tail_state = {
    'config': {
        'enabled': False,
        'min_edge_pp': 30.0,        # só dispara sinal acima desse edge
        'allow_F_only': False,      # se False, ignora padrão "F" sozinho (só usa AAF+)
        'auto_bet': False,          # 1.22.0 — quando True, daemon força entrada nos painéis
        'stake_p0': 1.0,            # 1.22.0 — stake do painel âncora (target 4x)
        'stake_p1': 2.0,            # 1.22.0 — stake do painel alavanca (target 10x)
        'target_p0': 4.0,
        'target_p1': 10.0,
        'bet_p0': True,             # 1.25.0 — toggle por painel (P0 = âncora 4x)
        'bet_p1': True,             # 1.25.0 — toggle por painel (P1 = alavanca 10x)
        'telegram_alert': False,    # 1.26.0 — envia alerta no Telegram quando sinal dispara
        'telegram_min_edge_pp': 50.0,  # 1.26.0 — só alerta padrões com edge ≥ X (50pp = só DDF/BBF/CCF)
    },
    'pending_signal': None,         # {pattern, edge_pp, p_target, triggered_at, snap_id}
    'last_outcomes': [],            # últimos N sinais já resolvidos: {pattern, won, ts}
    'stats': {'fired': 0, 'won': 0, 'lost': 0, 'by_pattern': {}},
}
tail_lock = Lock()
TAIL_CFG_PATH = os.path.join(os.path.dirname(__file__), 'data', 'tail.json')


def _tail_bucket(v):
    if v < 1.5: return 'A'
    elif v < 2.0: return 'B'
    elif v < 3.0: return 'C'
    elif v < 5.0: return 'D'
    elif v < 10.0: return 'E'
    return 'F'


def _load_tail_cfg():
    try:
        if os.path.exists(TAIL_CFG_PATH):
            with open(TAIL_CFG_PATH, 'r', encoding='utf-8') as f:
                saved = json.load(f)
            if isinstance(saved, dict):
                with tail_lock:
                    tail_state['config'].update(saved)
            print(f'tail config carregada')
    except Exception as e:
        print(f'warn: falha load tail cfg: {e}')


def _save_tail_cfg():
    try:
        os.makedirs(os.path.dirname(TAIL_CFG_PATH), exist_ok=True)
        with tail_lock:
            payload = dict(tail_state['config'])
        with open(TAIL_CFG_PATH, 'w', encoding='utf-8') as f:
            json.dump(payload, f, indent=2)
    except Exception as e:
        print(f'warn: falha save tail cfg: {e}')


def _tail_on_new_crash(value: float, snap_id: int):
    """Chamado após register_crash incrementar o deque.
    1) Resolve pending_signal (se havia, este crash é a "próxima rodada" prevista).
    2) Avalia se as últimas 3 rodadas formam novo padrão e cria sinal pra próxima.
    1.24.1: após resolver um pending, PULA detecção de novo padrão nesta call
    pra evitar bets em rodadas consecutivas (gap mínimo de 1 rodada entre bets)."""
    with tail_lock:
        cfg = dict(tail_state['config'])
        pending = tail_state.get('pending_signal')

    target_p0 = float(cfg.get('target_p0', 4.0))
    p0_hit = value >= target_p0
    just_resolved = False

    # 1) Resolve pending
    if pending is not None:
        won = value >= 10.0
        outcome = {'pattern': pending['pattern'], 'won': won, 'value': value,
                   'ts': time.time(), 'snap_id': snap_id, 'edge_pp': pending['edge_pp'],
                   'p0_hit': p0_hit}
        with tail_lock:
            tail_state['last_outcomes'].append(outcome)
            tail_state['last_outcomes'] = tail_state['last_outcomes'][-50:]
            tail_state['stats']['fired'] += 1
            if won:
                tail_state['stats']['won'] += 1
            else:
                tail_state['stats']['lost'] += 1
            bp = tail_state['stats']['by_pattern'].setdefault(
                pending['pattern'], {'fired': 0, 'won': 0})
            bp['fired'] += 1
            if won: bp['won'] += 1
            tail_state['pending_signal'] = None
        with autoclick_lock:
            tag = '✓' if won else '✗'
            autoclick['events'].append({
                'kind': 'info',
                'msg': f'🎯 Detector cauda [{pending["pattern"]}]: {tag} {value:.2f}x (P0 {"hit" if p0_hit else "miss"})',
            })
        just_resolved = True

    # 1.24.1 — se acabou de resolver, não dispara sinal novo nesta mesma chamada.
    # Garante gap mínimo de 1 rodada entre bets consecutivos.
    if just_resolved:
        return

    # 2) Avalia novo padrão (precisa estar enabled + ter ≥3 crashes)
    if not cfg.get('enabled'):
        return
    with lock:
        recent = list(state['crashes'])
    if len(recent) < 3:
        return
    last3 = [_tail_bucket(c['value']) for c in recent[-3:]]
    seq3 = ''.join(last3)
    new_pat = None
    if seq3 in TAIL_PATTERNS:
        new_pat = seq3
    elif last3[-1] == 'F':
        if last3[0] in ('A','B','C','D','E') and last3[1] in ('A','B','C','D','E') and last3[2] == 'F':
            new_pat = 'AAF'
        elif cfg.get('allow_F_only'):
            new_pat = 'F'
    if not new_pat:
        return
    info = TAIL_PATTERNS[new_pat]
    if info['edge_pp'] < cfg.get('min_edge_pp', 30.0):
        return
    with tail_lock:
        tail_state['pending_signal'] = {
            'pattern': new_pat,
            'edge_pp': info['edge_pp'],
            'p_target': info['p_target'],
            'triggered_at': time.time(),
            'snap_id': snap_id,
        }
    with autoclick_lock:
        autoclick['events'].append({
            'kind': 'info',
            'msg': f'🎯 SINAL CAUDA [{new_pat}] edge=+{info["edge_pp"]:.0f}pp — APOSTE PRÓXIMA @10x',
        })
    # 1.26.0 — Telegram alert quando sinal de alto edge dispara
    try:
        if cfg.get('telegram_alert') and info['edge_pp'] >= float(cfg.get('telegram_min_edge_pp', 50.0)):
            _tg_msg = (
                f'🎯 <b>SINAL CAUDA</b> — <code>{new_pat}</code>\n'
                f'Edge: <b>+{info["edge_pp"]:.1f}pp</b>\n'
                f'P(próx ≥10×): <b>{info["p_target"]*100:.1f}%</b>\n'
                f'Bot vai apostar na próxima rodada (modo Pós-10x)'
            )
            threading.Thread(target=_telegram_send_sync, args=(_tg_msg,), daemon=True).start()
    except Exception as _e:
        print(f'[telegram_tail] {_e}')


def strategy_record_outcome(any_panel_won: bool):
    """Hook após resolver uma rodada do schedule.
    any_panel_won=True → reset round_idx; False → round_idx += 1."""
    with strategy_lock:
        if not strategy_state.get('active_code'):
            return
        if any_panel_won:
            strategy_state['current_round_idx'] = 0
            strategy_state['wins'] += 1
        else:
            strategy_state['current_round_idx'] += 1
            strategy_state['losses'] += 1
    _save_strategy_cfg()


# 1.20.0 — rastreador per-snap de outcomes pra estratégia.
# Snap = crashes_total no momento do hit. Mantém só os últimos 16 snaps.
_strategy_round_tracker = {}  # snap_id -> {'panels': set([0,1...]) com hit=False, 'resolved': bool}
_strategy_tracker_lock = Lock()


def _strategy_register_hit(snap_id: int, panel: int, won: bool):
    """Chamado por /api/events quando processa um hit=True/False.
    Assume estratégia ativa (caller checa)."""
    with _strategy_tracker_lock:
        # Limpa snaps antigos (>16 atrás)
        for old_id in list(_strategy_round_tracker.keys()):
            if old_id < snap_id - 16:
                _strategy_round_tracker.pop(old_id, None)
        entry = _strategy_round_tracker.setdefault(snap_id, {'losses': set(), 'resolved': False})
        if entry['resolved']:
            return
        if won:
            entry['resolved'] = True
            do_record = True
            outcome = True
        else:
            entry['losses'].add(panel)
            # Se ambos painéis reportaram loss neste snap, fecha como derrota.
            if {0, 1}.issubset(entry['losses']):
                entry['resolved'] = True
                do_record = True
                outcome = False
            else:
                do_record = False
                outcome = None
    if do_record:
        strategy_record_outcome(outcome)


def _load_schedule_cfg():
    try:
        if os.path.exists(SCHEDULE_CFG_PATH):
            with open(SCHEDULE_CFG_PATH, 'r', encoding='utf-8') as f:
                saved = json.load(f)
            if isinstance(saved, dict):
                with schedule_lock:
                    schedule_state['config'].update(saved)
                print(f'schedule config carregada')
    except Exception as e:
        print(f'warn: falha load schedule: {e}')


def _save_schedule_cfg():
    try:
        os.makedirs(os.path.dirname(SCHEDULE_CFG_PATH), exist_ok=True)
        with schedule_lock:
            cfg = dict(schedule_state['config'])
        with open(SCHEDULE_CFG_PATH, 'w', encoding='utf-8') as f:
            json.dump(cfg, f, ensure_ascii=False, indent=2)
    except Exception as e:
        print(f'warn: falha save schedule: {e}')


def _compute_schedule(window_days=28, min_samples=30):
    """Computa células dia-da-semana × hora × target. Janela rolling em SP timezone."""
    from datetime import datetime, timezone, timedelta
    sao = timezone(timedelta(hours=-3))
    cutoff = time.time() - window_days * 86400
    try:
        with db._lock:
            rows = db._conn.execute(
                'SELECT ts, value FROM crashes WHERE ts >= ?', (cutoff,)
            ).fetchall()
    except Exception:
        return []
    targets = [1.5, 2.0, 3.0, 4.0, 5.0, 7.0, 10.0]
    bucket = {}  # (dow, hour, target) -> [n, hits]
    for ts, value in rows:
        try:
            dt = datetime.fromtimestamp(ts, tz=sao)
        except Exception:
            continue
        dow = dt.weekday()  # 0=Mon, 6=Sun
        hour = dt.hour
        for t in targets:
            key = (dow, hour, t)
            if key not in bucket:
                bucket[key] = [0, 0]
            bucket[key][0] += 1
            if value >= t:
                bucket[key][1] += 1
    cells = []
    for (dow, hour, t), (n, hits) in bucket.items():
        if n < min_samples:
            continue
        hit_rate = hits / n
        be = 1.0 / t
        edge = hit_rate - be
        cells.append({
            'dow': dow, 'hour': hour, 'target': t,
            'n': n, 'hits': hits,
            'hit_rate': round(hit_rate, 4),
            'edge_pp': round(edge * 100, 2),
        })
    cells.sort(key=lambda c: -c['edge_pp'])
    return cells


def _check_in_window():
    """Atualiza schedule_state['in_window'] e top_targets_now baseado na hora atual."""
    from datetime import datetime, timezone, timedelta
    sao = timezone(timedelta(hours=-3))
    now = datetime.now(sao)
    dow = now.weekday()
    hour = now.hour
    with schedule_lock:
        cells = list(schedule_state['cells'])
        threshold = float(schedule_state['config'].get('edge_threshold_pp', 5.0))
    matching = [c for c in cells if c['dow'] == dow and c['hour'] == hour and c['edge_pp'] >= threshold]
    matching.sort(key=lambda c: -c['edge_pp'])
    in_window = len(matching) > 0
    with schedule_lock:
        schedule_state['in_window'] = in_window
        schedule_state['top_targets_now'] = matching[:5]
    return in_window, matching


def _schedule_watchdog():
    """A cada 60s: re-checa janela atual + recomputa cells a cada 1h."""
    last_recompute = 0
    while True:
        try:
            time.sleep(60)
            now = time.time()
            with schedule_lock:
                cfg = dict(schedule_state['config'])
            if not cfg.get('enabled'):
                # se foi desligado e tinha pausa de schedule, libera
                with autoclick_lock:
                    pause_reason = autoclick.get('pause_reason', '') or ''
                    if 'janela quente' in pause_reason.lower():
                        autoclick['paused_until'] = 0
                        autoclick['pause_reason'] = ''
                continue
            # recompute hourly
            if now - last_recompute > 3600 or not schedule_state.get('cells'):
                cells = _compute_schedule(
                    window_days=int(cfg.get('window_days', 28)),
                    min_samples=int(cfg.get('min_samples', 30)),
                )
                with schedule_lock:
                    schedule_state['cells'] = cells
                    schedule_state['recomputed_at'] = now
                last_recompute = now
            # checa janela atual
            in_window, _matching = _check_in_window()
            with autoclick_lock:
                paused_until = autoclick.get('paused_until', 0)
                pause_reason = autoclick.get('pause_reason', '') or ''
                schedule_paused = 'janela quente' in pause_reason.lower()
            if not in_window:
                # fora: dispara parada técnica se não tá em pause já
                if paused_until <= now:
                    with autoclick_lock:
                        autoclick['paused_until'] = now + 1_000_000  # schedule_watchdog libera
                        autoclick['tech_pause_start_total'] = 0
                        autoclick['pause_reason'] = 'fora da janela quente'
                        autoclick['events'].append({
                            'kind': 'info',
                            'msg': '⏸ PARADA TÉCNICA: fora da janela quente — aguardando próxima janela',
                        })
            else:
                # dentro: se estava pausado por schedule, libera
                if schedule_paused and paused_until > now:
                    with autoclick_lock:
                        autoclick['paused_until'] = 0
                        autoclick['pause_reason'] = ''
                        autoclick['events'].append({
                            'kind': 'info',
                            'msg': '✓ Janela quente entrou — retomando operação',
                        })
        except Exception as e:
            print(f'[schedule_watchdog] {e}')


def _load_cenario_cfg():
    try:
        if os.path.exists(CENARIO_CFG_PATH):
            with open(CENARIO_CFG_PATH, 'r', encoding='utf-8') as f:
                saved = json.load(f)
            if isinstance(saved, dict):
                # extrai baseline persistido (chaves com _) e separa da config
                last_bal = saved.pop('_last_applied_bankroll', None)
                last_tot = saved.pop('_last_applied_total', None)
                with cenario_lock:
                    cenario_state['config'].update(saved)
                    if last_bal is not None:
                        cenario_state['last_applied_bankroll'] = float(last_bal)
                    if last_tot is not None:
                        cenario_state['last_applied_total'] = int(last_tot)
                print(f"cenário config carregada de {CENARIO_CFG_PATH}")
    except Exception as e:
        print(f'warn: falha ao carregar cenario.json: {e}')


def _save_cenario_cfg():
    try:
        os.makedirs(os.path.dirname(CENARIO_CFG_PATH), exist_ok=True)
        with cenario_lock:
            snapshot = dict(cenario_state['config'])
            # também persiste baseline da gestão auto pra sobreviver restart
            snapshot['_last_applied_bankroll'] = cenario_state.get('last_applied_bankroll', 0.0)
            snapshot['_last_applied_total'] = cenario_state.get('last_applied_total', 0)
        with open(CENARIO_CFG_PATH, 'w', encoding='utf-8') as f:
            json.dump(snapshot, f, ensure_ascii=False, indent=2)
    except Exception as e:
        print(f'warn: falha ao salvar cenario.json: {e}')

# ============================================================
# INTELIGÊNCIA ADAPTATIVA
# ============================================================
intelligence = {
    'config': {
        'enabled': False,
        'reavaliarCadaRodadas': 100,
        'reavaliarAposPerdas': 2,
        'agressividade': 'moderado',   # leve / moderado / forte
        'pausarSeDesfavoravel': False,
    },
    # Estado atual (resultado da última avaliação)
    'state': {
        'scenario': 'NEUTRO',           # FAVORAVEL / NEUTRO / CAUTELOSO / DESFAVORAVEL
        'score': 0,                     # -100..+100
        'bet_multiplier': 1.0,          # multiplica a aposta base (ex: 0.5 = metade)
        'cooldown_add': 0,              # rodadas extras de cooldown
        'should_pause': False,
        'last_eval_at': None,
        'last_trigger_reason': None,    # 'round_interval' | 'loss_streak' | 'manual'
        'reasoning': [],                # linhas descrevendo a análise
        'crashes_at_eval': 0,
    },
}
intelligence_lock = Lock()


def _compute_scenario(crashes_list, stats):
    """Avalia cenário atual. Retorna dict com scenario, score, ajustes e reasoning."""
    reasoning = []
    score = 0

    if len(crashes_list) < 50:
        return {
            'scenario': 'NEUTRO', 'score': 0, 'bet_multiplier': 1.0,
            'cooldown_add': 0, 'should_pause': False,
            'reasoning': [f'Amostra muito pequena ({len(crashes_list)} rodadas). Aguarde pelo menos 50.'],
        }

    last = crashes_list[-50:]
    vals = [c['value'] for c in last]
    n = len(vals)
    TARGET = 1.3

    # Score 1: hit rate observado vs teórico
    hits = sum(1 for v in vals if v >= TARGET)
    p_obs = hits / n
    p_theo = 0.99 / TARGET
    delta_hit = (p_obs - p_theo) * 100   # pontos percentuais
    hit_score = max(-40, min(40, delta_hit * 2))
    score += hit_score
    reasoning.append(f'Hit rate últimas {n}: {p_obs*100:.0f}% (esperado {p_theo*100:.0f}%, Δ {delta_hit:+.1f}pp → {hit_score:+.0f} pts)')

    # Score 2: streak atual de perdas
    streak = stats.get('loss_streak', 0)
    if streak >= 3:
        streak_score = -30
    elif streak == 2:
        streak_score = -15
    elif streak == 1:
        streak_score = -5
    else:
        streak_score = +5
    score += streak_score
    reasoning.append(f'Streak de perdas atual: {streak} → {streak_score:+.0f} pts')

    # Score 3: padrão "após big crash" (≥5x) — taxa de 1.3x cai
    if vals[-1] >= 5.0:
        score -= 15
        reasoning.append(f'Última rodada foi ≥5x ({vals[-1]:.2f}x). Padrão observado: taxa cai na próxima. −15 pts')

    # Score 4: padrão "após streak de baixos"
    low_tail = 0
    for v in reversed(vals):
        if v < TARGET: low_tail += 1
        else: break
    if low_tail >= 2:
        score += 20
        reasoning.append(f'{low_tail} baixos seguidos recentes — padrão observado 100% nos dados. +20 pts')

    # Score 5: média dos últimos 10
    last10 = vals[-10:]
    m10 = sum(last10) / len(last10) if last10 else 0
    if m10 > 3.0:
        score += 10; reasoning.append(f'Média últimas 10 = {m10:.2f}x (quente). +10 pts')
    elif m10 < 1.3:
        score -= 10; reasoning.append(f'Média últimas 10 = {m10:.2f}x (fria). −10 pts')

    score = max(-100, min(100, score))

    # Classifica cenário e ajustes
    if score >= 30:
        scenario = 'FAVORAVEL'; mult = 1.0; add = 0; pause = False
    elif score >= 0:
        scenario = 'NEUTRO'; mult = 1.0; add = 0; pause = False
    elif score >= -30:
        scenario = 'CAUTELOSO'; mult = 0.5; add = 1; pause = False
    else:
        scenario = 'DESFAVORAVEL'; mult = 0.25; add = 2; pause = True

    return {
        'scenario': scenario, 'score': score,
        'bet_multiplier': mult, 'cooldown_add': add, 'should_pause': pause,
        'reasoning': reasoning,
    }


BUCKET_NAMES = {'B': 'BAIXO<1.3', 'M': 'MÉDIO<2', 'A': 'ALTO<5', 'V': 'TOPO≥5'}

def _bucket(v):
    if v < 1.3: return 'B'
    if v < 2.0: return 'M'
    if v < 5.0: return 'A'
    return 'V'


def _find_best_pattern(vals, target, min_support=3):
    """Busca no histórico o padrão que mais bem prediz o target atual.
    Quantiza crashes em buckets e procura sequências (k=2..5) que batam com
    a cauda atual. Retorna o melhor candidato por (suporte × hit rate)."""
    seq = [_bucket(v) for v in vals]
    best = None
    for k in (6, 5, 4, 3, 2):
        if len(seq) < k + min_support + 1:
            continue
        pattern = tuple(seq[-k:])
        occurrences = []
        for i in range(len(seq) - k):
            if tuple(seq[i:i+k]) == pattern:
                occurrences.append(vals[i+k])
        support = len(occurrences)
        if support < min_support:
            continue
        hits = sum(1 for v in occurrences if v >= target)
        rate = hits / support
        score = rate * (1 - 1.0 / (support + 1))
        cand = {
            'k': k, 'pattern': pattern, 'support': support,
            'hits': hits, 'rate': rate, 'score': score,
        }
        if best is None or score > best['score']:
            best = cand
    return best


def evaluate_target_probability(target, panel=None, margin_override=None):
    """Análise por padrão de sequência: busca nas últimas 100 rodadas se a
    cauda atual (últimas 2-5 rodadas quantizadas) já apareceu antes e qual
    foi o hit rate do target logo após. Se 'panel' for fornecido, aplica
    penalidade baseada no histórico da IA naquele painel."""
    with lock:
        crashes = list(state['crashes'])

    reasoning = []
    theo = 0.99 / target if target > 0 else 0

    if len(crashes) < 50:
        return {
            'target': target, 'probability': theo, 'theoretical': theo,
            'decision': 'reject',
            'reasoning': [f'Amostra muito pequena ({len(crashes)} crashes). Aguarde pelo menos 50.'],
            'sample_size': len(crashes),
            'ia_correction': 0,
        }

    last = crashes[-1000:]
    n = len(last)
    vals = [c['value'] for c in last]
    hits_tot = sum(1 for v in vals if v >= target)
    p_base = hits_tot / n

    reasoning.append(f'Janela: últimas {n} rodadas — baseline observado {p_base*100:.1f}% (teórico {theo*100:.1f}%)')

    best = _find_best_pattern(vals, target, min_support=3)
    tail_k = min(6, n - 1)
    tail_seq = [_bucket(v) for v in vals[-tail_k:]]
    tail_str = ' → '.join(BUCKET_NAMES[b].split('<')[0].split('≥')[0].rstrip() for b in tail_seq)
    reasoning.append(f'Cauda atual ({tail_k}): {tail_str}  [{"".join(tail_seq)}]')

    # Suavização Bayesiana: puxa hit rate observado em direção ao teórico
    # (prior força k=5). Evita que 2/5 = 40% num padrão raro vire estimativa
    # inflada; mantém estimativas estáveis com amostra pequena.
    K_PRIOR = 5
    if best:
        pat_str = ' → '.join(BUCKET_NAMES[b].split('<')[0].split('≥')[0].rstrip() for b in best['pattern'])
        raw_rate = best['rate']
        smoothed = (best['hits'] + K_PRIOR * theo) / (best['support'] + K_PRIOR)
        reasoning.append(
            f'PADRÃO casado (k={best["k"]}): {pat_str}  [{"".join(best["pattern"])}] '
            f'— apareceu {best["support"]}× no histórico, '
            f'em {best["hits"]} vezes a próxima foi ≥ {target:.2f}x '
            f'→ hit rate bruto {raw_rate*100:.1f}% · suavizado {smoothed*100:.1f}%'
        )
        p_adj = smoothed
    else:
        reasoning.append(f'Sem padrão com suporte ≥3 na janela. Usando baseline observado.')
        p_adj = p_base

    # Removidas em v1.11.3: regras hardcoded de "cooldown após alto" e "bônus
    # após baixos seguidos" eram variantes da falácia do jogador. Aviator é
    # provably-fair, rodadas independentes. Pattern matching + CALIBRAÇÃO já
    # capturam relações reais entre cauda e crash seguinte.

    # Aprendizado CAUSAL por padrão: cruza a cauda atual com apostas REAIS
    # resolvidas do DB (mesmo padrão, target similar). Supera o baseline teórico.
    pattern_key = ''.join(best['pattern']) if best else ''
    pattern_bet_corr = 0.0
    pattern_history = None
    if pattern_key:
        try: pattern_history = db.pattern_bet_history(pattern_key, target, tol=0.25, limit=30)
        except Exception: pattern_history = None
    if pattern_history and pattern_history['count'] >= 3:
        c = pattern_history['count']
        h = pattern_history['hits']
        real_rate = h / c
        # Suaviza o hit rate real também antes de comparar (evita que 1/4 vire prior forte)
        real_smoothed = (h + K_PRIOR * theo) / (c + K_PRIOR)
        pattern_bet_corr = (real_smoothed - p_adj) * min(1.0, c / 6)
        reasoning.append(
            f'APRENDIZADO CAUSAL: padrão "{pattern_key}" já teve {c} apostas resolvidas '
            f'neste target, {h} acertos (bruto {real_rate*100:.1f}% · suavizado {real_smoothed*100:.1f}%). '
            f'Pnl acumulado: R${pattern_history["pnl"]:+.2f}. Ajuste {pattern_bet_corr*100:+.1f}pp'
        )
        p_adj += pattern_bet_corr

    # Pattern BLACKLIST temporário: se o padrão atual perdeu 3× seguidas
    # nas apostas recentes (dentro de 2h), bloqueia por 2h. Aprendizado
    # granular — só mata o padrão específico, não penaliza tudo.
    pattern_blacklisted = False
    blacklist_info = None
    if panel is not None and pattern_key:
        try:
            last3 = db.recent_pattern_bets(panel, pattern_key, target, n=3)
        except Exception:
            last3 = []
        if len(last3) >= 3 and all(r.get('hit') == 0 for r in last3):
            oldest_ts = min(r.get('ts', 0) or 0 for r in last3)
            if time.time() - oldest_ts < 7200:  # 2h
                pattern_blacklisted = True
                blacklist_info = {'pattern': pattern_key, 'losses_in_row': 3, 'until_ts': oldest_ts + 7200}
                reasoning.append(
                    f'PATTERN TIMEOUT: "{pattern_key}" perdeu 3× seguidas nas últimas 2h → bloqueado até {time.strftime("%H:%M", time.localtime(oldest_ts + 7200))}'
                )

    p_adj = max(0.01, min(0.99, p_adj))

    # Critério: margem fixa do modo de operação (dropdown do painel).
    # Sem auto-ajuste, sem calibração por faixa, sem penalidade global.
    # Aprendizado é focado por padrão (pattern_bet_history + pattern blacklist).
    MARGIN_BASE = float(margin_override) if margin_override is not None else 0.10
    ABSOLUTE_FLOOR = 0.25
    approve_threshold_rel = theo * (1 + MARGIN_BASE)
    approve_threshold = max(approve_threshold_rel, ABSOLUTE_FLOOR)
    floor_active = approve_threshold == ABSOLUTE_FLOOR
    if pattern_blacklisted:
        decision = 'reject'
        reasoning.append('✗ REJEITA por timeout do padrão')
    else:
        decision = 'approve' if p_adj >= approve_threshold else 'reject'
        reasoning.append(
            f'Probabilidade final: {p_adj*100:.1f}%. '
            f'{"✓ APROVA" if decision == "approve" else "✗ REJEITA"} '
            f'(critério: ≥ {approve_threshold*100:.1f}% = '
            f'{f"piso {ABSOLUTE_FLOOR*100:.0f}%" if floor_active else f"teórico {theo*100:.1f}% + {MARGIN_BASE*100:.0f}% margem"})'
        )
    # Mantém nome ia_correction pra compat do JSON; valor fixo 0 pós-refactor.
    ia_correction = 0.0
    margin_pct = MARGIN_BASE
    prob_floor = ABSOLUTE_FLOOR
    adapt_src = None
    band_history = None
    band_corr = 0.0
    pattern_history_band = None

    pattern_info = None
    if best:
        pattern_info = {
            'k': best['k'],
            'pattern': ''.join(best['pattern']),
            'support': best['support'],
            'hits': best['hits'],
            'rate': round(best['rate'], 4),
        }
    return {
        'target': target,
        'probability': round(p_adj, 4),
        'probability_raw': round(p_base, 4),
        'pattern': pattern_info,
        'pattern_bet_history': pattern_history,
        'band_history': band_history,
        'band_corr': round(band_corr, 4),
        'tail': ''.join(tail_seq),
        'theoretical': round(theo, 4),
        'approve_threshold': round(approve_threshold, 4),
        'adaptive': {
            'margin_pct': round(margin_pct, 4),
            'prob_floor': round(prob_floor, 4),
            'recent_ia': adapt_src,
        },
        'decision': decision,
        'reasoning': reasoning,
        'sample_size': n,
        'ia_correction': round(ia_correction, 4),
        'pattern_bet_corr': round(pattern_bet_corr, 4),
        'blacklisted': pattern_blacklisted,
        'blacklist_info': blacklist_info,
    }


def run_intelligence_eval(reason='manual'):
    """Roda a análise e atualiza o estado. Respeita config.agressividade."""
    with lock:
        crashes = list(state['crashes'])
    with autoclick_lock:
        ac_stats = dict(autoclick['stats'])
    with intelligence_lock:
        cfg = dict(intelligence['config'])

    verdict = _compute_scenario(crashes, ac_stats)

    # Ajusta agressividade (amplia/reduz os efeitos)
    agg = cfg.get('agressividade', 'moderado')
    if agg == 'leve':
        # reduz efeito: multiplicador mais próximo de 1, cooldown menor
        verdict['bet_multiplier'] = (verdict['bet_multiplier'] + 1.0) / 2
        verdict['cooldown_add'] = verdict['cooldown_add'] // 2
    elif agg == 'forte':
        # amplia: multiplicador mais agressivo, cooldown maior
        verdict['bet_multiplier'] = max(0.1, verdict['bet_multiplier'] * 0.7)
        verdict['cooldown_add'] = verdict['cooldown_add'] * 2

    # Se pausarSeDesfavoravel desabilitado, desliga a pausa
    if not cfg.get('pausarSeDesfavoravel', False):
        verdict['should_pause'] = False

    with intelligence_lock:
        intelligence['state'].update({
            'scenario': verdict['scenario'],
            'score': verdict['score'],
            'bet_multiplier': round(verdict['bet_multiplier'], 2),
            'cooldown_add': verdict['cooldown_add'],
            'should_pause': verdict['should_pause'],
            'last_eval_at': time.time(),
            'last_trigger_reason': reason,
            'reasoning': verdict['reasoning'],
            'crashes_at_eval': len(crashes),
        })
    return verdict

# ============================================================
# TELEGRAM (notificações)
# ============================================================
TELEGRAM_CFG_PATH = os.path.join(os.path.dirname(__file__), 'data', 'telegram.json')

telegram = {
    'config': {
        'enabled': False,
        'botToken': '',
        'chatId': '',
        'notifyHit': True,
        'notifyMiss': True,
        'notifyBet': False,
        'notifyStop': True,
    },
}
telegram_lock = Lock()


def _load_telegram_cfg():
    try:
        if os.path.exists(TELEGRAM_CFG_PATH):
            with open(TELEGRAM_CFG_PATH, 'r', encoding='utf-8') as f:
                saved = json.load(f)
            if isinstance(saved, dict):
                with telegram_lock:
                    telegram['config'].update(saved)
                print(f"telegram config carregada de {TELEGRAM_CFG_PATH} (enabled={telegram['config'].get('enabled')})")
    except Exception as e:
        print(f'warn: falha ao carregar telegram.json: {e}')


def _save_telegram_cfg():
    try:
        os.makedirs(os.path.dirname(TELEGRAM_CFG_PATH), exist_ok=True)
        with telegram_lock:
            snapshot = dict(telegram['config'])
        with open(TELEGRAM_CFG_PATH, 'w', encoding='utf-8') as f:
            json.dump(snapshot, f, ensure_ascii=False, indent=2)
    except Exception as e:
        print(f'warn: falha ao salvar telegram.json: {e}')


_telegram_queue = queue.Queue(maxsize=200)
_telegram_worker_started = False
_telegram_worker_lock = Lock()


def _telegram_send_sync(text, attempts=3):
    """Envia SÍNCRONO com retry. Retorna (ok, info)."""
    with telegram_lock:
        cfg = dict(telegram['config'])
    if not cfg.get('enabled') or not cfg.get('botToken') or not cfg.get('chatId'):
        return False, 'telegram desabilitado ou sem credenciais'
    import urllib.request, urllib.parse
    url = f"https://api.telegram.org/bot{cfg['botToken']}/sendMessage"
    data = urllib.parse.urlencode({
        'chat_id': cfg['chatId'],
        'text': text,
        'parse_mode': 'HTML',
        'disable_web_page_preview': 'true',
    }).encode()
    last_err = ''
    for i in range(attempts):
        try:
            req = urllib.request.Request(url, data=data, method='POST')
            r = urllib.request.urlopen(req, timeout=10)
            return True, r.read()[:200].decode('utf-8', errors='replace')
        except Exception as e:
            last_err = str(e)
            # backoff incremental: 0.4s, 0.8s, 1.2s
            if i < attempts - 1:
                time.sleep(0.4 * (i + 1))
    # todas falharam — loga no feed de eventos do autoclick pra ficar visível na UI
    try:
        with autoclick_lock:
            autoclick['events'].append({
                'kind': 'err',
                'msg': f'Telegram falhou ({attempts}x): {last_err[:150]}',
            })
    except Exception:
        pass
    return False, last_err


def _telegram_worker():
    while True:
        try:
            text = _telegram_queue.get()
            if text is None:
                break
            _telegram_send_sync(text)
        except Exception:
            pass


def _send_telegram(text):
    """Versão assíncrona: enfileira pro worker em background. Usada em
    hot-paths do handler de eventos pra não bloquear a resposta HTTP."""
    global _telegram_worker_started
    with _telegram_worker_lock:
        if not _telegram_worker_started:
            _telegram_worker_started = True
            threading.Thread(target=_telegram_worker, daemon=True, name='telegram-worker').start()
    try:
        _telegram_queue.put_nowait(text)
        return True, 'enqueued'
    except queue.Full:
        try:
            with autoclick_lock:
                autoclick['events'].append({
                    'kind': 'err',
                    'msg': 'Telegram: fila cheia, mensagem descartada',
                })
        except Exception:
            pass
        return False, 'queue_full'


TARGET = 10.0

def emit_signal(last_crashes):
    """Heuristica para alvo 10x: intervalos entre 10x+ sao a base do sinal.
    NAO preditivo — serve pra medir empiricamente contra rodadas reais."""
    crashes = [c['value'] for c in list(last_crashes)]
    if len(crashes) < 3:
        return None

    # Rodadas desde o último 10x
    gap = 0
    last_10x = None
    for v in reversed(crashes):
        if v >= TARGET:
            last_10x = v
            break
        gap += 1

    # Distribuição teórica: prob(>=10x) ~= 1/10. Gap médio ~10 rodadas.
    # Heurística:
    #  - gap >= 20: "atrasado", emite BET (falácia do jogador clássica)
    #  - gap entre 12 e 19: ATENÇÃO (warming up)
    #  - gap < 3: SKIP (acabou de sair, improvável repetir)
    #  - resto: OBSERVE

    if gap < 3:
        return {'type': 'SKIP', 'reason': f'{gap} rodada(s) apos {last_10x:.2f}x - improvavel 10x em sequencia', 'target': TARGET}
    if gap >= 20:
        return {'type': 'BET', 'reason': f'{gap} rodadas sem 10x - intervalo longo (heuristica de reversao; falacia do jogador)', 'target': TARGET}
    if gap >= 12:
        return {'type': 'ATENCAO', 'reason': f'{gap} rodadas sem 10x - aproximando da faixa de alerta', 'target': TARGET}
    return {'type': 'OBSERVE', 'reason': f'{gap} rodadas apos ultimo 10x; media teorica ~10', 'target': TARGET}


GAP_FILL_ENABLED = False     # heurística falsa-positiva demais — desligado.
                             # Confiar no regex novo do daemon_dom (aceita "1x" sem ponto)
                             # como correção primária. Reabilitar só se voltar a perder 1.00.
GAP_FILL_MIN_SECONDS = 28    # gap >28s = provavelmente 1 rodada perdida (média real ~17s)
GAP_FILL_MAX_SECONDS = 90    # gap >90s = pausa do user/daemon, NÃO inferir nada
GAP_FILL_AVG_ROUND = 17      # média observada nos últimos 30 dias
GAP_FILL_MAX_INSERT = 3      # cap por chamada
GAP_FILL_VALUE = 1.00        # crashes que o daemon costuma perder (instant crash)


def _maybe_fill_gap(prev_at: float, new_at: float):
    """Se houve gap maior que GAP_FILL_MIN_SECONDS sem registrar crash novo,
    insere placeholders 1.00× com inferred=True, distribuídos no intervalo.
    Não toca em state['crashes'] (deque em memória) — só vai pro DB e pra cloud.
    Pattern detector e tail-detector continuam só vendo crashes reais."""
    if not GAP_FILL_ENABLED:
        return
    gap = new_at - prev_at
    if gap < GAP_FILL_MIN_SECONDS or gap > GAP_FILL_MAX_SECONDS:
        return  # gap normal ou pausa real (>90s) — não inferir
    n_missing = max(1, round(gap / GAP_FILL_AVG_ROUND) - 1)
    n_missing = min(n_missing, GAP_FILL_MAX_INSERT)
    step = gap / (n_missing + 1)
    for i in range(1, n_missing + 1):
        ts_inf = prev_at + i * step
        try:
            db.insert_crash(GAP_FILL_VALUE, ts=ts_inf, inferred=True)
        except Exception as e:
            print(f'[gap_fill] insert failed: {e}')
    print(f'[gap_fill] gap={gap:.1f}s preencheu {n_missing} placeholder(s) 1.00× inferred')


def register_crash(value):
    now = time.time()
    with lock:
        # Debounce 5s
        if state['crashes']:
            last = state['crashes'][-1]
            if abs(last['value'] - value) < 0.005 and (now - last['at']) < 5:
                return False
        # 1.26.0 — gap-fill: detecta rodadas invisíveis (Spribe oculta 1.00× do .payouts-block)
        if state['crashes']:
            prev_at = state['crashes'][-1]['at']
            try: _maybe_fill_gap(prev_at, now)
            except Exception as e: print(f'[gap_fill] {e}')
        state['crashes'].append({'value': value, 'at': now})
        state['crashes_total'] += 1
        snap_id = state['crashes_total']
    # 1.21.0 — tail detector hook (resolve pending + avalia novo sinal)
    try:
        _tail_on_new_crash(value, snap_id)
    except Exception as e:
        print(f'[tail_detector] {e}')
    with lock:
        try: db.insert_crash(value, ts=now)
        except Exception: pass
        # Auto-trigger da inteligência a cada N rodadas
        try:
            with intelligence_lock:
                icfg = dict(intelligence['config'])
                istate = dict(intelligence['state'])
            if icfg.get('enabled'):
                N = int(icfg.get('reavaliarCadaRodadas', 100))
                if N > 0 and state['crashes_total'] % N == 0:
                    # dispara re-eval em thread separada pra não segurar o lock
                    threading.Thread(target=run_intelligence_eval, args=('round_interval',), daemon=True).start()
        except Exception:
            pass
        # Fecha sinais pendentes (compara contra target = 10x)
        for s in state['signals']:
            if s.get('actual') is None:
                s['actual'] = value
                target = s.get('target', TARGET)
                if s['type'] == 'BET':
                    s['hit'] = value >= target
                elif s['type'] == 'SKIP':
                    s['hit'] = value < target
                elif s['type'] == 'ATENCAO':
                    s['hit'] = value >= target
                else:
                    s['hit'] = None
        # Emite sinal novo
        sig = emit_signal(state['crashes'])
        if sig:
            sig['at'] = now
            sig['actual'] = None
            sig['hit'] = None
            state['signals'].append(sig)
        return True


def compute_cycles(crashes_list, signals_list):
    """Agrupa crashes em 'ciclos de caça' — cada ciclo termina em um 10x+.
    Cada ciclo retorna: intervalo, aposta, rejeicao, pagou, at."""
    cycles = []
    TARGET_LOCAL = 10.0
    def find_signal_for(crash):
        """Acha o sinal emitido ANTES desse crash cujo 'actual' bate."""
        best = None
        best_dt = None
        for s in signals_list:
            if s.get('at') is None or s.get('actual') is None: continue
            dt = crash['at'] - s['at']
            if dt < 0 or dt > 300: continue
            if abs(s.get('actual', -999) - crash['value']) > 0.01: continue
            if best is None or dt < best_dt:
                best = s; best_dt = dt
        return best

    segment = []   # crashes desde o último 10x+
    for c in crashes_list:
        segment.append(c)
        if c['value'] >= TARGET_LOCAL:
            bet = 0; atencao = 0; skip = 0
            for cc in segment:
                sig = find_signal_for(cc)
                t = sig.get('type') if sig else None
                if t == 'BET': bet += 1
                elif t == 'ATENCAO': atencao += 1
                elif t == 'SKIP': skip += 1
                # OBSERVE / sem sinal = não conta
            cycles.append({
                'intervalo': len(segment),
                'bet': bet,
                'atencao': atencao,
                'aposta': bet + atencao,   # mantém compat
                'skip': skip,
                'pagou': c['value'],
                'at': c['at'],
            })
            segment = []
    # Reverter: mais recente primeiro
    return list(reversed(cycles))


def compute_pro_stats(crashes_list):
    """Métricas estatísticas avançadas pro Dashboard PRO."""
    if not crashes_list:
        return None
    vals = [c['value'] for c in crashes_list]
    n = len(vals)

    # 1. Histograma por faixas
    buckets = [(1.00, 1.10), (1.10, 1.50), (1.50, 2.00), (2.00, 3.00),
               (3.00, 5.00), (5.00, 10.00), (10.00, 20.00), (20.00, 50.00), (50.00, 9999.0)]
    labels = ['1.00-1.10', '1.10-1.50', '1.50-2.00', '2-3', '3-5', '5-10', '10-20', '20-50', '50+']
    counts = [0] * len(buckets)
    for v in vals:
        for i, (lo, hi) in enumerate(buckets):
            if lo <= v < hi:
                counts[i] += 1
                break
    histogram = [
        {'label': labels[i], 'count': counts[i], 'pct': (counts[i] / n * 100) if n else 0}
        for i in range(len(buckets))
    ]

    # 2. P(x >= X) observado vs teórico para vários thresholds
    thresholds = [2, 3, 5, 10, 20, 50]
    p_observed_vs_theo = []
    for X in thresholds:
        obs = sum(1 for v in vals if v >= X) / n
        theo = 0.99 / X
        # intervalo de confiança 95% (Wilson score)
        z = 1.96
        p = obs
        denom = 1 + z*z/n
        center = (p + z*z/(2*n)) / denom
        spread = z * math.sqrt(p*(1-p)/n + z*z/(4*n*n)) / denom
        p_observed_vs_theo.append({
            'threshold': X, 'observed': obs, 'theoretical': theo,
            'ci_lo': max(0, center - spread), 'ci_hi': min(1, center + spread),
            'count': sum(1 for v in vals if v >= X),
        })

    # 3. RTP empírico (assumindo estratégia de sacar em TARGET)
    # RTP real do Aviator é ~97% independente da estratégia, mas usuário usa estratégia
    # EV por aposta na estratégia 10x: perde 1 se crash < 10, ganha (10-1)=9 se >= 10
    p_win_target = sum(1 for v in vals if v >= TARGET) / n if n else 0
    ev_per_bet = p_win_target * (TARGET - 1) + (1 - p_win_target) * (-1)
    # RTP observado: soma dos payouts reais / número de apostas (em termos da estratégia 10x)
    payouts = [(TARGET - 1) if v >= TARGET else -1 for v in vals]
    rtp_strategy = (sum(payouts) / n + 1) * 100 if n else None  # 100% = break-even
    # RTP "verdadeiro" do jogo (payout esperado se cashed out no crash exato antes)
    # Não dá pra calcular diretamente; aproximação: média dos multiplicadores ponderada
    # Kelly criterion
    kelly_b = TARGET - 1
    kelly_f = (p_win_target * kelly_b - (1 - p_win_target)) / kelly_b if kelly_b > 0 else 0

    # 4. Autocorrelação lag-1 (em log pra normalizar distribuição heavy-tailed)
    autocorr_lag1 = None
    if n >= 10:
        logs = [math.log(v) for v in vals if v > 0]
        m = len(logs) - 1
        if m >= 5:
            x = logs[:-1]; y = logs[1:]
            mx = sum(x) / m; my = sum(y) / m
            num = sum((x[i] - mx) * (y[i] - my) for i in range(m))
            dx = math.sqrt(sum((xi - mx) ** 2 for xi in x))
            dy = math.sqrt(sum((yi - my) ** 2 for yi in y))
            if dx > 0 and dy > 0:
                autocorr_lag1 = num / (dx * dy)

    # 5. Runs test (Wald-Wolfowitz) — sequência de acima/abaixo da mediana
    runs_test = None
    if n >= 10:
        median = sorted(vals)[n // 2]
        sequence = [1 if v > median else 0 for v in vals if v != median]
        if len(sequence) >= 10:
            n1 = sum(sequence); n2 = len(sequence) - n1
            runs = 1
            for i in range(1, len(sequence)):
                if sequence[i] != sequence[i-1]: runs += 1
            expected = 2 * n1 * n2 / (n1 + n2) + 1 if (n1 + n2) else 0
            variance = (2 * n1 * n2 * (2 * n1 * n2 - n1 - n2)) / (((n1 + n2) ** 2) * (n1 + n2 - 1)) if (n1+n2) > 1 else 0
            z_score = (runs - expected) / math.sqrt(variance) if variance > 0 else None
            runs_test = {'runs': runs, 'expected': expected, 'z': z_score}

    # 6. Kolmogorov-Smirnov test contra distribuição teórica
    # F_theo(x) = 1 - 0.99/x para x >= 1
    ks_test = None
    if n >= 30:
        sorted_vals = sorted(vals)
        d_max = 0
        for i, v in enumerate(sorted_vals):
            f_emp = (i + 1) / n
            f_theo = max(0, 1 - 0.99 / v) if v >= 1 else 0
            d = abs(f_emp - f_theo)
            if d > d_max: d_max = d
        critical_05 = 1.36 / math.sqrt(n)
        p_approx = math.exp(-2 * n * d_max * d_max) if d_max > 0 else 1.0
        ks_test = {
            'D': d_max, 'critical_0.05': critical_05,
            'rejects_h0': d_max > critical_05,
            'p_approx': p_approx, 'n': n,
        }

    # 7. Distribuição de gaps entre crashes >= TARGET
    gaps = []
    current_gap = 0
    for v in vals:
        if v >= TARGET:
            gaps.append(current_gap)
            current_gap = 0
        else:
            current_gap += 1
    gap_current = current_gap
    gap_stats = None
    if gaps:
        sg = sorted(gaps)
        p50 = sg[len(sg) // 2]
        p90 = sg[min(int(len(sg) * 0.9), len(sg) - 1)]
        p99 = sg[min(int(len(sg) * 0.99), len(sg) - 1)]
        mean_gap = sum(sg) / len(sg)
        std_gap = math.sqrt(sum((g - mean_gap) ** 2 for g in sg) / len(sg)) if len(sg) > 1 else 0
        below_current = sum(1 for g in sg if g <= gap_current)
        percentile = below_current / len(sg) * 100
        z_current = (gap_current - mean_gap) / std_gap if std_gap > 0 else None
        gap_stats = {
            'count': len(sg), 'current': gap_current, 'percentile': percentile,
            'p50': p50, 'p90': p90, 'p99': p99, 'max': sg[-1],
            'mean': mean_gap, 'std': std_gap, 'z_current': z_current,
            'distribution': sg,
        }

    # 8. Drawdown da estratégia (sequência de P&L cumulativo apostando target)
    cum_pl = 0
    max_cum = 0
    max_drawdown = 0
    current_dd = 0
    losing_streak = 0
    max_losing_streak = 0
    for v in vals:
        pl = (TARGET - 1) if v >= TARGET else -1
        cum_pl += pl
        if cum_pl > max_cum:
            max_cum = cum_pl
            current_dd = 0
        else:
            current_dd = max_cum - cum_pl
            if current_dd > max_drawdown:
                max_drawdown = current_dd
        if pl < 0:
            losing_streak += 1
            if losing_streak > max_losing_streak:
                max_losing_streak = losing_streak
        else:
            losing_streak = 0
    strategy_stats = {
        'bets': n,
        'cum_pl': cum_pl,
        'max_drawdown': max_drawdown,
        'max_losing_streak': max_losing_streak,
        'current_losing_streak': losing_streak,
    }

    return {
        'histogram': histogram,
        'p_observed_vs_theo': p_observed_vs_theo,
        'rtp_strategy': rtp_strategy,
        'ev_per_bet': ev_per_bet,
        'p_win_target': p_win_target,
        'kelly_fraction': kelly_f,
        'autocorr_lag1': autocorr_lag1,
        'runs_test': runs_test,
        'ks_test': ks_test,
        'gap_stats': gap_stats,
        'strategy_stats': strategy_stats,
    }


def get_snapshot():
    with lock:
        crashes = list(state['crashes'])
        signals = list(state['signals'])
        closed = [s for s in signals if s.get('hit') is not None]
        hits = [s for s in closed if s.get('hit') is True]
        # Intervalos entre crashes >= 10x (target). Essencial para a estratégia.
        intervalos = []
        gap = 0
        last_10x_at = None
        time_gaps = []
        prev_10x_at = None
        for c in crashes:
            if c['value'] >= TARGET:
                intervalos.append(gap)
                gap = 0
                last_10x_at = c['at']
                if prev_10x_at is not None:
                    time_gaps.append(c['at'] - prev_10x_at)
                prev_10x_at = c['at']
            else:
                gap += 1
        stats = {
            'total': state.get('crashes_total', len(crashes)),
            'total_window': len(crashes),
            'media': sum(c['value'] for c in crashes) / len(crashes) if crashes else 0,
            'intervalo_medio': sum(intervalos) / len(intervalos) if intervalos else 0,
            'intervalo_atual': gap,
            'ultimo_acerto_rodadas': intervalos[-1] if intervalos else 0,
            'max_intervalo': max(intervalos) if intervalos else 0,
            'total_acima_2x': sum(1 for c in crashes if c['value'] >= 2.0),
            'total_acima_10x': sum(1 for c in crashes if c['value'] >= 10.0),
            'total_acima_15x': sum(1 for c in crashes if c['value'] >= 15.0),
            'total_acima_50x': sum(1 for c in crashes if c['value'] >= 50.0),
            'target': TARGET,
            'last_10x_at': last_10x_at,
            'time_gaps_10x': time_gaps,
            'avg_time_gap_10x': (sum(time_gaps) / len(time_gaps)) if time_gaps else None,
            'max_time_gap_10x': max(time_gaps) if time_gaps else None,
            'signals_total': len(signals),
            'signals_closed': len(closed),
            'hits': len(hits),
            'hit_rate': (len(hits) / len(closed) * 100) if closed else None,
        }
        return {
            'crashes': crashes,
            'signals': signals[-20:],
            'stats': stats,
            'pro_stats': compute_pro_stats(crashes),
            'cycles': compute_cycles(crashes, signals),
            'session_seconds': int(time.time() - state['session_start']),
            'ws_connected': state['ws_connected'],
            'ws_msgs_received': state['ws_msgs_received'],
            'last_ws_msg_ago': int(time.time() - state['last_ws_msg_at']) if state['last_ws_msg_at'] else None,
        }


class Handler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        # silêncio nas requisições de polling
        if '/api/state' in args[0] if args else False:
            return
        super().log_message(fmt, *args)

    def _cors(self):
        origin = self.headers.get('Origin', '*')
        self.send_header('Access-Control-Allow-Origin', origin if origin != '*' else '*')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type, Access-Control-Request-Private-Network')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
        self.send_header('Access-Control-Allow-Private-Network', 'true')
        self.send_header('Access-Control-Allow-Credentials', 'true')

    def do_OPTIONS(self):
        self.send_response(204)
        self._cors()
        self.end_headers()

    # Rotas sempre liberadas (necessárias pra telas de ativação/update/sobre).
    _PUBLIC_PATHS = {
        '/', '/index.html', '/CHANGELOG.md', '/snippet.js',
        '/api/version', '/api/updates/check', '/api/license/status',
        '/api/license/activate', '/api/license/deactivate',
    }

    def _is_public_path(self, path):
        if path in self._PUBLIC_PATHS: return True
        if path.startswith('/static/'): return True
        return False

    def _guard_licensed(self):
        """Retorna True se a rota pode prosseguir. False = já mandou 403."""
        try:
            if _license.is_licensed():
                return True
        except Exception:
            pass
        body = json.dumps({'error': 'license_required', 'licensed': False}).encode()
        self.send_response(403)
        self.send_header('Content-Type', 'application/json')
        self._cors()
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)
        return False

    def do_GET(self):
        path = self.path.split('?')[0]
        if path == '/api/license/status':
            body = json.dumps(_license.status()).encode()
            self.send_response(200); self.send_header('Content-Type', 'application/json')
            self._cors(); self.send_header('Content-Length', str(len(body)))
            self.end_headers(); self.wfile.write(body); return
        if not self._is_public_path(path) and not self._guard_licensed():
            return
        if path == '/' or path == '/index.html':
            self._serve_static('index.html', 'text/html; charset=utf-8')
        elif path == '/api/updates/check':
            info = updater.check_update()
            body = json.dumps(info).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self._cors()
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        elif path == '/api/version':
            body = json.dumps({
                'version': _version.VERSION,
                'release_name': _version.RELEASE_NAME,
                'release_date': _version.RELEASE_DATE,
                'db_totals': db.totals(),
            }).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self._cors()
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        elif path == '/api/state':
            body = json.dumps(get_snapshot()).encode('utf-8')
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self._cors()
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        elif path.startswith('/api/intelligence/gate'):
            # ?target=1.3&panel=0&margin=0.10
            try:
                query = self.path.split('?', 1)[1] if '?' in self.path else ''
                target = None
                panel = None
                margin_override = None
                for part in query.split('&'):
                    if part.startswith('target='):
                        target = float(part.split('=', 1)[1])
                    elif part.startswith('panel='):
                        try: panel = int(part.split('=', 1)[1])
                        except: pass
                    elif part.startswith('margin='):
                        try: margin_override = float(part.split('=', 1)[1])
                        except: pass
                if target is None or target <= 1.0:
                    self.send_error(400, 'target inválido'); return
                result = evaluate_target_probability(target, panel=panel, margin_override=margin_override)
                body = json.dumps(result).encode()
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self._cors()
                self.send_header('Content-Length', str(len(body)))
                self.end_headers()
                self.wfile.write(body)
            except Exception as e:
                self.send_error(500, str(e))
            return
        elif path == '/api/intelligence/state':
            with intelligence_lock:
                body = json.dumps({'config': intelligence['config'], 'state': intelligence['state']}).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self._cors()
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        elif path == '/api/telegram/config':
            with telegram_lock:
                # mascara o token pra retorno (só últimos 4 chars)
                cfg = dict(telegram['config'])
                if cfg.get('botToken'):
                    cfg['botToken'] = '••••••••' + cfg['botToken'][-4:]
            body = json.dumps(cfg).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self._cors()
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        elif path == '/api/control':
            # Dados consolidados pra aba CONTROLE (cockpit).
            import sqlite3
            with lock:
                crashes_all = list(state['crashes'])
            crashes_300 = crashes_all[-300:] if len(crashes_all) > 300 else crashes_all
            n = len(crashes_300)
            # KPIs por target
            targets = [2.0, 5.0, 10.0, 50.0]
            kpis = []
            for t in targets:
                hits = sum(1 for c in crashes_300 if c.get('value', 0) >= t)
                observed = (hits / n) if n else 0.0
                try:
                    # panel=None → pattern matching puro + suavização bayesiana
                    # sem viés de painel específico (mais fiel pra KPI geral).
                    r_eval = evaluate_target_probability(t, panel=None)
                    ia_prob = r_eval.get('probability', 0.0)
                except Exception:
                    ia_prob = 0.0
                kpis.append({'target': t, 'observed': round(observed, 4),
                             'ia': round(ia_prob, 4), 'sample': n, 'hits': hits})
            # Crashes pra heatmap
            crashes_seq = [{'v': c.get('value'), 't': c.get('at')} for c in crashes_300]
            # Painéis: última aposta + últimas 20 outcomes
            panels = {}
            try:
                con = sqlite3.connect(db.DB_PATH)
                con.row_factory = sqlite3.Row
                for p in (0, 1):
                    lb = con.execute(
                        '''SELECT ts, target, amount, hit, delta_pnl FROM bets
                           WHERE panel=? AND resolved_ts IS NOT NULL AND amount > 0
                           ORDER BY id DESC LIMIT 1''', (p,)).fetchone()
                    last20 = con.execute(
                        '''SELECT hit FROM bets WHERE panel=? AND resolved_ts IS NOT NULL
                           AND amount > 0 ORDER BY id DESC LIMIT 20''', (p,)).fetchall()
                    panels[p] = {
                        'last_bet': ({
                            'ts': lb['ts'], 'target': lb['target'], 'amount': lb['amount'],
                            'hit': bool(lb['hit']) if lb['hit'] is not None else None,
                            'delta_pnl': lb['delta_pnl'],
                        } if lb else None),
                        'last20': [bool(r['hit']) if r['hit'] is not None else None
                                   for r in reversed(list(last20))],
                    }
                # Session/totals
                now_ts = time.time()
                sod = now_ts - (now_ts % 86400)  # início do dia UTC
                today_rows = con.execute(
                    '''SELECT ts, delta_pnl FROM bets WHERE resolved_ts IS NOT NULL
                       AND amount > 0 AND ts >= ? ORDER BY ts ASC''', (sod,)).fetchall()
                cum = 0
                pnl_series = []
                for row in today_rows:
                    cum += float(row['delta_pnl'] or 0)
                    pnl_series.append({'t': row['ts'], 'pnl': round(cum, 2)})
                gaps = []
                for i in range(1, len(today_rows)):
                    gaps.append(today_rows[i]['ts'] - today_rows[i-1]['ts'])
                avg_gap = int(sum(gaps) / len(gaps)) if gaps else 0
                total_bets = con.execute(
                    'SELECT COUNT(*) c FROM bets WHERE amount > 0').fetchone()['c']
                hourly = con.execute('''
                    SELECT CAST(strftime('%H', ts, 'unixepoch', 'localtime') AS INTEGER) h,
                           COUNT(*) n, SUM(hit) w
                    FROM bets WHERE resolved_ts IS NOT NULL AND amount > 0
                    GROUP BY h ORDER BY h
                ''').fetchall()
                hourly_data = [{'hour': r['h'], 'count': r['n'],
                                'wins': r['w'] or 0,
                                'rate': round((r['w'] or 0)/r['n'], 4) if r['n'] else 0}
                               for r in hourly]
                session = {
                    'bets_today': len(today_rows),
                    'bets_total': total_bets,
                    'avg_gap_s': avg_gap,
                    'pnl_series': pnl_series,
                    'hourly': hourly_data,
                }
                con.close()
            except Exception as e:
                panels['error'] = str(e)
                session = {'error': str(e)}
            body = json.dumps({
                'kpis': kpis,
                'crashes': crashes_seq,
                'panels': panels,
                'session': session,
            }).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self._cors()
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        elif path == '/api/analytics/ia':
            # Relatório: top padrões e top faixas de prob por hit rate.
            import sqlite3
            out = {'by_pattern': [], 'by_prob_band': [], 'by_target': []}
            try:
                con = sqlite3.connect(db.DB_PATH)
                rows = con.execute('''
                    SELECT pattern, panel, target, COUNT(*) n, SUM(hit) hits,
                           ROUND(SUM(delta_pnl),2) pnl
                    FROM bets
                    WHERE resolved_ts IS NOT NULL AND pattern IS NOT NULL AND ia_approved=1
                    GROUP BY pattern, panel, target HAVING n>=3
                    ORDER BY (1.0*SUM(hit)/COUNT(*)) DESC LIMIT 30
                ''').fetchall()
                for r in rows:
                    out['by_pattern'].append({
                        'pattern': r[0], 'panel': r[1], 'target': r[2],
                        'count': r[3], 'hits': r[4], 'pnl': r[5],
                        'rate': round(100.0 * r[4] / r[3], 1),
                    })
                rows = con.execute('''
                    SELECT CAST(probability*10 AS INTEGER) band10, panel, target,
                           COUNT(*) n, SUM(hit) hits, ROUND(SUM(delta_pnl),2) pnl,
                           ROUND(AVG(probability)*100,1) avg_prob
                    FROM bets
                    WHERE resolved_ts IS NOT NULL AND probability IS NOT NULL AND ia_approved=1
                    GROUP BY band10, panel, target HAVING n>=3
                    ORDER BY band10 DESC, panel, target
                ''').fetchall()
                for r in rows:
                    out['by_prob_band'].append({
                        'band_label': f'{r[0]*10}–{r[0]*10+10}%', 'panel': r[1], 'target': r[2],
                        'count': r[3], 'hits': r[4], 'pnl': r[5], 'avg_prob': r[6],
                        'rate': round(100.0 * r[4] / r[3], 1),
                    })
                rows = con.execute('''
                    SELECT panel, target, COUNT(*) n, SUM(hit) hits, ROUND(SUM(delta_pnl),2) pnl
                    FROM bets
                    WHERE resolved_ts IS NOT NULL AND ia_approved=1
                    GROUP BY panel, target ORDER BY panel, target
                ''').fetchall()
                for r in rows:
                    out['by_target'].append({
                        'panel': r[0], 'target': r[1], 'count': r[2], 'hits': r[3], 'pnl': r[4],
                        'rate': round(100.0 * r[3] / r[2], 1),
                        'theoretical': round(99.0 / r[1], 1),
                    })
                con.close()
            except Exception as e:
                out['error'] = str(e)
            body = json.dumps(out).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self._cors()
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        elif path == '/api/autoclick/status':
            with autoclick_lock:
                events = list(autoclick['events'])
                autoclick['events'].clear()
                now_ts = time.time()
                paused = autoclick.get('paused_until', 0) or 0
                stats_snap = dict(autoclick['stats'])
                pause_reason = autoclick.get('pause_reason', '')
                shadow_snap = dict(autoclick.get('shadow_stats', {}))
                shadow_results_snap = list(autoclick.get('shadow_results', []))
                calibration_snap = dict(autoclick.get('calibration') or {'remaining': 0, 'total': 0})
                tech_pause_start_total = autoclick.get('tech_pause_start_total', 0)
                tech_pause_total_rounds = autoclick.get('tech_pause_total_rounds', 20)
                cfg_revision_snap = autoclick.get('cfg_revision', 0)
                cooldowns_snap = dict(autoclick.get('panel_cooldowns', {}))
                cfg_snap = dict(autoclick['config'])
            # V/D/IA agora é fonte única: DB filtrado desde o último reset.
            # Imune a eventos perdidos na rede e reinícios.
            last_reset = stats_snap.get('last_reset', 0) or 0
            try:
                panels_from_db = db.panels_stats_since(last_reset)
            except Exception:
                panels_from_db = {0: {'wins': 0, 'losses': 0, 'ia_approves': 0, 'ia_wins': 0, 'ia_losses': 0},
                                  1: {'wins': 0, 'losses': 0, 'ia_approves': 0, 'ia_wins': 0, 'ia_losses': 0}}
            body = json.dumps({
                'stats': stats_snap,
                'panels_stats': panels_from_db,
                'events': events,
                'config': cfg_snap,
                'paused_until': paused,
                'pause_reason': pause_reason,
                'pause_remaining': max(0, int(paused - now_ts)) if paused > now_ts else 0,
                'shadow_stats': shadow_snap,
                'shadow_results': shadow_results_snap,
                'calibration': calibration_snap,
                'tech_pause_start_total': tech_pause_start_total,
                'tech_pause_total_rounds': tech_pause_total_rounds,
                'crashes_total': db.count_crashes() if hasattr(db, 'count_crashes') else 0,
                'cfg_revision': cfg_revision_snap,
                'panel_cooldowns': cooldowns_snap,
            }).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self._cors()
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        elif path == '/api/schedule/state':
            with schedule_lock:
                out = {
                    'config': dict(schedule_state['config']),
                    'cells': list(schedule_state.get('cells') or []),
                    'recomputed_at': schedule_state.get('recomputed_at', 0),
                    'in_window': schedule_state.get('in_window', False),
                    'top_targets_now': list(schedule_state.get('top_targets_now') or []),
                }
            body = json.dumps(out, default=str).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self._cors(); self.send_header('Content-Length', str(len(body)))
            self.end_headers(); self.wfile.write(body); return
        elif path == '/api/strategy/state':
            with strategy_lock:
                code = strategy_state.get('active_code')
                idx = strategy_state.get('current_round_idx', 0)
                unit = strategy_state.get('stake_unit', 1.0)
                wins = strategy_state.get('wins', 0)
                losses = strategy_state.get('losses', 0)
                started_at = strategy_state.get('started_at', 0.0)
            active = _strategy_find(code) if code else None
            schedule = active.get('schedule') if active else []
            exhausted = bool(active and idx >= len(schedule))
            out = {
                'active_code': code,
                'active_name': active.get('display_name') if active else None,
                'current_round_idx': idx,
                'rounds_total': len(schedule),
                'cumulative': bool(active and active.get('cumulative')),
                'stake_unit': unit,
                'wins': wins,
                'losses': losses,
                'started_at': started_at,
                'exhausted': exhausted,
                'current_row': schedule[idx] if active and idx < len(schedule) else None,
            }
            body = json.dumps(out, default=str).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self._cors(); self.send_header('Content-Length', str(len(body)))
            self.end_headers(); self.wfile.write(body); return
        elif path == '/api/operation_mode':
            with autoclick_lock:
                hedge = bool((autoclick.get('config') or {}).get('hedge_enabled'))
            with strategy_lock:
                strat = strategy_state.get('active_code')
            with tail_lock:
                tail_auto = bool(tail_state['config'].get('auto_bet'))
            if hedge: mode = 'hedge'
            elif strat: mode = 'presetup'
            elif tail_auto: mode = 'pos10x'
            else: mode = 'manual'
            out = {
                'mode': mode,
                'strategy_code': strat,
                'hedge_enabled': hedge,
                'tail_auto_bet': tail_auto,
            }
            body = json.dumps(out).encode()
            self.send_response(200); self.send_header('Content-Type','application/json')
            self._cors(); self.send_header('Content-Length', str(len(body)))
            self.end_headers(); self.wfile.write(body); return
        elif path == '/api/tail/state':
            with tail_lock:
                with lock:
                    recent = list(state['crashes'])[-3:]
                last3 = [{'value': c['value'], 'bucket': _tail_bucket(c['value'])} for c in recent]
                out = {
                    'config': dict(tail_state['config']),
                    'pending_signal': tail_state.get('pending_signal'),
                    'last_outcomes': list(tail_state.get('last_outcomes') or [])[-15:],
                    'stats': dict(tail_state.get('stats') or {}),
                    'last3': last3,
                    'patterns_meta': TAIL_PATTERNS,
                }
            body = json.dumps(out, default=str).encode()
            self.send_response(200); self.send_header('Content-Type','application/json')
            self._cors(); self.send_header('Content-Length', str(len(body)))
            self.end_headers(); self.wfile.write(body); return
        elif path == '/api/strategy/catalog':
            with strategy_lock:
                cat = strategy_state.get('catalog')
            body = json.dumps(cat or {'strategies': []}, default=str).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self._cors(); self.send_header('Content-Length', str(len(body)))
            self.end_headers(); self.wfile.write(body); return
        elif path == '/api/cenario/balance':
            try:
                b = _balance.get_balance()
            except Exception as e:
                b = {'balance': None, 'source': 'error', 'error': str(e)}
            body = json.dumps(b).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self._cors(); self.send_header('Content-Length', str(len(body)))
            self.end_headers(); self.wfile.write(body); return
        elif path == '/api/cenario/state':
            with cenario_lock:
                out = {
                    'config': dict(cenario_state['config']),
                    'last_analysis': cenario_state.get('last_analysis'),
                    'pending_popup': cenario_state.get('pending_popup'),
                    'last_run_at': cenario_state.get('last_run_at'),
                    'last_run_total': cenario_state.get('last_run_total'),
                    'last_applied_bankroll': cenario_state.get('last_applied_bankroll', 0.0),
                    'last_applied_total': cenario_state.get('last_applied_total', 0),
                }
            try:
                out['current_total'] = db.count_crashes()
            except Exception:
                out['current_total'] = 0
            body = json.dumps(out, default=str).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self._cors()
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body); return
        elif path == '/api/autoclick/config':
            with autoclick_lock:
                body = json.dumps(autoclick['config']).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self._cors()
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        elif path == '/snippet.js':
            self._serve_static('snippet.js', 'application/javascript; charset=utf-8')
        elif path == '/CHANGELOG.md':
            try:
                with open(os.path.join(os.path.dirname(__file__), 'CHANGELOG.md'), 'rb') as f:
                    body = f.read()
                self.send_response(200)
                self.send_header('Content-Type', 'text/markdown; charset=utf-8')
                self._cors()
                self.send_header('Content-Length', str(len(body)))
                self.end_headers()
                self.wfile.write(body)
            except Exception:
                self.send_error(404)
        elif path == '/STRATEGIES_MANUAL.md':
            self._serve_static('STRATEGIES_MANUAL.md', 'text/markdown; charset=utf-8')
        elif path.startswith('/static/'):
            name = path[len('/static/'):]
            safe = os.path.basename(name)
            self._serve_static(safe, self._mime(safe))
        else:
            self.send_error(404)

    def do_POST(self):
        path = self.path.split('?')[0]
        if path == '/api/license/activate':
            length = int(self.headers.get('Content-Length', 0))
            try: body_in = json.loads(self.rfile.read(length) or b'{}')
            except: self.send_error(400); return
            key = (body_in.get('key') or '').strip()
            r = _license.activate(key)
            body = json.dumps(r).encode()
            self.send_response(200 if r.get('ok') else 400)
            self.send_header('Content-Type', 'application/json')
            self._cors(); self.send_header('Content-Length', str(len(body)))
            self.end_headers(); self.wfile.write(body); return
        if path == '/api/license/deactivate':
            _license.deactivate()
            self.send_response(204); self._cors(); self.end_headers(); return
        if not self._is_public_path(path) and not self._guard_licensed():
            return
        if self.path == '/api/autoclick/config':
            length = int(self.headers.get('Content-Length', 0))
            try: cfg = json.loads(self.rfile.read(length))
            except: self.send_error(400); return
            with autoclick_lock:
                autoclick['config'] = cfg
                autoclick['events'].append({
                    'kind': 'info',
                    'msg': f'Config atualizada: {"ATIVO" if cfg.get("enabled") else "OFF"} {"(DRY-RUN)" if cfg.get("dryRun") else ""} target={cfg.get("cashoutTarget")}x aposta=R${cfg.get("betAmount")}'
                })
            self.send_response(204); self._cors(); self.end_headers()
            return
        if self.path == '/api/updates/install':
            result = updater.install_update()
            body = json.dumps(result).encode()
            self.send_response(200 if result.get('ok') else 400)
            self.send_header('Content-Type', 'application/json')
            self._cors()
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            if result.get('ok'):
                # Agenda restart depois da resposta ter sido enviada
                updater.schedule_restart(delay_s=1.5)
            return
        if self.path == '/api/autoclick/pause':
            length = int(self.headers.get('Content-Length', 0))
            try: body_in = json.loads(self.rfile.read(length) or b'{}')
            except: body_in = {}
            minutes = float(body_in.get('minutes', 0) or 0)
            reason = str(body_in.get('reason', 'parada técnica'))[:200]
            extend = bool(body_in.get('extend', False))
            with autoclick_lock:
                if minutes > 0:
                    now_ts = time.time()
                    # 1.19.3: parada técnica = duração em minutos da config (techPauseMinutes).
                    # paused_until expira naturalmente; sem watchdog de rodadas.
                    autoclick['paused_until'] = now_ts + minutes * 60.0
                    autoclick['tech_pause_start_total'] = 0
                    autoclick['shadow_stats'] = {
                        0: {'bets': 0, 'wins': 0, 'losses': 0},
                        1: {'bets': 0, 'wins': 0, 'losses': 0},
                    }
                    autoclick['shadow_results'] = []
                    autoclick['pause_reason'] = reason
                    autoclick['stats']['loss_streak'] = 0
                    autoclick['events'].append({
                        'kind': 'info',
                        'msg': f'⏸ PARADA TÉCNICA: {reason} — retomando em {minutes:.0f} min',
                    })
                else:
                    autoclick['paused_until'] = 0
                    autoclick['pause_reason'] = ''
                    autoclick['tech_pause_start_total'] = 0
                    autoclick['events'].append({'kind': 'info', 'msg': 'parada técnica cancelada — retomando operação real'})
            self.send_response(204); self._cors(); self.end_headers()
            return
        if self.path == '/api/autoclick/panel_state':
            # Daemon reporta cooldown remaining por painel pra UI.
            length = int(self.headers.get('Content-Length', 0))
            try: body_in = json.loads(self.rfile.read(length) or b'{}')
            except: body_in = {}
            p = body_in.get('panel')
            rem = int(body_in.get('remaining', 0) or 0)
            kind = body_in.get('kind')
            with autoclick_lock:
                if isinstance(p, int) and p in autoclick.get('panel_cooldowns', {}):
                    autoclick['panel_cooldowns'][p] = {'remaining': max(0, rem), 'kind': kind}
            self.send_response(204); self._cors(); self.end_headers()
            return
        if self.path == '/api/autoclick/calibration':
            length = int(self.headers.get('Content-Length', 0))
            try: c = json.loads(self.rfile.read(length))
            except: self.send_error(400); return
            with autoclick_lock:
                autoclick['calibration'] = {
                    'remaining': int(c.get('remaining', 0)),
                    'total': int(c.get('total', 0)),
                }
            self.send_response(204); self._cors(); self.end_headers(); return
        if self.path == '/api/autoclick/shadow':
            # Shadow bets durante parada técnica — APENAS pra decidir retomada.
            # NÃO afetam panels_stats (V/D/IA) nem inserem em bets. Essas
            # métricas são exclusivas de apostas reais.
            #
            # Modelo "janela de 8" (1.16.0): a cada shadow resolvida, server
            # checa se a janela atingiu 8 amostras. Se sim:
            #   - ≥4 vitórias → libera (paused_until=0), reseta stats
            #   - <4 vitórias → reseta a janela e segue parado
            length = int(self.headers.get('Content-Length', 0))
            try: body_in = json.loads(self.rfile.read(length) or b'{}')
            except: body_in = {}
            p = body_in.get('panel')
            hit = body_in.get('hit')
            target = body_in.get('target')
            crash_v = body_in.get('crash')
            WINDOW = 8
            MIN_WINS = 4
            with autoclick_lock:
                if isinstance(p, int) and p in autoclick['shadow_stats'] and isinstance(hit, bool):
                    ss = autoclick['shadow_stats'][p]
                    ss['bets'] += 1
                    if hit: ss['wins'] += 1
                    else: ss['losses'] += 1
                    # registra detalhe do sinal pra UI
                    autoclick['shadow_results'].append({
                        'ts': time.time(),
                        'panel': p,
                        'target': float(target) if target is not None else None,
                        'crash': float(crash_v) if crash_v is not None else None,
                        'hit': hit,
                    })
                    # cap defensivo
                    if len(autoclick['shadow_results']) > 50:
                        autoclick['shadow_results'] = autoclick['shadow_results'][-50:]
                # avalia janela combinada (ambos painéis)
                total_bets = sum(int(v.get('bets', 0)) for v in autoclick['shadow_stats'].values())
                total_wins = sum(int(v.get('wins', 0)) for v in autoclick['shadow_stats'].values())
                if total_bets >= WINDOW:
                    fresh = {
                        0: {'bets': 0, 'wins': 0, 'losses': 0},
                        1: {'bets': 0, 'wins': 0, 'losses': 0},
                    }
                    if total_wins >= MIN_WINS:
                        autoclick['paused_until'] = 0
                        autoclick['shadow_stats'] = fresh
                        autoclick['shadow_results'] = []
                        autoclick['events'].append({
                            'kind': 'info',
                            'msg': f'✓ Simulação {total_wins}/{WINDOW} (mín {MIN_WINS}) — retomando operação real',
                        })
                    else:
                        autoclick['shadow_stats'] = fresh
                        autoclick['shadow_results'] = []
                        autoclick['events'].append({
                            'kind': 'info',
                            'msg': f'⏸ Simulação {total_wins}/{WINDOW} < {MIN_WINS} — reiniciando janela de 8',
                        })
            self.send_response(204); self._cors(); self.end_headers()
            return
        if self.path == '/api/autoclick/reset':
            with autoclick_lock:
                autoclick['stats'] = {'bets_today': 0, 'pnl_today': 0.0, 'loss_streak': 0, 'last_reset': time.time()}
                autoclick['panels_stats'] = {
                    0: {'wins': 0, 'losses': 0, 'ia_approves': 0, 'ia_wins': 0, 'ia_losses': 0},
                    1: {'wins': 0, 'losses': 0, 'ia_approves': 0, 'ia_wins': 0, 'ia_losses': 0},
                }
                autoclick['ia_last_approval'] = {0: False, 1: False}
                autoclick['paused_until'] = 0
                autoclick['pause_reason'] = ''
                autoclick['shadow_stats'] = {
                    0: {'bets': 0, 'wins': 0, 'losses': 0},
                    1: {'bets': 0, 'wins': 0, 'losses': 0},
                }
                autoclick['shadow_results'] = []
                autoclick['events'].append({'kind': 'info', 'msg': 'estatísticas zeradas'})
            self.send_response(204); self._cors(); self.end_headers()
            return
        if self.path == '/api/autoclick/event':
            # Daemon de autoclick posta eventos aqui
            length = int(self.headers.get('Content-Length', 0))
            try: ev = json.loads(self.rfile.read(length))
            except: self.send_error(400); return
            with autoclick_lock:
                entry = {'kind': ev.get('kind','info'), 'msg': ev.get('msg','')}
                if ev.get('hit') is True:
                    entry['hit'] = True
                    entry['delta_pnl'] = float(ev.get('delta_pnl', 0))
                autoclick['events'].append(entry)
                if 'delta_pnl' in ev:
                    autoclick['stats']['pnl_today'] += float(ev['delta_pnl'])
                if ev.get('kind') == 'bet':
                    autoclick['stats']['bets_today'] += 1
                if ev.get('hit') is True:
                    autoclick['stats']['loss_streak'] = 0
                elif ev.get('hit') is False:
                    autoclick['stats']['loss_streak'] += 1
                pnl_today = autoclick['stats']['pnl_today']
                bets_today = autoclick['stats']['bets_today']
                ls_now = autoclick['stats']['loss_streak']
                # Stats por painel
                panel_idx = ev.get('panel')
                if isinstance(panel_idx, int) and panel_idx in autoclick['panels_stats']:
                    ps = autoclick['panels_stats'][panel_idx]
                    if ev.get('hit') is True:
                        ps['wins'] += 1
                        if autoclick['ia_last_approval'][panel_idx]:
                            ps['ia_wins'] += 1
                        autoclick['ia_last_approval'][panel_idx] = False
                        try: db.resolve_last_open_bet(panel_idx, True, float(ev.get('delta_pnl', 0)))
                        except Exception: pass
                        # 1.20.0 — strategy outcome (win em qualquer painel reseta round_idx)
                        try:
                            with strategy_lock:
                                strat_active = bool(strategy_state.get('active_code'))
                            if strat_active:
                                snap_id = state.get('crashes_total', 0)
                                _strategy_register_hit(snap_id, panel_idx, True)
                        except Exception as _e:
                            print(f'[strategy_hook] {_e}')
                    elif ev.get('hit') is False:
                        ps['losses'] += 1
                        if autoclick['ia_last_approval'][panel_idx]:
                            ps['ia_losses'] += 1
                        autoclick['ia_last_approval'][panel_idx] = False
                        try: db.resolve_last_open_bet(panel_idx, False, float(ev.get('delta_pnl', 0)))
                        except Exception: pass
                        # 1.20.0 — strategy outcome (ambos perdem = round++)
                        try:
                            with strategy_lock:
                                strat_active = bool(strategy_state.get('active_code'))
                            if strat_active:
                                snap_id = state.get('crashes_total', 0)
                                _strategy_register_hit(snap_id, panel_idx, False)
                        except Exception as _e:
                            print(f'[strategy_hook] {_e}')
                    elif ev.get('kind') in ('bet', 'dry') and float(ev.get('amount', 0) or 0) > 0:
                        # Registra aposta (real ou simulada) pro aprendizado e DB.
                        # Em DRY há 2 eventos 'dry' (colocação e cashout); só o da
                        # colocação carrega 'amount' > 0 — evita duplicidade.
                        if ev.get('ia_approved'):
                            ps['ia_approves'] += 1
                            autoclick['ia_last_approval'][panel_idx] = True
                        try:
                            prob_val = ev.get('probability')
                            prob_val = float(prob_val) if prob_val is not None else None
                            db.insert_bet(
                                panel_idx,
                                float(ev.get('target', 0)),
                                float(ev.get('amount', 0)),
                                bool(ev.get('ia_approved')),
                                pattern=ev.get('pattern'),
                                probability=prob_val,
                            )
                        except Exception: pass
            # Auto-trigger da inteligência por streak de perdas
            try:
                with intelligence_lock:
                    icfg = dict(intelligence['config'])
                if icfg.get('enabled') and ev.get('hit') is False:
                    thr = int(icfg.get('reavaliarAposPerdas', 2))
                    if thr > 0 and ls_now >= thr:
                        threading.Thread(target=run_intelligence_eval, args=('loss_streak',), daemon=True).start()
            except Exception:
                pass
            # (antes: continuação do bloco de notificação telegram)
            # Notifica Telegram conforme preferência
            try:
                tcfg = telegram['config']
                if tcfg.get('enabled'):
                    delta = ev.get('delta_pnl')
                    hit = ev.get('hit')
                    msg = ev.get('msg', '')
                    safe_msg = html.escape(msg)  # evita 400 do Telegram em texto com <, >, &
                    tgt_v = ev.get('target')
                    # Crash da rodada da aposta: acha o PRIMEIRO crash no feed
                    # cujo timestamp é POSTERIOR ao momento da aposta
                    # (placed_at). Isso é imune a atraso de snippet e race de
                    # contagem — o tempo real é a fonte da verdade, não a
                    # contagem api_total.
                    crash_v = None
                    placed_at_v = ev.get('placed_at')
                    try:
                        with lock:
                            cs = list(state.get('crashes') or [])
                        if cs and placed_at_v:
                            pa = float(placed_at_v) - 0.5  # margem de segurança
                            # Varre do mais antigo pro mais recente; primeiro com at >= pa é o crash da rodada
                            for c in cs:
                                if (c.get('at') or 0) >= pa:
                                    crash_v = c.get('value')
                                    break
                        # Fallback secundário: último detectado se recente
                        if crash_v is None and cs:
                            last = cs[-1]
                            last_at = last.get('at') or 0
                            if time.time() - last_at < 10:
                                crash_v = last.get('value')
                    except Exception:
                        pass
                    if crash_v is None:
                        crash_v = ev.get('crash')
                    detail_lines = []
                    if tgt_v is not None:
                        try: detail_lines.append(f"🎯 Target: <b>{float(tgt_v):.2f}x</b>")
                        except: pass
                    # Crash omitido da mensagem por enquanto (feedback do user).
                    detail = ('\n' + '\n'.join(detail_lines)) if detail_lines else ''
                    if hit is True and tcfg.get('notifyHit'):
                        txt = (f"✅ <b>GANHO</b> +R${float(delta):.2f}"
                               f"{detail}\n"
                               f"P&amp;L hoje: R${pnl_today:+.2f} · {bets_today} apostas")
                        _send_telegram(txt)
                    elif hit is False and tcfg.get('notifyMiss'):
                        txt = (f"❌ <b>PERDA</b> {float(delta):+.2f}"
                               f"{detail}\n"
                               f"P&amp;L hoje: R${pnl_today:+.2f} · {bets_today} apostas")
                        _send_telegram(txt)
                    elif ev.get('kind') == 'bet' and tcfg.get('notifyBet'):
                        _send_telegram(f"🎯 {safe_msg}")
                    elif ev.get('kind') == 'err' and tcfg.get('notifyStop') and 'PARADA' in msg:
                        _send_telegram(f"🛑 <b>{safe_msg}</b>\nP&amp;L final: R${pnl_today:+.2f}")
            except Exception:
                pass
            self.send_response(204); self._cors(); self.end_headers()
            return
        if self.path == '/api/intelligence/config':
            length = int(self.headers.get('Content-Length', 0))
            try: cfg = json.loads(self.rfile.read(length))
            except: self.send_error(400); return
            with intelligence_lock:
                intelligence['config'].update(cfg)
            self.send_response(204); self._cors(); self.end_headers()
            return
        if self.path == '/api/intelligence/evaluate':
            v = run_intelligence_eval(reason='manual')
            body = json.dumps(v).encode()
            self.send_response(200); self.send_header('Content-Type', 'application/json')
            self._cors(); self.send_header('Content-Length', str(len(body)))
            self.end_headers(); self.wfile.write(body)
            return
        # ============== 1.19.0: schedule ==============
        if self.path == '/api/schedule/config':
            length = int(self.headers.get('Content-Length', 0))
            try: c = json.loads(self.rfile.read(length))
            except: self.send_error(400); return
            with schedule_lock:
                cur = schedule_state['config']
                for k in ('enabled', 'window_days', 'edge_threshold_pp', 'min_samples'):
                    if k in c: cur[k] = c[k]
            _save_schedule_cfg()
            self.send_response(204); self._cors(); self.end_headers(); return
        if self.path == '/api/schedule/recompute':
            try:
                with schedule_lock:
                    cfg = dict(schedule_state['config'])
                cells = _compute_schedule(
                    window_days=int(cfg.get('window_days', 28)),
                    min_samples=int(cfg.get('min_samples', 30)),
                )
                with schedule_lock:
                    schedule_state['cells'] = cells
                    schedule_state['recomputed_at'] = time.time()
                _check_in_window()
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self._cors()
                body = json.dumps({'ok': True, 'cells_count': len(cells)}).encode()
                self.send_header('Content-Length', str(len(body)))
                self.end_headers(); self.wfile.write(body); return
            except Exception as e:
                self.send_response(500); self._cors(); self.end_headers()
                self.wfile.write(json.dumps({'error': str(e)}).encode()); return
        # ============== 1.20.0: strategy presets ==============
        # ============== 1.22.0: operation mode (mutex Hedge/Pré-setup/Pós-10x) ==============
        if self.path == '/api/operation_mode':
            length = int(self.headers.get('Content-Length', 0))
            try: c = json.loads(self.rfile.read(length))
            except: self.send_error(400); return
            mode = str(c.get('mode') or 'manual').lower()
            if mode not in ('manual', 'hedge', 'presetup', 'pos10x'):
                self.send_response(400); self._cors(); self.end_headers(); return
            # Aplica side effects: limpa OS OUTROS modos
            with autoclick_lock:
                cfg = autoclick.get('config') or {}
                cfg['hedge_enabled'] = (mode == 'hedge')
                autoclick['config'] = cfg
                autoclick['cfg_revision'] = autoclick.get('cfg_revision', 0) + 1
            with tail_lock:
                tail_state['config']['enabled'] = (mode == 'pos10x')
                tail_state['config']['auto_bet'] = (mode == 'pos10x')
            if mode != 'presetup':
                with strategy_lock:
                    strategy_state['active_code'] = None
                    strategy_state['current_round_idx'] = 0
                    strategy_state['wins'] = 0
                    strategy_state['losses'] = 0
            elif mode == 'presetup':
                # presetup requer code+unit no body
                code = str(c.get('strategy_code') or '').strip()
                unit = float(c.get('stake_unit') or 1.0)
                if not code or not _strategy_find(code):
                    self.send_response(400); self._cors(); self.end_headers()
                    self.wfile.write(json.dumps({'error': 'strategy_code obrigatório e válido'}).encode())
                    return
                with strategy_lock:
                    strategy_state['active_code'] = code
                    strategy_state['current_round_idx'] = 0
                    strategy_state['wins'] = 0
                    strategy_state['losses'] = 0
                    strategy_state['started_at'] = time.time()
                    strategy_state['stake_unit'] = unit
                _save_strategy_cfg()
            _save_tail_cfg()
            with autoclick_lock:
                autoclick['events'].append({'kind': 'info', 'msg': f'⚙ Modo de operação: {mode.upper()}'})
            self.send_response(204); self._cors(); self.end_headers(); return
        # ============== 1.21.0: tail detector ==============
        if self.path == '/api/tail/config':
            length = int(self.headers.get('Content-Length', 0))
            try: c = json.loads(self.rfile.read(length))
            except: self.send_error(400); return
            with tail_lock:
                cur = tail_state['config']
                for k in ('enabled', 'min_edge_pp', 'allow_F_only',
                          'auto_bet', 'stake_p0', 'stake_p1',
                          'target_p0', 'target_p1', 'bet_p0', 'bet_p1',
                          'telegram_alert', 'telegram_min_edge_pp'):
                    if k in c: cur[k] = c[k]
            with autoclick_lock:
                autoclick['cfg_revision'] = autoclick.get('cfg_revision', 0) + 1
            _save_tail_cfg()
            self.send_response(204); self._cors(); self.end_headers(); return
        if self.path == '/api/tail/reset_stats':
            with tail_lock:
                tail_state['stats'] = {'fired': 0, 'won': 0, 'lost': 0, 'by_pattern': {}}
                tail_state['last_outcomes'] = []
                tail_state['pending_signal'] = None
            self.send_response(204); self._cors(); self.end_headers(); return
        if self.path == '/api/strategy/load':
            length = int(self.headers.get('Content-Length', 0))
            try: c = json.loads(self.rfile.read(length))
            except: self.send_error(400); return
            code = str(c.get('code') or '').strip()
            unit = float(c.get('stake_unit', 1.0) or 1.0)
            if not code or not _strategy_find(code):
                self.send_response(404); self._cors(); self.end_headers()
                self.wfile.write(json.dumps({'error': f'estrategia nao encontrada: {code}'}).encode()); return
            with strategy_lock:
                strategy_state['active_code'] = code
                strategy_state['current_round_idx'] = 0
                strategy_state['wins'] = 0
                strategy_state['losses'] = 0
                strategy_state['started_at'] = time.time()
                strategy_state['stake_unit'] = unit
            with autoclick_lock:
                autoclick['cfg_revision'] = autoclick.get('cfg_revision', 0) + 1
                autoclick['events'].append({'kind': 'info', 'msg': f'🎯 Estratégia carregada: {code} (unit=R${unit})'})
            _save_strategy_cfg()
            self.send_response(204); self._cors(); self.end_headers(); return
        if self.path == '/api/strategy/clear':
            with strategy_lock:
                strategy_state['active_code'] = None
                strategy_state['current_round_idx'] = 0
                strategy_state['wins'] = 0
                strategy_state['losses'] = 0
                strategy_state['started_at'] = 0.0
            with autoclick_lock:
                autoclick['cfg_revision'] = autoclick.get('cfg_revision', 0) + 1
                autoclick['events'].append({'kind': 'info', 'msg': '🎯 Estratégia desligada'})
            _save_strategy_cfg()
            self.send_response(204); self._cors(); self.end_headers(); return
        if self.path == '/api/strategy/reset':
            with strategy_lock:
                strategy_state['current_round_idx'] = 0
                strategy_state['wins'] = 0
                strategy_state['losses'] = 0
                strategy_state['started_at'] = time.time()
            with autoclick_lock:
                autoclick['cfg_revision'] = autoclick.get('cfg_revision', 0) + 1
            _save_strategy_cfg()
            self.send_response(204); self._cors(); self.end_headers(); return
        if self.path == '/api/strategy/outcome':
            # Hook do daemon após resolver a rodada do schedule.
            length = int(self.headers.get('Content-Length', 0))
            try: c = json.loads(self.rfile.read(length))
            except: self.send_error(400); return
            won = bool(c.get('any_panel_won'))
            strategy_record_outcome(won)
            self.send_response(204); self._cors(); self.end_headers(); return
        # ============== 1.19.0: hedge ==============
        if self.path == '/api/hedge/config':
            length = int(self.headers.get('Content-Length', 0))
            try: c = json.loads(self.rfile.read(length))
            except: self.send_error(400); return
            with autoclick_lock:
                cfg = autoclick.get('config') or {}
                for k in ('hedge_enabled', 'hedge_total_stake', 'hedge_profit_target', 'hedge_t_a'):
                    if k in c: cfg[k] = c[k]
                autoclick['config'] = cfg
                autoclick['cfg_revision'] = autoclick.get('cfg_revision', 0) + 1
            self.send_response(204); self._cors(); self.end_headers(); return
        # ============== 1.17.0: cenário ==============
        if self.path == '/api/cenario/config':
            length = int(self.headers.get('Content-Length', 0))
            try: c = json.loads(self.rfile.read(length))
            except: self.send_error(400); return
            with cenario_lock:
                cur = cenario_state['config']
                for k in ('enabled', 'interval', 'bankroll', 'target_profit',
                          'auto_apply_on_balance_change', 'balance_change_threshold',
                          'auto_apply_interval_rounds'):
                    if k in c: cur[k] = c[k]
            _save_cenario_cfg()
            self.send_response(204); self._cors(); self.end_headers(); return
        if self.path == '/api/cenario/analyze':
            length = int(self.headers.get('Content-Length', 0))
            try: body_in = json.loads(self.rfile.read(length) or b'{}')
            except: body_in = {}
            # permite override pontual de bankroll/target_profit
            with cenario_lock:
                if 'bankroll' in body_in: cenario_state['config']['bankroll'] = float(body_in['bankroll'])
                if 'target_profit' in body_in: cenario_state['config']['target_profit'] = float(body_in['target_profit'])
            result = _run_cenario_analysis()
            if not result:
                self.send_response(400); self._cors(); self.end_headers()
                self.wfile.write(json.dumps({'error': 'amostra insuficiente ou saldo/objetivo não definidos'}).encode())
                return
            body = json.dumps(result, default=str).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self._cors(); self.send_header('Content-Length', str(len(body)))
            self.end_headers(); self.wfile.write(body); return
        if self.path == '/api/cenario/apply':
            length = int(self.headers.get('Content-Length', 0))
            try: body_in = json.loads(self.rfile.read(length) or b'{}')
            except: body_in = {}
            rec = body_in.get('recommended')
            if not rec:
                # usa o último popup pendente
                with cenario_lock:
                    pp = cenario_state.get('pending_popup')
                    rec = pp.get('recommended') if pp else None
            if not rec:
                self.send_response(400); self._cors(); self.end_headers()
                self.wfile.write(json.dumps({'error': 'sem recomendação'}).encode()); return
            applied_cfg = _apply_cenario_recommendations(rec)
            with cenario_lock:
                cenario_state['pending_popup'] = None
            body = json.dumps({'ok': True, 'applied': applied_cfg}).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self._cors(); self.send_header('Content-Length', str(len(body)))
            self.end_headers(); self.wfile.write(body); return
        if self.path == '/api/cenario/dismiss':
            with cenario_lock:
                cenario_state['pending_popup'] = None
            self.send_response(204); self._cors(); self.end_headers(); return
        if self.path == '/api/cenario/balance':
            # endpoint pra o user editar manual o saldo (fallback do CDP)
            length = int(self.headers.get('Content-Length', 0))
            try: c = json.loads(self.rfile.read(length))
            except: self.send_error(400); return
            try:
                v = float(c.get('bankroll'))
                with cenario_lock:
                    cenario_state['config']['bankroll'] = v
                self.send_response(204); self._cors(); self.end_headers(); return
            except Exception as e:
                self.send_response(400); self._cors(); self.end_headers()
                self.wfile.write(json.dumps({'error': str(e)}).encode()); return

        if self.path == '/api/telegram/config':
            length = int(self.headers.get('Content-Length', 0))
            try: cfg = json.loads(self.rfile.read(length))
            except: self.send_error(400); return
            with telegram_lock:
                telegram['config'].update(cfg)
            _save_telegram_cfg()
            self.send_response(204); self._cors(); self.end_headers()
            return
        if self.path == '/api/telegram/test':
            ok, info = _telegram_send_sync('🔔 <b>Teste do IronDome</b>\nSe você leu isso, a integração está funcionando.')
            body = json.dumps({'ok': ok, 'info': info}).encode()
            self.send_response(200 if ok else 400)
            self.send_header('Content-Type', 'application/json')
            self._cors()
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        if self.path == '/api/events':
            length = int(self.headers.get('Content-Length', 0))
            raw = self.rfile.read(length)
            try:
                data = json.loads(raw)
            except Exception:
                self.send_error(400)
                return
            kind = data.get('kind')
            if kind == 'crash':
                v = float(data.get('value', 0))
                if v > 1.0 and v < 1000:
                    register_crash(v)
            elif kind == 'heartbeat':
                with lock:
                    state['ws_connected'] = bool(data.get('connected', False))
                    state['ws_msgs_received'] = int(data.get('msgsReceived', 0))
                    state['last_ws_msg_at'] = time.time() if data.get('connected') else state['last_ws_msg_at']
            self.send_response(204)
            self._cors()
            self.end_headers()
        else:
            self.send_error(404)

    def _serve_static(self, filename, mime):
        path = os.path.join(STATIC_DIR, filename)
        try:
            with open(path, 'rb') as f:
                body = f.read()
            self.send_response(200)
            self.send_header('Content-Type', mime)
            self._cors()
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        except FileNotFoundError:
            self.send_error(404, f'Not found: {filename}')

    @staticmethod
    def _mime(name):
        if name.endswith('.html'): return 'text/html; charset=utf-8'
        if name.endswith('.js'): return 'application/javascript; charset=utf-8'
        if name.endswith('.css'): return 'text/css; charset=utf-8'
        if name.endswith('.json'): return 'application/json'
        if name.endswith('.svg'): return 'image/svg+xml'
        return 'application/octet-stream'


def _run_cenario_analysis() -> dict | None:
    """Executa análise. Retorna dict do resultado ou None em caso de falta de dados."""
    try:
        with autoclick_lock:
            cur_cfg = dict(autoclick.get('config') or {})
        with cenario_lock:
            ccfg = dict(cenario_state['config'])
        bankroll = float(ccfg.get('bankroll') or 0.0)
        target_profit = float(ccfg.get('target_profit') or 0.0)
        if bankroll <= 0 or target_profit <= 0:
            return None
        # últimas 300 do DB (fonte canônica)
        try:
            crashes = db.load_recent_crashes(300)
        except Exception:
            crashes = []
        if len(crashes) < 50:
            return None
        # calibração da IA: pega histórico DB local
        ia_bands = []
        try:
            with db._lock:
                rows = db._conn.execute('''
                    WITH buckets AS (
                        SELECT
                            CASE
                                WHEN probability < 0.50 THEN '<50%'
                                WHEN probability < 0.60 THEN '50-60%'
                                WHEN probability < 0.70 THEN '60-70%'
                                WHEN probability < 0.80 THEN '70-80%'
                                WHEN probability < 0.90 THEN '80-90%'
                                ELSE '90%+'
                            END AS band, hit, probability, delta_pnl
                        FROM bets WHERE ia_approved=1 AND resolved_ts IS NOT NULL
                          AND amount > 0 AND probability IS NOT NULL
                    )
                    SELECT band, COUNT(*), SUM(hit*100.0)/COUNT(*),
                           AVG(probability)*100, COALESCE(SUM(delta_pnl), 0)
                    FROM buckets GROUP BY band
                ''').fetchall()
            ia_bands = [{'band': r[0], 'total': r[1], 'hit_rate': r[2],
                         'avg_prob': r[3], 'pnl': r[4]} for r in rows]
        except Exception:
            ia_bands = []
        result = _cenario.analyze(
            crashes=crashes,
            bankroll=bankroll,
            target_profit=target_profit,
            current_cfg=cur_cfg,
            ia_calibration=ia_bands,
        )
        with cenario_lock:
            cenario_state['last_analysis'] = result
            cenario_state['last_run_at'] = time.time()
            with autoclick_lock:
                cenario_state['last_run_total'] = autoclick['stats'].get('bets_today', 0)
        return result
    except Exception as e:
        print(f'[cenario] erro: {e}')
        return None


def _apply_cenario_recommendations(rec: dict) -> dict:
    """Aplica recomendação ao autoclick.config. Retorna o config aplicado."""
    with autoclick_lock:
        cfg = dict(autoclick.get('config') or {})
        panels = list(cfg.get('panels') or [{}, {}])
        while len(panels) < 2: panels.append({})
        for i in (0, 1):
            r = (rec.get('panels') or [None, None])[i]
            if not r: continue
            panels[i] = dict(panels[i])
            for k in ('enabled', 'cashoutTarget', 'betAmount', 'operationMode'):
                if k in r:
                    panels[i][k] = r[k]
        cfg['panels'] = panels
        for k in ('stopLoss', 'stopWin', 'maxLossStreak', 'cooldownAfterLoss', 'operationMode'):
            if k in rec:
                cfg[k] = rec[k]
        autoclick['config'] = cfg
        # 1.17.0: bump revision pra UI saber que precisa refrescar form
        autoclick['cfg_revision'] = autoclick.get('cfg_revision', 0) + 1
        autoclick['events'].append({
            'kind': 'info',
            'msg': f'⚙️ CENÁRIO aplicado: {len(rec.get("panels",[]))} painéis ajustados, stop−R${rec.get("stopLoss",0):.0f} stopwin+R${rec.get("stopWin",0):.0f}',
        })
        return cfg


def _tech_pause_watchdog():
    """1.19.3: parada técnica passou a ser por TEMPO (paused_until expira sozinho).
    Watchdog mantido só pra emitir o evento de retomada quando paused_until expira."""
    was_paused = False
    while True:
        try:
            time.sleep(3)
            with autoclick_lock:
                paused_until = autoclick.get('paused_until', 0)
                reason = autoclick.get('pause_reason', '') or ''
            now = time.time()
            paused = paused_until > now
            if was_paused and not paused and 'janela quente' not in reason.lower():
                # Saiu da pausa por expiração natural do paused_until
                with autoclick_lock:
                    autoclick['pause_reason'] = ''
                    autoclick['tech_pause_start_total'] = 0
                    autoclick['events'].append({
                        'kind': 'info',
                        'msg': '✓ Parada técnica concluída — retomando operação',
                    })
            was_paused = paused
        except Exception as e:
            print(f'[tech_pause_watchdog] {e}')


def _cenario_watchdog():
    """A cada 5s, checa 2 gatilhos:
    1. Passaram N novas rodadas desde a última análise → roda + popup se significant
    2. Saldo (lido via balance_reader) variou > X% desde o último auto-apply →
       roda análise + aplica silenciosamente + notifica Telegram
    """
    while True:
        try:
            time.sleep(5)
            with cenario_lock:
                ccfg = dict(cenario_state['config'])
                last_total = cenario_state.get('last_run_total', 0)
                pending = cenario_state.get('pending_popup')
                last_applied_bankroll = cenario_state.get('last_applied_bankroll', 0.0)
            if not ccfg.get('enabled'):
                continue

            # ---------- gestão automatizada: 2 gatilhos ----------
            if ccfg.get('auto_apply_on_balance_change'):
                # estado atual
                try:
                    binfo = _balance.get_balance()
                except Exception:
                    binfo = None
                cur_bal = float((binfo or {}).get('balance') or 0.0)
                try:
                    cur_total_rounds = db.count_crashes()
                except Exception:
                    cur_total_rounds = 0
                with cenario_lock:
                    last_applied_total = cenario_state.get('last_applied_total', 0)
                # baseline: na 1ª iteração, marca saldo + total atual sem aplicar
                if last_applied_bankroll <= 0:
                    with cenario_lock:
                        cenario_state['last_applied_bankroll'] = cur_bal if cur_bal > 0 else 0.0
                        cenario_state['last_applied_total'] = cur_total_rounds
                        if cur_bal > 0:
                            cenario_state['config']['bankroll'] = cur_bal
                    if cur_bal > 0:
                        # persiste baseline pra sobreviver restart
                        _save_cenario_cfg()
                else:
                    threshold = float(ccfg.get('balance_change_threshold') or 0.20)
                    interval_rounds = int(ccfg.get('auto_apply_interval_rounds') or 20)
                    bal_delta = (abs(cur_bal - last_applied_bankroll) / last_applied_bankroll) if last_applied_bankroll > 0 and cur_bal > 0 else 0.0
                    rounds_delta = cur_total_rounds - last_applied_total
                    triggered = []
                    if bal_delta >= threshold and cur_bal > 0:
                        triggered.append(('saldo', f'saldo {("↗" if cur_bal > last_applied_bankroll else "↘")} {bal_delta*100:.0f}%'))
                    if rounds_delta >= interval_rounds and interval_rounds > 0:
                        triggered.append(('rodadas', f'{rounds_delta} rodadas desde último apply'))
                    if triggered:
                        # atualiza bankroll na config e roda análise
                        with cenario_lock:
                            if cur_bal > 0:
                                cenario_state['config']['bankroll'] = cur_bal
                        result = _run_cenario_analysis()
                        if result and result.get('changes'):
                            _apply_cenario_recommendations(result['recommended'])
                            with cenario_lock:
                                cenario_state['last_applied_bankroll'] = cur_bal if cur_bal > 0 else last_applied_bankroll
                                cenario_state['last_applied_total'] = cur_total_rounds
                            _save_cenario_cfg()
                            try:
                                import html as _html
                                reasons = result.get('significance_reasons') or []
                                body = '\n'.join(f'• {_html.escape(r)}' for r in reasons[:6])
                                gatilho_txt = ', '.join(t[1] for t in triggered)
                                msg = (f'🤖 <b>Gestão auto · {gatilho_txt}</b>\n'
                                       f'{len(result["changes"])} parâmetro(s) reaplicado(s):\n{body}')
                                threading.Thread(target=_telegram_send_sync, args=(msg,), daemon=True).start()
                            except Exception:
                                pass
                            with autoclick_lock:
                                autoclick['events'].append({
                                    'kind': 'info',
                                    'msg': f'🤖 Gestão auto · {", ".join(t[0] for t in triggered)} · {len(result["changes"])} aplicados',
                                })
                        else:
                            # sem mudanças: ainda assim atualiza baseline pra não disparar continuamente
                            with cenario_lock:
                                cenario_state['last_applied_bankroll'] = cur_bal if cur_bal > 0 else last_applied_bankroll
                                cenario_state['last_applied_total'] = cur_total_rounds
                            _save_cenario_cfg()
                            with autoclick_lock:
                                gatilho_txt = ', '.join(t[0] for t in triggered)
                                autoclick['events'].append({
                                    'kind': 'info',
                                    'msg': f'🤖 Gestão auto · {gatilho_txt} · cenário avaliado · estratégia ótima, nada a alterar',
                                })

            # ---------- gatilho 1: intervalo de rodadas ----------
            interval = max(20, int(ccfg.get('interval') or 100))
            try:
                cur_total = db.count_crashes()
            except Exception:
                continue
            if last_total == 0:
                with cenario_lock:
                    cenario_state['last_run_total'] = cur_total
                continue
            if cur_total - last_total < interval:
                continue
            result = _run_cenario_analysis()
            if not result: continue
            with cenario_lock:
                cenario_state['last_run_total'] = cur_total
            if result.get('significant') and not pending:
                with cenario_lock:
                    cenario_state['pending_popup'] = result
                try:
                    import html as _html
                    reasons = result.get('significance_reasons') or []
                    body = '\n'.join(f'• {_html.escape(r)}' for r in reasons[:6])
                    if len(reasons) > 6: body += f'\n• … mais {len(reasons) - 6}'
                    msg = f'🧠 <b>Cenário sugere mudanças</b>\n{body}\n\nAbra a aba CENÁRIO pra revisar e aplicar.'
                    threading.Thread(target=_telegram_send_sync, args=(msg,), daemon=True).start()
                except Exception:
                    pass
                with autoclick_lock:
                    autoclick['events'].append({
                        'kind': 'info',
                        'msg': f'🧠 Cenário: {len(result.get("changes", []))} mudanças sugeridas',
                    })
        except Exception as e:
            print(f'[cenario_watchdog] {e}')


def main():
    db.init_db()
    cloud_sync.start()
    _balance.start()
    _load_cenario_cfg()
    threading.Thread(target=_cenario_watchdog, daemon=True, name='cenario-watchdog').start()
    threading.Thread(target=_tech_pause_watchdog, daemon=True, name='tech-pause-watchdog').start()
    _load_schedule_cfg()
    threading.Thread(target=_schedule_watchdog, daemon=True, name='schedule-watchdog').start()
    _load_strategy_catalog()
    _load_strategy_cfg()
    _load_tail_cfg()
    _load_telegram_cfg()
    try:
        loaded = db.load_recent_crashes(2000)
        with lock:
            state['crashes'] = deque(loaded, maxlen=2000)
            state['crashes_total'] = db.count_crashes()
        ps = db.get_panels_stats()
        with autoclick_lock:
            autoclick['panels_stats'] = ps
        print(f'DB: {len(loaded)} crashes carregados, panels_stats: {ps}')
    except Exception as e:
        print(f'DB warn: {e}')
    server = ThreadingHTTPServer(('127.0.0.1', PORT), Handler)
    if os.path.exists(CERT_FILE) and os.path.exists(KEY_FILE):
        context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
        context.load_cert_chain(certfile=CERT_FILE, keyfile=KEY_FILE)
        server.socket = context.wrap_socket(server.socket, server_side=True)
        scheme = 'https'
    else:
        scheme = 'http'
    print(f'Aviator Signals backend rodando em {scheme}://localhost:{PORT}')
    print(f'Dashboard: {scheme}://localhost:{PORT}/')
    server.serve_forever()


if __name__ == '__main__':
    main()
