"""IronDome Cloud Operator — auto-click local seguindo estratégia da cloud.

Fluxo:
  1. Lê config (api_key, casa)
  2. Conecta na cloud via api_key, lê /api/v1/operator/state
  3. Se enabled=True, abre Brave com CDP 9222 e a URL da casa
  4. Subscreve SSE /api/v1/stream pra receber crashes
  5. Mantém estado da estratégia (round_idx, daily_pnl, cycles)
  6. A cada novo crash:
       - Avança round_idx ou reseta (10x)
       - Atualiza daily_pnl
       - Se enabled+gate verde, programa stake/target nos painéis e clica "Apostar"
  7. POST /heartbeat a cada 5s (com daily_pnl, round_idx, cycles)
  8. Se cap atingido, cloud retorna paused_reason=cap_diario, operator para de apostar
"""
from __future__ import annotations
import json
import os
import re
import sys
import time
import urllib.request
import urllib.error
import threading
import subprocess
from pathlib import Path

VERSION = '1.0.9'

CONFIG_DIR = Path.home() / '.irondome-operator'
CONFIG_PATH = CONFIG_DIR / 'config.json'
HOUSES_PATH = Path(__file__).parent / 'houses.json'

DEFAULT_CLOUD_URL = 'https://irondome-cloud.vercel.app'
CDP_PORT = 9222
HEARTBEAT_INTERVAL = 3.0
STATE_POLL_INTERVAL = 3.0


# ─── Config ────────────────────────────────────────────────────────────────
def load_config() -> dict:
    if not CONFIG_PATH.exists():
        return {}
    try:
        return json.loads(CONFIG_PATH.read_text())
    except Exception:
        return {}


def save_config(cfg: dict):
    CONFIG_DIR.mkdir(exist_ok=True)
    CONFIG_PATH.write_text(json.dumps(cfg, indent=2))


def first_run_setup() -> dict:
    print('=' * 60)
    print('  IronDome Cloud Operator — Configuração inicial')
    print('=' * 60)
    print()
    print('Pegue sua api_key em:')
    print(f'  {DEFAULT_CLOUD_URL}/  (Admin → seu tenant → revelar key)')
    print()
    api_key = input('Cole a sua api_key: ').strip()
    if not api_key.startswith('idk_'):
        print('aviso: a key normalmente começa com "idk_". Continuando mesmo assim.')
    cloud_url = input(f'URL da cloud [{DEFAULT_CLOUD_URL}]: ').strip() or DEFAULT_CLOUD_URL
    cfg = {'api_key': api_key, 'cloud_url': cloud_url}
    save_config(cfg)
    print(f'\nSalvo em {CONFIG_PATH}')
    return cfg


# ─── HTTP helpers ──────────────────────────────────────────────────────────
def http_json(method: str, url: str, api_key: str, body: dict | None = None) -> dict:
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method,
        headers={'x-api-key': api_key, 'Content-Type': 'application/json'})
    try:
        with urllib.request.urlopen(req, timeout=15) as r:
            return json.loads(r.read().decode())
    except urllib.error.HTTPError as e:
        print(f'  HTTP {e.code}: {e.read().decode()[:200]}')
        raise
    except Exception as e:
        print(f'  network: {e}')
        raise


# ─── CDP / Brave control ───────────────────────────────────────────────────
class BraveController:
    def __init__(self, house_url: str):
        self.house_url = house_url
        self.ws = None
        self.next_id = 1

    def ensure_brave(self):
        """Verifica se Brave está rodando com CDP 9222. Se não, tenta abrir."""
        try:
            with urllib.request.urlopen(f'http://localhost:{CDP_PORT}/json/version', timeout=2) as r:
                ver = json.loads(r.read().decode())
                print(f'  Brave já rodando: {ver.get("Browser", "?")}')
                return True
        except Exception:
            pass
        # tenta abrir Brave manualmente
        brave_path = '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'
        if not os.path.exists(brave_path):
            print(f'  ERRO: Brave Browser não instalado em {brave_path}')
            print(f'  Baixe em: https://brave.com/download/')
            return False
        print(f'  Abrindo Brave com CDP 9222...')
        subprocess.Popen([
            brave_path,
            f'--remote-debugging-port={CDP_PORT}',
            '--remote-allow-origins=*',
            self.house_url,
        ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        # aguarda subir
        for _ in range(20):
            try:
                urllib.request.urlopen(f'http://localhost:{CDP_PORT}/json/version', timeout=1)
                print(f'  Brave pronto.')
                return True
            except Exception:
                time.sleep(1)
        print(f'  ERRO: Brave não abriu CDP no tempo esperado.')
        return False

    def connect_target(self):
        """Acha o IFRAME da Spribe (aviator-next.spribegaming.com) e conecta CDP nele direto.
        A Spribe é o provider real do jogo — o iframe tem todos os botões e inputs."""
        try:
            import websocket  # type: ignore
        except ImportError:
            print('  ERRO: pip install websocket-client')
            return False
        try:
            with urllib.request.urlopen(f'http://localhost:{CDP_PORT}/json', timeout=3) as r:
                tabs = json.loads(r.read().decode())
        except Exception as e:
            print(f'  ERRO listando tabs: {e}')
            return False
        # Prioridade: iframe da Spribe (game real). Fallback: aba 7K (caso ainda não tenha aberto o jogo).
        target = None
        for t in tabs:
            url = t.get('url', '')
            if 'spribegaming' in url or 'aviator-next' in url:
                target = t
                print(f'  alvo: iframe Spribe ({url[:60]}...)')
                break
        if not target:
            for t in tabs:
                url = t.get('url', '')
                if 'aviator' in url.lower() or '7k.bet' in url.lower():
                    target = t
                    print(f'  alvo (fallback wrapper): {url[:60]}')
                    break
        if not target:
            print(f'  Aviator não encontrado. Abra {self.house_url} no Brave e clique JOGAR.')
            return False
        try:
            self.ws = websocket.create_connection(target['webSocketDebuggerUrl'], timeout=5)
            print(f'  CDP conectado: {target.get("title", "<iframe>")}')
            # Runtime.enable / Page.enable enviados sem usar cdp_call (evita recursão de reconnect)
            try:
                for method in ('Runtime.enable', 'Page.enable'):
                    msg = {'id': self.next_id, 'method': method, 'params': {}}
                    self.next_id += 1
                    self.ws.send(json.dumps(msg))
                    # consome resposta
                    while True:
                        raw = self.ws.recv()
                        d = json.loads(raw)
                        if d.get('id') == msg['id']:
                            break
            except Exception:
                pass
            return True
        except Exception as e:
            print(f'  ERRO conectando CDP: {e}')
            return False

    def cdp_call(self, method: str, params: dict | None = None, session_id: str | None = None) -> dict:
        msg = {'id': self.next_id, 'method': method, 'params': params or {}}
        if session_id:
            msg['sessionId'] = session_id
        self.next_id += 1
        # Send + receive com auto-reconnect em caso de Broken pipe / WebSocket morto.
        for attempt in (1, 2):
            try:
                if not self.ws:
                    raise RuntimeError('CDP não conectado')
                self.ws.send(json.dumps(msg))
                while True:
                    raw = self.ws.recv()
                    data = json.loads(raw)
                    if data.get('id') == msg['id']:
                        return data
                    # senão é evento, ignora
            except (BrokenPipeError, ConnectionError, OSError) as e:
                # Conexão morta — Brave pode ter recarregado a aba ou trocado de iframe.
                # Tenta reconectar e refazer a chamada.
                print(f'  [CDP] connection lost ({type(e).__name__}: {e}) — reconectando…')
                try:
                    if self.ws:
                        try: self.ws.close()
                        except Exception: pass
                    self.ws = None
                except Exception: pass
                if attempt == 1 and self.connect_target():
                    print(f'  [CDP] reconectado, retry call')
                    continue
                raise RuntimeError(f'CDP call failed after reconnect attempt: {e}')
            except Exception as e:
                # WebSocket library exception (ex: WebSocketConnectionClosedException)
                if 'closed' in str(e).lower() or 'broken' in str(e).lower():
                    print(f'  [CDP] ws error ({e}) — reconectando…')
                    try:
                        if self.ws:
                            try: self.ws.close()
                            except Exception: pass
                        self.ws = None
                    except Exception: pass
                    if attempt == 1 and self.connect_target():
                        continue
                raise

    def find_iframe_targets(self) -> list[dict]:
        """Lista todos os targets/frames do Brave (incluindo iframes da Spribe)."""
        try:
            with urllib.request.urlopen(f'http://localhost:{CDP_PORT}/json', timeout=3) as r:
                tabs = json.loads(r.read().decode())
            return tabs
        except Exception:
            return []

    def eval_js(self, expr: str, session_id: str | None = None) -> dict:
        """Executa JS na aba (ou em iframe via session) e retorna o RemoteObject completo."""
        return self.cdp_call(
            'Runtime.evaluate',
            {'expression': expr, 'returnByValue': True, 'awaitPromise': True},
            session_id=session_id,
        )

    def cdp_type_value(self, panel_idx: int, kind: str, value: str) -> dict:
        """Escreve um valor num input usando CDP Input.insertText — simula digitação
        real do teclado, ignora qualquer truque do framework Angular. Usado como
        fallback quando setter+InputEvent não conseguem atualizar o FormControl.

        kind = 'stake' (input pequeno) ou 'target' (input full-width).
        Retorna {ok, btn_text, stake_rendered_after}."""
        import json as _json
        sel = {
            'stake': '.input:not(.full-width) > input',
            'target': '.input.full-width > input',
        }.get(kind)
        if not sel:
            return {'ok': False, 'msg': f'kind invalido: {kind}'}
        # 1. foca o input + seleciona todo o conteúdo atual
        js_focus = f"""
        (function() {{
          const inputs = Array.from(document.querySelectorAll({_json.dumps(sel)}));
          const el = inputs[{panel_idx}];
          if (!el) return {{ ok: false, msg: 'input nao encontrado' }};
          el.focus();
          try {{ el.setSelectionRange(0, (el.value || '').length); }} catch (e) {{}}
          return {{ ok: true, before: el.value || '' }};
        }})()
        """
        focus_res = self._extract_result(self.eval_js(js_focus))
        if not focus_res or not focus_res.get('ok'):
            return {'ok': False, 'msg': f'focus falhou: {focus_res}'}
        # 2. apaga o selecionado via Delete key (simula tecla Delete real)
        try:
            self.cdp_call('Input.dispatchKeyEvent', {
                'type': 'rawKeyDown',
                'windowsVirtualKeyCode': 46, 'nativeVirtualKeyCode': 46,
                'key': 'Delete', 'code': 'Delete',
            })
            self.cdp_call('Input.dispatchKeyEvent', {
                'type': 'keyUp',
                'windowsVirtualKeyCode': 46, 'nativeVirtualKeyCode': 46,
                'key': 'Delete', 'code': 'Delete',
            })
        except Exception:
            pass
        # 3. insere o novo texto via Input.insertText (simula IME / paste)
        try:
            self.cdp_call('Input.insertText', {'text': str(value)})
        except Exception as e:
            return {'ok': False, 'msg': f'insertText falhou: {e}'}
        # 4. blur pra Angular finalizar mudança
        self.eval_js(f"""
        (function() {{
          const inputs = Array.from(document.querySelectorAll({_json.dumps(sel)}));
          const el = inputs[{panel_idx}];
          if (el) {{
            el.dispatchEvent(new Event('change', {{ bubbles: true }}));
            el.blur();
          }}
        }})()
        """)
        # 5. lê o estado pós-write
        check_js = f"""
        (function() {{
          const inputs = Array.from(document.querySelectorAll({_json.dumps(sel)}));
          const el = inputs[{panel_idx}];
          const btns = Array.from(document.querySelectorAll('button.btn.bet'));
          const btn = btns[{panel_idx}];
          return {{
            ok: true,
            value: el ? el.value : null,
            btn_text: btn ? (btn.textContent||'').trim() : null,
          }};
        }})()
        """
        return self._extract_result(self.eval_js(check_js))

    def _extract_result(self, cdp_response: dict) -> dict:
        """Extrai o valor do retorno de Runtime.evaluate. Path correto: result.result.value."""
        if 'error' in cdp_response:
            return {'ok': False, 'msg': f"cdp_error: {cdp_response['error'].get('message','?')}"}
        outer = cdp_response.get('result', {})
        if outer.get('exceptionDetails'):
            return {'ok': False, 'msg': f"js_exception: {outer['exceptionDetails'].get('text','?')}"}
        inner = outer.get('result', {})
        if inner.get('type') == 'undefined':
            return {'ok': False, 'msg': 'js returned undefined'}
        return inner.get('value') or {'ok': False, 'msg': 'no value in result'}

    def arm_cashout(self, panel_idx: int, target: float, stake: float, jitter_min_ms: int = 0, jitter_max_ms: int = 0) -> dict:
        """Arma watcher no browser que polla o botão de cashout do painel a cada 20ms.
        Quando o multiplicador atinge target, clica o botão.
        Logs: armazena o texto do botão na primeira detecção do estado de cashout
        (pra debug) e na hora do click."""
        js = f"""
        (function() {{
          const panelIdx = {panel_idx};
          const target = {target:.2f};
          const stake = {stake:.4f};
          const tk = '__sb_armT_' + panelIdx;
          const rk = '__sb_armR_' + panelIdx;
          const dk = '__sb_armD_' + panelIdx;  // debug log
          if (window[tk]) {{ clearInterval(window[tk]); window[tk] = null; }}
          window[rk] = null;
          // phase guard: começa em 'check_initial'. Se na primeira tick o botão JÁ estiver
          // em modo Cashout, é resíduo da rodada anterior (DOM ainda não atualizou) → vai
          // pra 'wait_reset' e ignora ticks até o botão sair desse estado. Só então 'live'.
          // Sem isso, a 1ª tick lê val=stake×mult_anterior, dispara b.click() falso positivo,
          // e a Spribe processa o click no mult corrente (baixo) — saída precoce.
          window[dk] = {{ first_text: null, last_text: null, last_val: null, last_mult: null,
                          ticks: 0, phase: 'check_initial', target: target, stake: stake }};
          const t0 = Date.now();
          window[tk] = setInterval(() => {{
            if (Date.now() - t0 > 60000) {{
              clearInterval(window[tk]); window[tk] = null;
              window[rk] = {{ timeout: true, target: target, stake: stake, debug: window[dk] }};
              return;
            }}
            try {{
              window[dk].ticks++;
              const buttons = Array.from(document.querySelectorAll('.buttons-block > button'));
              const b = buttons[panelIdx];
              if (!b) return;
              const txtRaw = (b.textContent || '').trim();
              const txt = txtRaw.toLowerCase();
              window[dk].last_text = txtRaw;
              const inCashout = txt.includes('cashout') || txt.includes('sacar')
                              || txt.includes('saque') || txt.includes('retirar');
              // state machine pra ignorar resíduo do DOM da rodada anterior
              if (window[dk].phase === 'check_initial') {{
                window[dk].phase = inCashout ? 'wait_reset' : 'live';
                return;
              }}
              if (window[dk].phase === 'wait_reset') {{
                if (!inCashout) window[dk].phase = 'live';
                return;
              }}
              // phase === 'live'
              if (!inCashout) return;
              if (!window[dk].first_text) window[dk].first_text = txtRaw;
              // Extrai TODOS os números do texto. Em Spribe normalmente o primeiro é
              // o valor em BRL (stake × multiplier). Se houver mais (ex: "@ 4.00x"),
              // o último com 'x' é o multiplier direto.
              const nums = [...txtRaw.matchAll(/(\\d+[.,]?\\d*)/g)].map(x => parseFloat(x[1].replace(',', '.')));
              if (!nums.length) return;
              // heurística: se texto tem 'x' (multiplier explícito), usa o número antes do 'x'.
              // senão usa o primeiro (BRL value) e divide por stake.
              let mult = null;
              const xMatch = txtRaw.match(/(\\d+[.,]?\\d*)\\s*x/i);
              if (xMatch) {{
                mult = parseFloat(xMatch[1].replace(',', '.'));
                window[dk].mode = 'mult-direct';
              }} else {{
                const val = nums[0];
                if (!isFinite(val) || val <= 0 || stake <= 0) return;
                mult = val / stake;
                window[dk].mode = 'brl/stake';
                window[dk].last_val = val;
              }}
              window[dk].last_mult = mult;
              if (mult < 1.05) return;  // ignore valores estranhos no início (mult<1.05 = sem rodada ativa real)
              if (mult >= target - 0.03) {{
                clearInterval(window[tk]); window[tk] = null;
                try {{ b.click(); }} catch (e) {{}}
                window[rk] = {{ clicked: true, mult: mult, target: target, debug: window[dk], txt: txtRaw }};
              }}
            }} catch (e) {{ window[dk].err = String(e); }}
          }}, 20);
          return {{ armed: true, panel: panelIdx, target: target, stake: stake }};
        }})()
        """
        return self._extract_result(self.eval_js(js))

    def peek_cashout_state(self, panel_idx: int) -> dict:
        """Lê o estado atual do botão de cashout (pra debug)."""
        js = f"""
        (function() {{
          const buttons = Array.from(document.querySelectorAll('.buttons-block > button'));
          const b = buttons[{panel_idx}];
          if (!b) return {{ found: false }};
          return {{
            found: true,
            text: (b.textContent || '').trim(),
            cls: b.className,
            armed: window['__sb_armT_' + {panel_idx}] != null,
            debug: window['__sb_armD_' + {panel_idx}] || null,
            result: window['__sb_armR_' + {panel_idx}] || null,
          }};
        }})()
        """
        return self._extract_result(self.eval_js(js))

    def check_armed(self, panel_idx: int) -> dict | None:
        """Lê o resultado do watcher armado. Retorna None se ainda em curso."""
        js = f"""
        (function() {{
          const rk = '__sb_armR_' + {panel_idx};
          const r = window[rk] || null;
          if (r) window[rk] = null;
          return r;
        }})()
        """
        res = self._extract_result(self.eval_js(js))
        return res if res and (res.get('clicked') or res.get('timeout')) else None

    def set_panel(self, panel_idx: int, stake: float, target: float, selectors: dict, jitter_min_ms: int = 0, jitter_max_ms: int = 0) -> dict:
        """Configura um painel da Spribe (Aviator). Aplica jitter aleatório antes do click."""
        js = f"""
        (function() {{
          const panelIdx = {panel_idx};
          const stake = {stake:.2f};
          const target = {target:.2f};
          const jMin = {int(jitter_min_ms)};
          const jMax = {int(jitter_max_ms)};
          // pega todos os inputs visíveis dentro de .input (parent class)
          const stakeInputs = Array.from(document.querySelectorAll('.input:not(.full-width) > input'));
          const tgtInputs = Array.from(document.querySelectorAll('.input.full-width > input'));
          // botões "Aposta XX BRL" — class .btn.bet
          const betBtns = Array.from(document.querySelectorAll('button.btn.bet'));
          if (stakeInputs.length < 2 || tgtInputs.length < 2 || betBtns.length < 2) {{
            return {{
              ok: false,
              msg: 'painéis nao encontrados (talvez Aposta esta desabilitada agora)',
              found: {{ stakes: stakeInputs.length, targets: tgtInputs.length, buttons: betBtns.length }},
            }};
          }}
          const stakeEl = stakeInputs[panelIdx];
          const tgtEl = tgtInputs[panelIdx];
          const betEl = betBtns[panelIdx];
          // se botão está com texto "Cancelar" ou "Sacar", aposta já está em curso → skip
          const btnTxt = (betEl.textContent || '').trim();
          if (btnTxt.toLowerCase().includes('cancelar') || btnTxt.toLowerCase().includes('sacar') || btnTxt.toLowerCase().includes('cashout')) {{
            return {{ ok: false, msg: 'aposta ja em curso (' + btnTxt + ')', skipped: true }};
          }}
          // FORÇA modo Aposta (manual). FILTRA pra pegar SÓ os switchers de painel
          // (cada um com tabs Aposta + Automático). O nav do topo (Apostas/Anterior/Topo)
          // também tem class .navigation-switcher mas não é o que queremos.
          const allSwitchers = Array.from(document.querySelectorAll('.navigation-switcher'));
          const panelSwitchers = allSwitchers.filter(sw => {{
            const tabs = sw.querySelectorAll('button.tab');
            for (const t of tabs) {{
              const tt = (t.textContent || '').trim().toLowerCase();
              if (tt.startsWith('autom')) return true;  // só esse switcher tem "Automático"
            }}
            return false;
          }});
          const sw = panelSwitchers[panelIdx];
          let switchedToManual = false;
          let modeAfter = 'unknown';
          if (sw) {{
            const tabs = sw.querySelectorAll('button.tab');
            let apostaTab = null;
            for (const t of tabs) {{
              const tt = (t.textContent || '').trim().toLowerCase();
              if (tt === 'aposta' || tt === 'manual') {{ apostaTab = t; break; }}
            }}
            if (apostaTab) {{
              if (!apostaTab.classList.contains('active')) {{
                apostaTab.click();
                switchedToManual = true;
              }}
              modeAfter = apostaTab.classList.contains('active') ? 'manual' : 'auto-still';
            }} else {{
              modeAfter = 'no-aposta-tab';
            }}
          }} else {{
            modeAfter = 'no-switcher (' + allSwitchers.length + ' switchers, ' + panelSwitchers.length + ' panels)';
          }}
          // ABORTA bet se ainda em Automático — nossa watcher faz o cashout, não a Spribe
          if (modeAfter !== 'manual') {{
            return {{
              ok: false,
              msg: 'panel ' + panelIdx + ' nao em modo manual (' + modeAfter + ') — pulando rodada pra evitar Automático antigo',
              skipped: true,
              mode_after: modeAfter,
            }};
          }}
          // setter nativo (bypass framework) + InputEvent rico pra Angular reactiveForms
          // pegar a mudança. Sem o inputType=insertReplacementText o FormControl mantém
          // o valor anterior (causa do "stake fantasma" R$9 quando set foi 3).
          const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
          function setInputValue(el, val) {{
            el.focus();
            // seleciona conteúdo atual antes de sobrescrever (evita concatenação)
            try {{ el.setSelectionRange(0, (el.value || '').length); }} catch (e) {{}}
            setter.call(el, val);
            // InputEvent fiel: Angular usa inputType+data pra triggerar valueAccessor
            try {{
              el.dispatchEvent(new InputEvent('input', {{
                bubbles: true, cancelable: true,
                inputType: 'insertReplacementText', data: val,
              }}));
            }} catch (e) {{
              el.dispatchEvent(new Event('input', {{ bubbles: true }}));
            }}
            el.dispatchEvent(new Event('change', {{ bubbles: true }}));
            el.blur();
          }}
          setInputValue(stakeEl, stake.toFixed(2));
          setInputValue(tgtEl, target.toFixed(2));
          // valida que Angular FormControl pegou — botão renderiza do FormControl.
          // Faz busy-wait com retry (até 600ms) e re-escreve agressivamente (sequência
          // de backspaces virtuais) se não bater no 1º teste.
          function readBtnStake() {{
            const t = (betEl.textContent || '').trim();
            const m = t.match(/(\\d+[.,]?\\d*)/);
            return {{ text: t, val: m ? parseFloat(m[1].replace(',', '.')) : null }};
          }}
          function reForceWrite(el, val) {{
            // Tentativa agressiva: simula Backspaces (KeyboardEvents) pra Angular invalidar
            // o FormControl atual, depois reescreve com InputEvent.
            el.focus();
            try {{ el.setSelectionRange(0, (el.value || '').length); }} catch (e) {{}}
            // dispatcha sequência de backspace
            for (let k = 0; k < Math.max(6, (el.value||'').length); k++) {{
              el.dispatchEvent(new KeyboardEvent('keydown', {{ key: 'Backspace', code: 'Backspace', bubbles: true }}));
              el.dispatchEvent(new InputEvent('input', {{ bubbles: true, inputType: 'deleteContentBackward', data: null }}));
              el.dispatchEvent(new KeyboardEvent('keyup', {{ key: 'Backspace', code: 'Backspace', bubbles: true }}));
            }}
            setter.call(el, '');
            el.dispatchEvent(new InputEvent('input', {{ bubbles: true, inputType: 'deleteContentBackward', data: null }}));
            // agora escreve novo valor
            setter.call(el, val);
            el.dispatchEvent(new InputEvent('input', {{ bubbles: true, inputType: 'insertReplacementText', data: val }}));
            el.dispatchEvent(new Event('change', {{ bubbles: true }}));
          }}
          // primeira validação imediata
          let chk = readBtnStake();
          let triesLog = [chk.val];
          // se não bateu, faz retries com busy-wait + re-write progressivamente agressivo
          if (chk.val !== null && Math.abs(chk.val - stake) > 0.01) {{
            // tenta até 3 vezes com sleep crescente
            for (let attempt = 1; attempt <= 3; attempt++) {{
              const t0 = Date.now();
              const wait = 50 * attempt;  // 50, 100, 150ms
              while (Date.now() - t0 < wait) {{ /* busy wait */ }}
              chk = readBtnStake();
              triesLog.push(chk.val);
              if (chk.val !== null && Math.abs(chk.val - stake) <= 0.01) break;
              // ainda errado → re-escreve agressivo (backspaces + setter)
              reForceWrite(stakeEl, stake.toFixed(2));
            }}
          }}
          if (chk.val !== null && Math.abs(chk.val - stake) > 0.01) {{
            return {{
              ok: false,
              skipped: true,
              msg: 'Angular FormControl nao atualizou apos ' + triesLog.length + ' tentativas: '
                   + 'stake_intent=' + stake.toFixed(2) + ' btn_text="' + chk.text + '" '
                   + 'tries=' + JSON.stringify(triesLog),
              btn_text: chk.text,
              stake_attempted: stake,
              stake_rendered: chk.val,
              mode_after: modeAfter,
            }};
          }}
          // anti-detecção: jitter aleatório antes do click final
          const delay = jMin + Math.random() * Math.max(0, jMax - jMin);
          setTimeout(() => {{ try {{ betEl.click(); }} catch (e) {{}} }}, delay);
          return {{
            ok: true,
            panel: panelIdx,
            stake: stake,
            target: target,
            btn_text: btnTxt,
            jitter_ms: Math.round(delay),
            switched_manual: switchedToManual,
            mode_after: modeAfter,
          }};
        }})()
        """
        res = self._extract_result(self.eval_js(js))
        # Fallback CDP: se a validação JS falhou por FormControl não atualizar
        # (Angular ignorou setter+InputEvent), tenta de novo com Input.insertText
        # que simula digitação real do teclado.
        if (not res.get('ok') and res.get('stake_rendered') is not None
                and res.get('mode_after') == 'manual'):
            return self._set_panel_via_cdp(panel_idx, stake, target, jitter_min_ms, jitter_max_ms)
        return res

    def _set_panel_via_cdp(self, panel_idx: int, stake: float, target: float,
                            jitter_min_ms: int, jitter_max_ms: int) -> dict:
        """Fallback usando Input.insertText. Chamado quando setter+InputEvent
        não atualizam o Angular FormControl."""
        import re as _re
        # 1. escreve stake via CDP keyboard
        sr = self.cdp_type_value(panel_idx, 'stake', f'{stake:.2f}')
        if not sr.get('ok'):
            return {'ok': False, 'skipped': True, 'mode_after': 'manual',
                    'msg': f'CDP stake erro: {sr.get("msg", "?")}'}
        m = _re.match(r'^\D*(\d+[.,]?\d*)', sr.get('btn_text') or '')
        rendered = float(m.group(1).replace(',', '.')) if m else None
        if rendered is None or abs(rendered - stake) > 0.01:
            return {'ok': False, 'skipped': True, 'mode_after': 'manual',
                    'btn_text': sr.get('btn_text'), 'stake_rendered': rendered, 'stake_attempted': stake,
                    'msg': f'CDP stake nao atualizou: btn_text="{sr.get("btn_text")}" rendered={rendered}'}
        # 2. escreve target via CDP
        tr = self.cdp_type_value(panel_idx, 'target', f'{target:.2f}')
        if not tr.get('ok'):
            return {'ok': False, 'skipped': True, 'mode_after': 'manual',
                    'msg': f'CDP target erro: {tr.get("msg", "?")}'}
        # 3. clica no botão Bet com jitter
        import random as _random
        delay = jitter_min_ms + _random.random() * max(0, jitter_max_ms - jitter_min_ms)
        click_js = f"""
        (function() {{
          const btns = Array.from(document.querySelectorAll('button.btn.bet'));
          const btn = btns[{panel_idx}];
          if (!btn) return false;
          setTimeout(() => {{ try {{ btn.click(); }} catch (e) {{}} }}, {int(delay)});
          return true;
        }})()
        """
        self.eval_js(click_js)
        return {'ok': True, 'panel': panel_idx, 'stake': stake, 'target': target,
                'btn_text': sr.get('btn_text'), 'jitter_ms': int(delay),
                'mode_after': 'manual', 'method': 'cdp_insertText'}

    def discover_aviator_selectors(self) -> dict:
        """Roda no main page + cada iframe procurando elementos que parecem ser do Aviator.
        Retorna dict {frame_url: {found: [...], counts: {...}}} pra ajudar a calibrar houses.json."""
        js = r"""
        (function() {
          const out = { url: location.href, candidates: [] };
          // procura inputs numéricos (stake) e botões com texto "Apostar"/"Bet"
          document.querySelectorAll('input').forEach(el => {
            const r = el.getBoundingClientRect();
            if (r.width < 20 || r.height < 10) return;
            out.candidates.push({
              tag: 'input',
              type: el.type,
              cls: el.className,
              parent: el.parentElement ? el.parentElement.className : '',
              value: el.value,
              placeholder: el.placeholder,
            });
          });
          document.querySelectorAll('button').forEach(el => {
            const txt = (el.textContent || '').trim().slice(0, 40);
            if (!txt) return;
            out.candidates.push({
              tag: 'button',
              text: txt,
              cls: el.className,
              parent: el.parentElement ? el.parentElement.className : '',
            });
          });
          return out;
        })()
        """
        results = []
        # main page
        results.append({'where': 'main', 'data': self._extract_result(self.eval_js(js))})
        # iframes
        try:
            frames_data = self.cdp_call('Page.getFrameTree')
            frames = []
            def walk(f):
                frames.append(f.get('frame', {}))
                for c in f.get('childFrames', []):
                    walk(c)
            walk(frames_data.get('result', {}).get('frameTree', {}))
            for f in frames[1:]:  # pula main (já fizemos)
                fid = f.get('id')
                if not fid:
                    continue
                try:
                    ctx_resp = self.cdp_call('Page.createIsolatedWorld',
                        {'frameId': fid, 'worldName': 'irondome', 'grantUniveralAccess': True})
                    ctx_id = ctx_resp.get('result', {}).get('executionContextId')
                    if ctx_id:
                        results.append({
                            'where': f.get('url', '?'),
                            'data': self._extract_result(
                                self.cdp_call('Runtime.evaluate',
                                    {'expression': js, 'contextId': ctx_id, 'returnByValue': True})
                            ),
                        })
                except Exception as e:
                    results.append({'where': f.get('url', '?'), 'data': {'err': str(e)}})
        except Exception as e:
            results.append({'where': '<frames err>', 'data': {'err': str(e)}})
        return {'frames': results}


# ─── Strategy state machine (mirror do simulador da cloud) ─────────────────
class StrategyEngine:
    def __init__(self, schedule: list, unit: float):
        self.schedule = schedule
        self.unit = float(unit)
        self.round_idx = 0
        self.cycles_completed = 0
        self.cycles_lost = 0
        self.daily_pnl = 0.0
        self.skipped = 0
        # bet_pending: True quando o bot APOSTOU pra próxima rodada e está esperando o
        # crash daquela rodada pra contabilizar P&L. False = sem aposta em curso.
        self.bet_pending = False
        self.pending_round_idx = 0  # round que tinha quando o bet foi colocado

    def current_panels(self) -> tuple[float, float, float, float]:
        idx = min(self.round_idx, len(self.schedule) - 1)
        r = self.schedule[idx]
        return (
            r['b1_stake'] * self.unit, r['b1_target'],
            r['b2_stake'] * self.unit, r['b2_target'],
        )

    def mark_bet_placed(self):
        """Chamado APÓS o bot clicar em Aposta no Brave. Trava bet_pending=True."""
        self.bet_pending = True
        self.pending_round_idx = self.round_idx

    def process_crash(self, value: float, p0_cashed_at: float | None = None, p1_cashed_at: float | None = None):
        """Processa o crash que resolveu o bet pendente. Atualiza P&L e avança round.
        Se NÃO há bet pendente, retorna 'no_bet' sem mexer em nada.

        p0_cashed_at / p1_cashed_at: multiplicador em que o watcher disparou o cashout
        (None se watcher não disparou — assume perda).
        Se ambos None, cai no fallback antigo (decide pelo crash value)."""
        if not self.bet_pending:
            return 'no_bet'
        idx = min(self.pending_round_idx, len(self.schedule) - 1)
        r = self.schedule[idx]
        b1_stake = r['b1_stake'] * self.unit
        b2_stake = r['b2_stake'] * self.unit
        t_b1 = r['b1_target']
        t_b2 = r['b2_target']
        total = b1_stake + b2_stake
        self.bet_pending = False

        # Modo NOVO: usa watcher como fonte de verdade.
        # Cashout só "vale" se o watcher disparou E o crash foi >= mult do cashout
        # (sanidade — não pode ter cashado em 10× num round que crashou em 5×).
        if p0_cashed_at is not None or p1_cashed_at is not None:
            p0_won = p0_cashed_at is not None and p0_cashed_at <= value + 0.1  # tolerância
            p1_won = p1_cashed_at is not None and p1_cashed_at <= value + 0.1
            payout = 0.0
            if p0_won:
                payout += b1_stake * p0_cashed_at
            if p1_won:
                payout += b2_stake * p1_cashed_at
            self.daily_pnl += payout - total
            if p0_won and p1_won:
                self.cycles_completed += 1
                self.round_idx = 0
                return 'reset'
            self.round_idx = idx + 1
            if self.round_idx >= len(self.schedule):
                self.cycles_lost += 1
                self.round_idx = 0
                return 'cycle_end'
            return 'advance'

        # Modo legado (sem watcher): decide pelo crash value
        if value >= t_b2:
            payout = b1_stake * t_b1 + b2_stake * t_b2
            self.daily_pnl += payout - total
            self.cycles_completed += 1
            self.round_idx = 0
            return 'reset'
        elif value >= t_b1:
            payout = b1_stake * t_b1
            self.daily_pnl += payout - total
            self.round_idx = idx + 1
        else:
            self.daily_pnl -= total
            self.round_idx = idx + 1
        if self.round_idx >= len(self.schedule):
            self.cycles_lost += 1
            self.round_idx = 0
            return 'cycle_end'
        return 'advance'

    # alias retrocompat (engine antiga chamava on_crash diretamente)
    def on_crash(self, value: float):
        return self.process_crash(value)


# ─── Main loop ─────────────────────────────────────────────────────────────
def run_main_loop(cfg: dict, stop_flag: threading.Event | None = None):
    """Versão chamável a partir do GUI ou CLI. Bloqueia até stop_flag.set()."""
    if stop_flag is None:
        stop_flag = threading.Event()
    print(f'IronDome Cloud Operator v{VERSION}')
    print('-' * 60)
    api_key = cfg['api_key']
    cloud_url = cfg.get('cloud_url', DEFAULT_CLOUD_URL)

    # houses.json: ao lado do .py ou no MEIPASS do PyInstaller
    if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
        houses_path = os.path.join(sys._MEIPASS, 'houses.json')
    else:
        houses_path = str(HOUSES_PATH)
    houses = json.loads(open(houses_path).read())

    print(f'\ncloud: {cloud_url}')
    print(f'api_key: {api_key[:10]}...{api_key[-6:]}')
    print()

    # Pega state inicial
    try:
        state = http_json('GET', f'{cloud_url}/api/v1/operator/state', api_key)
    except urllib.error.HTTPError as e:
        if e.code == 403:
            print('ERRO: seu tenant não tem auto_click_enabled. Contate o administrador.')
        elif e.code == 401:
            print('ERRO: api_key inválida.')
        sys.exit(1)
    print(f'state inicial:')
    print(f'  enabled: {state.get("enabled")}')
    sl = state.get('stoploss_brl')
    sg = state.get('stopgain_brl')
    print(f'  strategy: {state.get("strategy")}  unit=R${state.get("unit_brl")}  stoploss=R${sl or 0} (0=off)  stopgain=R${sg or 0} (0=off)')
    print(f'  house: {state.get("house")}')

    house_cfg = houses.get(state.get('house', '7K'))
    if not house_cfg:
        print(f'ERRO: casa "{state.get("house")}" não está em houses.json')
        sys.exit(1)

    # Inicializa Brave
    brave = BraveController(house_cfg['url'])
    if not brave.ensure_brave():
        sys.exit(1)
    if not brave.connect_target():
        print('  Tente abrir o Aviator manualmente no Brave que acabou de abrir, e re-rode o operator.')
        sys.exit(1)

    # Pega catálogo de estratégias
    strategies = http_json('GET', f'{cloud_url}/api/v1/strategies', api_key)['strategies']
    strats_by_code = {s['code']: s for s in strategies}

    # Engine
    engine = None

    def state_poll_loop():
        nonlocal engine
        last_paused = None
        while not stop_flag.is_set():
            try:
                st = http_json('GET', f'{cloud_url}/api/v1/operator/state', api_key)
                # detecta transição "pausado por cap → destravado": zera engine em memória
                # pra evitar loop (heartbeat reenviar pnl negativo e re-disparar cap)
                new_paused = st.get('paused_reason')
                if last_paused == 'cap_diario' and new_paused is None and engine is not None:
                    engine.daily_pnl = 0.0
                    engine.round_idx = 0
                    engine.cycles_completed = 0
                    engine.cycles_lost = 0
                    engine.skipped = 0
                    print('\n[engine] cap destravado pela cloud → reset local (P&L=0, round=1)')
                last_paused = new_paused
                # detecta nova sessão pelo dashboard (POST /operator/reset zerou
                # tudo no banco). Se há bet pendente, agenda o reset pra depois
                # do próximo crash; senão zera direto.
                cloud_round = int(st.get('current_round_idx', 0) or 0)
                cloud_pnl = float(st.get('daily_pnl_brl', 0) or 0)
                if (engine is not None and cloud_round == 0 and abs(cloud_pnl) < 0.01
                        and (engine.round_idx > 0 or abs(engine.daily_pnl) > 0.01
                             or engine.cycles_completed > 0 or engine.cycles_lost > 0)
                        and not state.get('_session_reset_pending')):
                    if engine.bet_pending:
                        state['_session_reset_pending'] = True
                        print('\n[engine] nova sessão solicitada — aguardando rodada atual resolver...')
                    else:
                        engine.daily_pnl = 0.0
                        engine.round_idx = 0
                        engine.cycles_completed = 0
                        engine.cycles_lost = 0
                        engine.skipped = 0
                        print('\n[engine] nova sessão iniciada pelo dashboard → reset local')
                # se mudou estratégia ou unit, recria engine
                code = st.get('strategy')
                unit = float(st.get('unit_brl', 1.0))
                if engine is None or engine.unit != unit or len(engine.schedule) != len(strats_by_code[code]['schedule']):
                    print(f'\n[engine] rebuild: {code} unit=R${unit}')
                    engine = StrategyEngine(strats_by_code[code]['schedule'], unit)
                # atualiza estado local (incluindo jitter)
                state.update(st)
                state['_jitter_min'] = int(st.get('jitter_min_ms', 400) or 400)
                state['_jitter_max'] = int(st.get('jitter_max_ms', 1200) or 1200)
            except Exception as e:
                print(f'[poll] {e}')
            stop_flag.wait(STATE_POLL_INTERVAL)

    # ─── Estado da aba Configuração (Fase 3) ────────────────────────────
    # Vars locais; resetam quando engine for recriado ou no auto-reset diário.
    cfg_state = {
        'daily_bets_today': 0,         # cap diário
        'misses_consec': 0,            # cooldown
        'cooldown_active': False,
        'cooldown_until_ts': 0.0,      # modo timer
        'cooldown_shadow_seen': 0,     # modo shadow
        'cooldown_shadow_hits': 0,
        'observe_seen': 0,             # contador modo OBSERVE→BET (rounds)
        'zgap_streak': 0,              # modo zgap_streak
        'last_reset_date': None,       # auto-reset diário (date SP)
        'history_recent': [],          # buffer pra detector cauda
        'last_hit_idx': -1,            # índice no buffer do último ≥10×
    }

    def _now_sp():
        """Datetime atual no fuso LOCAL da máquina onde o operator roda.
        Usuário no Brasil → SP. Usuário na Califórnia → LA. Permite cada
        cliente configurar a janela horária no relógio que vê todo dia.
        Nome mantido por compat (legado)."""
        from datetime import datetime
        return datetime.now()

    def _in_op_window(st: dict) -> bool:
        """Operator pode rodar agora? Checa hora + dia da semana."""
        ws = st.get('op_window_start', '00:00') or '00:00'
        we = st.get('op_window_end', '23:59') or '23:59'
        days = st.get('op_days', '1234567') or '1234567'
        now = _now_sp()
        # ISO weekday: seg=1, ..., dom=7
        if str(now.isoweekday()) not in days:
            return False
        hm = now.strftime('%H:%M')
        # janela normal: ws < we (ex: 08:00-23:59). janela atravessando meia-noite: ws > we (ex: 22:00-04:00)
        if ws <= we:
            return ws <= hm <= we
        return hm >= ws or hm <= we

    def _cooldown_active(st: dict) -> tuple[bool, str]:
        """True se em cooldown agora. Avança contador shadow conforme rodadas chegam."""
        if not cfg_state['cooldown_active']:
            return False, ''
        mode = st.get('cooldown_mode', 'shadow')
        if mode == 'timer':
            if time.time() >= cfg_state['cooldown_until_ts']:
                cfg_state['cooldown_active'] = False
                return False, 'timer expirou'
            remaining = int(cfg_state['cooldown_until_ts'] - time.time())
            return True, f'timer {remaining}s restantes'
        # modo shadow: avalia depois de M sinais simulados
        m = int(st.get('cooldown_value', 8) or 8)
        k = int(st.get('cooldown_k_min', 4) or 4)
        if cfg_state['cooldown_shadow_seen'] >= m:
            if cfg_state['cooldown_shadow_hits'] >= k:
                cfg_state['cooldown_active'] = False
                return False, f'shadow {cfg_state["cooldown_shadow_hits"]}/{m} ≥ {k} → retoma'
            # falhou, reinicia janela
            cfg_state['cooldown_shadow_seen'] = 0
            cfg_state['cooldown_shadow_hits'] = 0
            return True, f'shadow reiniciado (não atingiu {k})'
        return True, f'shadow {cfg_state["cooldown_shadow_seen"]}/{m}'

    def _trigger_cooldown(st: dict):
        """Ativa cooldown após N misses consecutivos."""
        cfg_state['cooldown_active'] = True
        mode = st.get('cooldown_mode', 'shadow')
        v = int(st.get('cooldown_value', 10) or 10)
        if mode == 'timer':
            cfg_state['cooldown_until_ts'] = time.time() + v * 60  # minutos
            print(f'[engine] cooldown TIMER {v}min ativado ({cfg_state["misses_consec"]} misses consecutivos)')
        else:
            cfg_state['cooldown_shadow_seen'] = 0
            cfg_state['cooldown_shadow_hits'] = 0
            k = int(st.get('cooldown_k_min', 4) or 4)
            print(f'[engine] cooldown SHADOW ativado ({cfg_state["misses_consec"]} misses; precisa de ≥{k} hits em {v} sinais)')

    def _observe_gate(st: dict, value: float) -> tuple[bool, str]:
        """Retorna (pode_apostar, motivo)."""
        mode = st.get('observe_mode', 'off') or 'off'
        if mode == 'off':
            return True, ''
        n = int(st.get('observe_n_rounds', 8) or 8)
        if mode == 'rounds':
            cfg_state['observe_seen'] += 1
            if cfg_state['observe_seen'] < n:
                return False, f'OBSERVE rounds {cfg_state["observe_seen"]}/{n}'
            return True, f'OBSERVE rounds {n} ✓'
        if mode == 'tail':
            # libera se houve um ≥10× nas últimas tail_window_n rodadas
            tw = int(st.get('tail_window_n', 5) or 5)
            recent = cfg_state['history_recent'][-tw:]
            if any(v >= 10 for v in recent):
                return True, f'OBSERVE tail (10× em últimas {tw}) ✓'
            return False, f'OBSERVE tail aguardando ≥10× nas últimas {tw}'
        if mode == 'zgap_streak':
            need = n
            if cfg_state['zgap_streak'] >= need:
                return True, f'OBSERVE zgap_streak {cfg_state["zgap_streak"]} ≥ {need} ✓'
            return False, f'OBSERVE zgap_streak {cfg_state["zgap_streak"]}/{need}'
        return True, ''

    # Heartbeat thread (também atualiza zgap_color em state)
    def heartbeat_loop():
        while not stop_flag.is_set():
            try:
                if engine is not None:
                    body = {
                        'current_round_idx': engine.round_idx,
                        'cycles_completed': engine.cycles_completed,
                        'cycles_lost': engine.cycles_lost,
                        'daily_pnl_brl': round(engine.daily_pnl, 2),
                        'pnl_total_brl': round(engine.daily_pnl, 2),  # simplificado
                        'cap_diario_count': cfg_state['daily_bets_today'],
                    }
                    resp = http_json('POST', f'{cloud_url}/api/v1/operator/heartbeat', api_key, body)
                    # guarda zgap pra handle_crash usar
                    zgap = resp.get('zgap', {})
                    state['_zgap_color'] = zgap.get('color', 'off')
                    state['_zgap_z'] = zgap.get('z')
            except Exception as e:
                print(f'[heart] {e}')
            stop_flag.wait(HEARTBEAT_INTERVAL)

    def auto_reset_daily_loop():
        """Thread que zera engine + cap_diario_count à meia-noite SP, se ativado."""
        while not stop_flag.is_set():
            try:
                if state.get('auto_reset_daily') and engine is not None:
                    today = _now_sp().date()
                    if cfg_state['last_reset_date'] is None:
                        cfg_state['last_reset_date'] = today
                    elif today != cfg_state['last_reset_date']:
                        engine.daily_pnl = 0.0
                        engine.round_idx = 0
                        engine.cycles_completed = 0
                        engine.cycles_lost = 0
                        engine.skipped = 0
                        cfg_state['daily_bets_today'] = 0
                        cfg_state['misses_consec'] = 0
                        cfg_state['cooldown_active'] = False
                        cfg_state['observe_seen'] = 0
                        cfg_state['zgap_streak'] = 0
                        cfg_state['last_reset_date'] = today
                        print(f'\n[engine] auto-reset diário aplicado ({today}) — P&L, ciclos, cap zerados')
            except Exception as e:
                print(f'[auto-reset] {e}')
            stop_flag.wait(60)  # checa a cada minuto

    # SSE crash subscription
    def sse_loop():
        nonlocal engine
        while not stop_flag.is_set():
            try:
                req = urllib.request.Request(
                    f'{cloud_url}/api/v1/stream?api_key={api_key}',
                    headers={'Accept': 'text/event-stream'})
                with urllib.request.urlopen(req, timeout=60) as r:
                    cur_event = None
                    for line in r:
                        if stop_flag.is_set():
                            break
                        line = line.decode().rstrip('\n')
                        if line.startswith('event: '):
                            cur_event = line[7:].strip()
                        elif line.startswith('data: '):
                            payload = line[6:]
                            if cur_event == 'crash':
                                try:
                                    data = json.loads(payload)
                                    handle_crash(float(data.get('value', 0)))
                                except Exception as e:
                                    print(f'[sse parse] {e}')
                            cur_event = None
            except Exception as e:
                print(f'[sse] reconectando em 5s ({e})')
                time.sleep(5)

    def handle_crash(value: float):
        if engine is None or not state.get('enabled') or state.get('paused_reason'):
            return
        ts = time.strftime('%H:%M:%S')
        # Buffer histórico recente (pra detector cauda + shadow cooldown)
        cfg_state['history_recent'].append(value)
        if len(cfg_state['history_recent']) > 30:
            cfg_state['history_recent'].pop(0)
        # Atualiza counters de observe se ainda em modo OBSERVE
        # (o gate avalia detalhe abaixo)
        # Atualiza zgap streak (depende de zgap_color atual no state)
        if state.get('_zgap_color') == 'green':
            cfg_state['zgap_streak'] += 1
        else:
            cfg_state['zgap_streak'] = 0
        # 1) RESOLVE bet pendente (independente do z-gap atual — o bet já foi colocado).
        if engine.bet_pending:
            # Lê watcher: mult em que CLICOU (None se não clicou). Sem clique = miss real,
            # mesmo que crash >= target (Spribe não registra cashout se o b.click() falhou).
            p0_cashed = p1_cashed = None
            try:
                s0 = brave.peek_cashout_state(0)
                s1 = brave.peek_cashout_state(1)
                r0 = (s0 or {}).get('result') or {}
                r1 = (s1 or {}).get('result') or {}
                if r0.get('clicked'):
                    p0_cashed = float(r0.get('mult'))
                    print(f'    [P0 cashout] clicked @ {p0_cashed:.2f}× (target={r0.get("target")})')
                elif r0.get('timeout'):
                    d = r0.get('debug') or {}
                    print(f'    [P0 cashout] TIMEOUT — last_mult={d.get("last_mult")} ticks={d.get("ticks")}')
                if r1.get('clicked'):
                    p1_cashed = float(r1.get('mult'))
                    print(f'    [P1 cashout] clicked @ {p1_cashed:.2f}× (target={r1.get("target")})')
                elif r1.get('timeout'):
                    d = r1.get('debug') or {}
                    print(f'    [P1 cashout] TIMEOUT — last_mult={d.get("last_mult")} ticks={d.get("ticks")}')
            except Exception as e:
                print(f'    [cashout peek err] {e}')
            outcome = engine.process_crash(value, p0_cashed, p1_cashed)
            print(f'[{ts}] crash {value:>6.2f}x → {outcome:<10} round={engine.round_idx+1} pnl=R${engine.daily_pnl:+.2f}  [RESOLVED]')
            # Atualiza misses_consec p/ cooldown
            if outcome == 'reset':
                cfg_state['misses_consec'] = 0  # ciclo fechado com sucesso
            elif outcome == 'cycle_end':
                cfg_state['misses_consec'] += 1
                threshold = int(state.get('cooldown_n_misses', 0) or 0)
                if threshold > 0 and cfg_state['misses_consec'] >= threshold and not cfg_state['cooldown_active']:
                    _trigger_cooldown(state)
            # bet pendente resolveu → se reset estava agendado, aplica agora
            if state.get('_session_reset_pending'):
                engine.daily_pnl = 0.0
                engine.round_idx = 0
                engine.cycles_completed = 0
                engine.cycles_lost = 0
                engine.skipped = 0
                state['_session_reset_pending'] = False
                print(f'[{ts}] [engine] nova sessão aplicada após resolução da rodada anterior → reset local')
                return  # não coloca bet nessa rodada — primeira da nova sessão começa na próxima
            if outcome == 'cycle_end':
                # ciclo perdido, schedule reiniciou. Aguarda próxima rodada pra recolocar bet.
                return

        # 2) GATES da aba Configuração antes de apostar.

        # 2.0) Cooldown ativo? (modo shadow simula a rodada atual)
        active, reason = _cooldown_active(state)
        if active:
            # se a próxima rodada SE TIVESSE apostado teria sido hit (≥ b2_target ou b1_target),
            # marca como acerto shadow. Verifica via current_panels.
            if state.get('cooldown_mode') == 'shadow':
                _, t_b1, _, t_b2 = engine.current_panels()
                cfg_state['cooldown_shadow_seen'] += 1
                if value >= min(t_b1, t_b2):  # qualquer painel acertaria
                    cfg_state['cooldown_shadow_hits'] += 1
            print(f'[{ts}]   cooldown ativo ({reason}) → não aposta')
            return

        # 2.1) Janela horária + dias da semana
        if not _in_op_window(state):
            now_str = _now_sp().strftime('%a %H:%M')
            print(f'[{ts}]   fora da janela ({now_str}; janela={state.get("op_window_start")}-{state.get("op_window_end")} dias={state.get("op_days")}) → não aposta')
            return

        # 2.2) Cap diário atingido? Desliga enabled silencioso.
        cap_limit = int(state.get('cap_diario_limit', 0) or 0)
        if cap_limit > 0 and cfg_state['daily_bets_today'] >= cap_limit:
            print(f'[{ts}]   cap diário atingido ({cfg_state["daily_bets_today"]}/{cap_limit}) → desliga autoclick')
            try:
                http_json('POST', f'{cloud_url}/api/v1/operator/state', api_key, {'enabled': False})
                state['enabled'] = False
            except Exception as e:
                print(f'[{ts}]   falha desligando: {e}')
            return

        # 2.3) z-gap (precisa estar VERDE)
        try:
            zgap_fresh = http_json('GET', f'{cloud_url}/api/v1/zgap/state', api_key)
            zgap_color = zgap_fresh.get('color', 'off')
            state['_zgap_color'] = zgap_color
        except Exception:
            zgap_color = state.get('_zgap_color', 'off')
        if zgap_color != 'green':
            engine.skipped = getattr(engine, 'skipped', 0) + 1
            print(f'[{ts}]   z-gap={zgap_color} → não aposta (skipped={engine.skipped})')
            return

        # 2.4 + 2.5) Sinais de gating em OR.
        # Cada gate ativo é um GATILHO independente — basta UM liberar pra apostar.
        # Se ambos desligados, não há gate (passa direto).
        ok_obs, reason_obs = _observe_gate(state, value)
        obs_active = state.get('observe_mode', 'off') != 'off'

        tail_active = bool(state.get('tail_enabled'))
        tail_ok = False
        tail_reason = ''
        if tail_active:
            tw = int(state.get('tail_window_n', 5) or 5)
            recent = cfg_state['history_recent'][-tw:]
            tail_ok = any(v >= 10 for v in recent)
            tail_reason = f'cauda inativa (sem ≥10× nas últimas {tw})'

        if obs_active and tail_active:
            # OR: basta um liberar
            if not (ok_obs or tail_ok):
                print(f'[{ts}]   {reason_obs} E {tail_reason} → não aposta (OR)')
                return
            elif tail_ok and not ok_obs:
                print(f'[{ts}]   gatilho cauda ativo ✓ (observe ainda em {reason_obs})')
            elif ok_obs and not tail_ok:
                print(f'[{ts}]   gatilho {reason_obs.lower()} ✓ ({tail_reason})')
            else:
                print(f'[{ts}]   ambos gatilhos ativos ✓')
        elif obs_active:
            if not ok_obs:
                print(f'[{ts}]   {reason_obs} → não aposta')
                return
        elif tail_active:
            if not tail_ok:
                print(f'[{ts}]   {tail_reason} → não aposta')
                return

        # z-gap verde + ao menos um gate liberou → coloca aposta pra próxima rodada
        b1_st, b1_tg, b2_st, b2_tg = engine.current_panels()
        jmin = int(state.get('_jitter_min', 400))
        jmax = int(state.get('_jitter_max', 1200))
        try:
            r1 = brave.set_panel(0, b1_st, b1_tg, house_cfg, jmin, jmax)
            r2 = brave.set_panel(1, b2_st, b2_tg, house_cfg, jmin, jmax)
            m1 = r1.get('mode_after', '?')
            m2 = r2.get('mode_after', '?')
            mtag1 = (' [cdp]' if r1.get('method') == 'cdp_insertText' else '')
            mtag2 = (' [cdp]' if r2.get('method') == 'cdp_insertText' else '')
            print(f'  → P0 R${b1_st:.2f}@{b1_tg}× ({r1.get("ok")} mode={m1} jitter={r1.get("jitter_ms")}ms{mtag1}) | P1 R${b2_st:.2f}@{b2_tg}× ({r2.get("ok")} mode={m2} jitter={r2.get("jitter_ms")}ms{mtag2})')
            if not r1.get('ok'):
                print(f'    ⚠️ P0: {r1.get("msg", "?")}')
            if not r2.get('ok'):
                print(f'    ⚠️ P1: {r2.get("msg", "?")}')
            if m1 == 'auto-still' or m2 == 'auto-still':
                print(f'    ⚠️ algum painel ficou em Automático — Spribe pode sacar em valor antigo. Próxima rodada tenta de novo.')
            # Marca bet_pending APENAS se ambos painéis foram colocados com sucesso
            if r1.get('ok') and r2.get('ok'):
                engine.mark_bet_placed()
                cfg_state['daily_bets_today'] += 1
                cfg_state['observe_seen'] = 0  # reset contador OBSERVE
                # Arma cashout SEM jitter (precisa ser instantâneo pra sair no target)
                a1 = brave.arm_cashout(0, b1_tg, b1_st, 0, 0)
                a2 = brave.arm_cashout(1, b2_tg, b2_st, 0, 0)
                print(f'    armed P0@{b1_tg}× | P1@{b2_tg}× (sem jitter no saque)')
            else:
                print(f'    bet NÃO marcado como pending (algum painel falhou)')
        except Exception as e:
            print(f'  → click failed: {e}')

    # Sobe threads
    threading.Thread(target=state_poll_loop, daemon=True).start()
    threading.Thread(target=heartbeat_loop, daemon=True).start()
    threading.Thread(target=sse_loop, daemon=True).start()
    threading.Thread(target=auto_reset_daily_loop, daemon=True).start()

    print('\noperator rodando.')
    while not stop_flag.is_set():
        time.sleep(1)
    print('encerrando…')


def main():
    """CLI entry point."""
    cfg = load_config()
    if not cfg.get('api_key'):
        cfg = first_run_setup()
    stop = threading.Event()
    try:
        run_main_loop(cfg, stop)
    except KeyboardInterrupt:
        stop.set()


if __name__ == '__main__':
    main()
