#!/usr/bin/env python3
"""
Mavis Supervisor v1 — Monitora e riavvia i processi Mavis (live_deploy).
Versione: gestisce i 3 processi Mavis dichiarati da Mattia:
  1. WEBHOOK   (porta 5580, webhook_receiver.py)
  2. DASHBOARD (porta 5503, stats_dashboard.py)
  3. MONITOR   (processo python rettangolo_monitor.py, rigenera monitor.html)
  + TUNNEL CHECK: verifica che l'URL trycloudflare attuale risponda. Se no,
    killa cloudflared e riavvia (un nuovo URL sara' generato e scritto in
    current_tunnel.txt da tunnel_watchdog.py).

NON tocca i 4 processi Charter (live_loop_aligned, web_solver_v6,
dashboard_app, market_dashboard) che sono in STANDBY.

Logica:
  - check ogni 30s
  - se processo morto: aspetta 60s di cooldown (anti-loop), poi riavvia
  - log minimale in webhook_listener/logs/supervisor.log + stdout
  - ctrl-c per uscire pulito

Setup env automatico (all'avvio del supervisor):
  legge G:\\AI TRADING ENGINE\\API_KEY_BYBIT.env e mappa:
    BYBIT_DEMO_API_KEY    -> BYBIT_API_KEY
    BYBIT_DEMO_SECRET_KEY -> BYBIT_API_SECRET
  setta WEBHOOK_SECRET = "TV_2026_MATTIA_DEMO"
  Cosi' i subprocess python ereditano le env giuste.

Riavvio subprocess:
  subprocess.Popen([python, -u, script.py], cwd=ROOT, env=os.environ,
                   creationflags=DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW, close_fds=False)
  DETACHED_PROCESS = il figlio sopravvive se il supervisore muore
  close_fds=False   = passa correttamente stdin/stdout/stderr (CRUCIALE)

Lanciare in detached mode con:
  powershell -Command "Start-Process python -ArgumentList mavis_supervisor_3.py -WorkingDirectory . -WindowStyle Hidden"
"""
import os
import re
import sys
import time
import urllib.request
import socket
import subprocess
from datetime import datetime
from pathlib import Path

try:
    import psutil
    HAS_PSUTIL = True
except ImportError:
    HAS_PSUTIL = False
    print("[WARN] psutil non disponibile, fallback su PowerShell (lento + lampeggia)")

ROOT = Path(r"G:\AI TRADING ENGINE\live_deploy")
LOG_FILE = ROOT / "webhook_listener" / "logs" / "supervisor.log"
ENV_FILE = Path(r"G:\AI TRADING ENGINE\API_KEY_BYBIT.env")
TUNNEL_URL_FILE = ROOT / "webhook_listener" / "current_tunnel.txt"

CHECK_INTERVAL = 30
RESTART_COOLDOWN = 60
TUNNEL_CHECK_COOLDOWN = 120  # tunnel check piu' rado per evitare rumore
STATS_SYNC_COOLDOWN = 300   # sync chiusure Bybit -> trades.csv ogni 5 min
WEBHOOK_SECRET_DEFAULT = "TV_2026_MATTIA_DEMO"
DETACHED_PROCESS = 0x00000008
CREATE_NO_WINDOW = 0x08000000
# TUTTE le subprocess del supervisore devono avere questi flag, altrimenti
# ogni check (powershellow, tailscale) apre una console lampeggiante che
# ruba il focus della tastiera a Mattia. (fix 2026-07-16)
SUBPROC_FLAGS = DETACHED_PROCESS | CREATE_NO_WINDOW
TAILSCALE_EXE = Path(r"C:\Program Files\Tailscale\tailscale.exe")
TAILSCALE_FUNNEL_PORT = 5580

# Counter log falliti (reset su successo). Usato da log() per escalation.
LOG_FAIL_COUNT = 0


def setup_env() -> None:
    """Legge API_KEY_BYBIT.env e setta le env per i subprocess."""
    try:
        for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
            if "=" not in line:
                continue
            k, _, v = line.partition("=")
            k = k.strip()
            v = v.strip()
            if k == "BYBIT_DEMO_API_KEY":
                os.environ["BYBIT_API_KEY"] = v
            elif k == "BYBIT_DEMO_SECRET_KEY":
                os.environ["BYBIT_API_SECRET"] = v
    except Exception as e:
        log(f"[warn] setup_env: lettura {ENV_FILE} fallita: {e}")
    os.environ["WEBHOOK_SECRET"] = WEBHOOK_SECRET_DEFAULT


def log(msg: str) -> None:
    """Scrive una riga di log su stdout (best-effort) e sul file (obbligatorio).

    Se la scrittura su file fallisce, scala escalation: stderr + file .fallback.log
    + counter globale LOG_FAIL_COUNT. Al primo successo dopo N fallimenti, scrive
    una riga [log-recovery] per diagnosi.
    """
    global LOG_FAIL_COUNT
    ts = datetime.now().isoformat(timespec="seconds")
    line = f"[{ts}] {msg}"

    # stdout best-effort (puo' fallire su pythonw, OK)
    try:
        print(line, flush=True)
    except Exception:
        pass

    # file log: OBBLIGATORIO
    try:
        LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
        with open(LOG_FILE, "a", encoding="utf-8") as f:
            if LOG_FAIL_COUNT > 0:
                f.write(f"[{ts}] [log-recovery] {LOG_FAIL_COUNT} log falliti prima di questo\n")
            f.write(line + "\n")
        LOG_FAIL_COUNT = 0
    except Exception as e:
        # escalation: stderr + file fallback
        LOG_FAIL_COUNT += 1
        try:
            print(f"[{ts}] [log-fail #{LOG_FAIL_COUNT}] {e} | original: {line}", file=sys.stderr, flush=True)
        except Exception:
            pass
        try:
            fallback = LOG_FILE.with_suffix(".fallback.log")
            with open(fallback, "a", encoding="utf-8") as f2:
                f2.write(f"[{ts}] [LOG-FAIL] {e} | original: {line}\n")
        except Exception:
            pass


def port_in_use(port: int) -> bool:
    """True se la porta e' in ascolto su 127.0.0.1."""
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.settimeout(2)
            return s.connect_ex(("127.0.0.1", port)) == 0
    except Exception:
        return False


def _find_pids(pattern: str) -> list[int]:
    """Restituisce la lista di PID python/pythonw il cui commandline contiene `pattern`.
    Usa psutil (veloce, niente subprocess PowerShell). Se psutil non c'e', fallback su
    PowerShell con flag corretti (ma piu' lento)."""
    if HAS_PSUTIL:
        result = []
        try:
            for p in psutil.process_iter(['pid', 'name', 'cmdline']):
                name = (p.info.get('name') or '').lower()
                if 'python' not in name:
                    continue
                cmd = p.info.get('cmdline') or []
                cmdline = ' '.join(cmd)
                if pattern in cmdline:
                    result.append(p.info['pid'])
        except Exception as e:
            log(f"  [warn] _find_pids({pattern!r}) psutil fallita: {e}")
        return result
    # Fallback PowerShell (raro, solo se psutil manca)
    try:
        out = subprocess.check_output(
            ["powershell", "-NoProfile", "-Command",
             f"@(Get-CimInstance Win32_Process -Filter \"Name = 'python.exe' OR Name = 'pythonw.exe'\" "
             f"| Where-Object {{ $_.CommandLine -match '{pattern}' }} "
             f"| Select-Object -ExpandProperty ProcessId)"],
            text=True, timeout=10,
            creationflags=SUBPROC_FLAGS
        )
        return [int(x) for x in out.split() if x.strip().isdigit()]
    except Exception as e:
        log(f"  [warn] _find_pids({pattern!r}) powershell fallback fallita: {e}")
        return []


def process_running(pattern: str) -> bool:
    """True se esiste almeno un processo python/pythonw il cui commandline contiene `pattern`."""
    return len(_find_pids(pattern)) > 0


def count_processes(pattern: str) -> int:
    """Ritorna il numero di processi python/pythonw il cui commandline contiene `pattern`.
    Usato per rilevare proliferazione (es. 6 monitor attivi contemporaneamente)."""
    return len(_find_pids(pattern))


def kill_all_processes(pattern: str) -> int:
    """Killa TUTTI i processi python/pythonw il cui commandline contiene `pattern`.
    Ritorna il numero di processi killati. Usato per cleanup proliferazione.
    Usa psutil (veloce, niente subprocess PowerShell)."""
    pids = _find_pids(pattern)
    killed = 0
    for pid in pids:
        try:
            if HAS_PSUTIL:
                p = psutil.Process(pid)
                p.terminate()
                try:
                    p.wait(timeout=3)
                except psutil.TimeoutExpired:
                    p.kill()
                    p.wait(timeout=2)
            else:
                subprocess.run(["taskkill", "/F", "/PID", str(pid)],
                               capture_output=True, timeout=5,
                               creationflags=SUBPROC_FLAGS)
            killed += 1
        except Exception as e:
            log(f"  [warn] kill pid={pid} fallita: {e}")
    return killed


def run_detached(script_name: str) -> None:
    """Lancia python <script_name> in DETACHED mode, working dir = ROOT.
    Eredita env corrente (gia' settata da setup_env).
    Mattia 2026-07-16: per i processi 'windowless' (monitor, dashboard, runner, sltp)
    usa pythonw.exe SENZA console visibile, redirigendo stdout/stderr ai file di log.
    Per il webhook mantiene python.exe per vedere gli stdout immediati in caso di errore."""
    # Processi che non hanno bisogno di console visibile
    WINDOWLESS_SCRIPTS = {
        "rettangolo_monitor.py",
        "stats_dashboard.py",
        "rettangolo_runner.py",
        "sltp_engine.py",
    "square_monitor.py",
    }
    try:
        if script_name in WINDOWLESS_SCRIPTS:
            # pythonw.exe: Python senza console. Redirigo output ai log.
            pyw = sys.executable.replace("python.exe", "pythonw.exe")
            if not os.path.exists(pyw):
                pyw = sys.executable  # fallback se pythonw non c'e'
            log_dir = ROOT / "webhook_listener" / "logs"
            log_dir.mkdir(parents=True, exist_ok=True)
            stdout_log = open(log_dir / f"{script_name.replace('.py','')}.out.log", "a", encoding="utf-8")
            stderr_log = open(log_dir / f"{script_name.replace('.py','')}.err.log", "a", encoding="utf-8")
            subprocess.Popen(
                [pyw, "-u", script_name],
                cwd=str(ROOT),
                env=os.environ.copy(),
                stdout=stdout_log,
                stderr=stderr_log,
                creationflags=SUBPROC_FLAGS,
                close_fds=False,
            )
            log(f"  [windowless] lanciato con {os.path.basename(pyw)}, output -> {script_name.replace('.py','')}.out.log")
        else:
            # Webhook: volutamente con console (python.exe) per vedere stdout in caso di errore.
            # Aggiungo comunque SUBPROC_FLAGS cosi' Start-Process non apre CMD lampeggianti.
            subprocess.Popen(
                ["python", "-u", script_name],
                cwd=str(ROOT),
                env=os.environ.copy(),
                creationflags=SUBPROC_FLAGS,
                close_fds=False,  # CRUCIALE: chiuderli causa SyntaxError misteriosi su questo sistema
            )
    except Exception as e:
        log(f"  [err] run_detached({script_name}) fallita: {e}")


def read_tunnel_url() -> str | None:
    """Legge l'URL Tailscale Funnel corrente da current_tunnel.txt. None se non c'e'."""
    try:
        if TUNNEL_URL_FILE.exists():
            url = TUNNEL_URL_FILE.read_text(encoding="utf-8").strip()
            if url and (".ts.net" in url or "trycloudflare.com" in url):
                return url
    except Exception:
        pass
    return None


def tailscale_funnel_active() -> bool:
    """Verifica se `tailscale funnel` ha un servizio attivo. Piu' affidabile di
    leggere current_tunnel.txt (che puo' essere stale)."""
    try:
        out = subprocess.check_output(
            [str(TAILSCALE_EXE), "funnel", "status"],
            text=True, timeout=10,
            creationflags=SUBPROC_FLAGS  # niente console lampeggiante
        )
        return "Funnel on:" in out
    except Exception:
        return False


def restart_tailscale_funnel_and_serve() -> None:
    """FIX 2026-07-16: riavvia sia `tailscale serve` (tailnet) che `tailscale funnel`
    (internet) in background. Il vecchio approccio lanciava solo `tailscale funnel 5580`
    senza --bg, quindi la config non persisteva dopo restart e current_tunnel.txt
    restava con URL vecchio (o vuoto)."""
    try:
        # 1. Tailscale SERVE (HTTPS 5580 tailnet)
        result_serve = subprocess.run(
            [str(TAILSCALE_EXE), "serve", "--bg", "--https=5580", "http://localhost:5580"],
            capture_output=True, text=True, timeout=15,
            creationflags=SUBPROC_FLAGS
        )
        log(f"  [restart] tailscale serve: rc={result_serve.returncode} out={result_serve.stdout[:200]}")
        time.sleep(2)
        # 2. Tailscale FUNNEL (HTTPS 443 internet)
        result_funnel = subprocess.run(
            [str(TAILSCALE_EXE), "funnel", "--bg", "5580"],
            capture_output=True, text=True, timeout=15,
            creationflags=SUBPROC_FLAGS
        )
        log(f"  [restart] tailscale funnel: rc={result_funnel.returncode} out={result_funnel.stdout[:200]}")
        time.sleep(2)
        # 3. Aggiorna current_tunnel.txt
        status_out = subprocess.check_output(
            [str(TAILSCALE_EXE), "funnel", "status"], text=True, timeout=10,
            creationflags=SUBPROC_FLAGS
        )
        import re as _re
        m = _re.search(r"https://([\w\.\-]+\.ts\.net)", status_out)
        if m:
            url = "https://" + m.group(1)
            TUNNEL_URL_FILE.write_text(url, encoding="utf-8")
            log(f"  [restart] current_tunnel.txt aggiornato: {url}")
        else:
            log(f"  [warn] URL Funnel non trovato in status output")
    except Exception as e:
        log(f"  [err] restart_tailscale_funnel_and_serve fallita: {e}")


def tunnel_alive(url: str, timeout: int = 8) -> bool:
    """HEAD sull'URL tunnel per verificare che risponda."""
    try:
        req = urllib.request.Request(url.rstrip("/") + "/health", method="GET")
        r = urllib.request.urlopen(req, timeout=timeout)
        return r.status == 200
    except Exception:
        return False


def sync_stats_with_bybit() -> None:
    """Chiama /api/sync-bybit sulla stats dashboard per allineare trades.csv
    con le chiusure reali Bybit (manuali + trigger SL/TP che NON passano dal
    webhook). Se trova trade mancanti, logga un alert. Esegue ogni 5 minuti.
    NB: la stats dashboard DEVE essere up sulla 5503 (verificato al check #2)."""
    try:
        req = urllib.request.Request("http://127.0.0.1:5503/api/sync-bybit?hours=6", method="GET")
        with urllib.request.urlopen(req, timeout=15) as resp:
            import json as _json
            result = _json.loads(resp.read().decode("utf-8"))
        added = result.get("added", 0)
        skipped = result.get("skipped_already_present", 0)
        scanned = result.get("scanned_closed_pnl", 0)
        if added > 0:
            log(f"[sync-bybit] ALERT: {added} chiusure Bybit mancanti -> aggiunte al CSV (scanned={scanned}, skipped={skipped})")
        # se added=0, silenzio (tutto ok)
    except Exception as e:
        log(f"[sync-bybit] err: {e}")


def restart_tailscale_funnel() -> None:
    """Rilancia `tailscale funnel 5580` come processo detached.
    Il demone tailscaled (servizio Windows) e' gia' attivo; questo rilancia
    solo il comando Funnel per la porta specifica."""
    try:
        # Killa TUTTI i processi 'tailscale.exe' che sono istanze di `funnel`
        # (il demone tailscaled e' un servizio Windows, non viene killato da qui)
        for pid_str in subprocess.check_output(
            ["powershell", "-NoProfile", "-Command",
             "Get-CimInstance Win32_Process -Filter \"Name = 'tailscale.exe'\" "
             "| Where-Object { $_.CommandLine -match 'funnel' } "
             "| Select-Object -ExpandProperty ProcessId"],
            text=True, timeout=10,
            creationflags=SUBPROC_FLAGS
        ).splitlines():
            pid = int(pid_str.strip())
            if pid:
                log(f"  [cleanup] killing tailscale funnel PID {pid}")
                subprocess.run(["taskkill", "/F", "/PID", str(pid)],
                               capture_output=True, timeout=5,
                               creationflags=SUBPROC_FLAGS)
        time.sleep(2)
        # Rilancia in detached
        subprocess.Popen(
            [str(TAILSCALE_EXE), "funnel", str(TAILSCALE_FUNNEL_PORT)],
            creationflags=SUBPROC_FLAGS,
            close_fds=False,
        )
        log(f"  [restart] tailscale funnel {TAILSCALE_FUNNEL_PORT} rilanciato in detached")
    except Exception as e:
        log(f"  [err] restart_tailscale_funnel fallita: {e}")


def main() -> int:
    setup_env()
    disable_restart = os.environ.get("DISABLE_RESTART", "").lower() in ("1", "true", "yes")
    log("=" * 60)
    if disable_restart:
        log("  *** DISABLE_RESTART=1 ATTIVO: monitor only, NO restart automatici ***")
    log("MAVIS SUPERVISOR v1 — avviato")
    log(f"  Check interval:  {CHECK_INTERVAL}s")
    log(f"  Restart cooldown: {RESTART_COOLDOWN}s")
    log(f"  Stats sync:    {STATS_SYNC_COOLDOWN}s (sync chiusure Bybit -> CSV)")
    log(f"  Monitorati: webhook(:5580), dashboard(:5503), rettangolo_monitor, sltp_engine, tailscale_funnel(5580)")
    log(f"  Esclusi: 4 processi Charter (STANDBY)")
    log(f"  Env BYBIT_API_KEY (len={len(os.environ.get('BYBIT_API_KEY',''))}), "
        f"BYBIT_API_SECRET (len={len(os.environ.get('BYBIT_API_SECRET',''))}), "
        f"WEBHOOK_SECRET (len={len(os.environ.get('WEBHOOK_SECRET',''))})")
    log("=" * 60)

    last_restart: dict[str, float] = {}

    while True:
        now = time.time()
        loop_start = time.time()
        try:
            # 1. WEBHOOK
            if not port_in_use(5580):
                if now - last_restart.get("webhook", 0) > RESTART_COOLDOWN:
                    log("[ALERT] webhook 5580 NON in ascolto -> restart" if not disable_restart else "[monitor] webhook 5580 NON in ascolto (DISABLE_RESTART)")
                    if not disable_restart:
                        run_detached("webhook_receiver.py")
                    last_restart["webhook"] = now
                else:
                    log(f"[skip] webhook in cooldown ({int(RESTART_COOLDOWN - (now - last_restart['webhook']))}s)")

            # 2. DASHBOARD 5503
            if not port_in_use(5503):
                if now - last_restart.get("dashboard", 0) > RESTART_COOLDOWN:
                    log("[ALERT] stats_dashboard 5503 NON in ascolto -> restart" if not disable_restart else "[monitor] stats_dashboard 5503 NON in ascolto (DISABLE_RESTART)")
                    if not disable_restart:
                        run_detached("stats_dashboard.py")
                    last_restart["dashboard"] = now
                else:
                    log(f"[skip] dashboard in cooldown ({int(RESTART_COOLDOWN - (now - last_restart['dashboard']))}s)")

            # 3. RETTANGOLO MONITOR (loop infinito)
            #    FIX 2026-07-16: rileva proliferazione (es. 6 istanze attive) e killa TUTTE
            #    prima di lanciarne una nuova. Cosi' ce n'e' sempre esattamente 1.
            monitor_count = count_processes("rettangolo_monitor")
            if monitor_count == 0:
                if now - last_restart.get("monitor", 0) > RESTART_COOLDOWN:
                    log("[ALERT] rettangolo_monitor NON attivo -> restart" if not disable_restart else "[monitor] rettangolo_monitor NON attivo (DISABLE_RESTART)")
                    if not disable_restart:
                        run_detached("rettangolo_monitor.py")
                    last_restart["monitor"] = now
                else:
                    log(f"[skip] monitor in cooldown ({int(RESTART_COOLDOWN - (now - last_restart['monitor']))}s)")
            elif monitor_count > 1:
                # PROLIFERAZIONE: killa tutte e riavvia 1 sola istanza pulita
                log(f"[ALERT] rettangolo_monitor PROLIFERAZIONE: {monitor_count} istanze attive -> cleanup + restart 1 sola")
                killed = kill_all_processes("rettangolo_monitor")
                log(f"  [cleanup] killate {killed} istanze duplicate")
                time.sleep(2)
                if not disable_restart:
                    run_detached("rettangolo_monitor.py")
                last_restart["monitor"] = now
            # else: monitor_count == 1, tutto ok

            # 3b. SLTP ENGINE (loop 300s, applica SL/TP trailing a TUTTE le posizioni aperte)
            #     FIX 2026-07-16 (Verifier): senza questo check, sltp_engine morto alle 13:20
            #     e' rimasto spento ~10h — ETH aperta 23:09 dal webhook Pine e' rimasta senza
            #     SL/TP per ~37min. Ora se muore riparte entro 30s.
            sltp_count = count_processes("sltp_engine")
            if sltp_count == 0:
                if now - last_restart.get("sltp", 0) > RESTART_COOLDOWN:
                    log("[ALERT] sltp_engine NON attivo -> restart" if not disable_restart else "[monitor] sltp_engine NON attivo (DISABLE_RESTART)")
                    if not disable_restart:
                        run_detached("sltp_engine.py")
                    last_restart["sltp"] = now
                else:
                    log(f"[skip] sltp in cooldown ({int(RESTART_COOLDOWN - (now - last_restart['sltp']))}s)")
            elif sltp_count > 1:
                # PROLIFERAZIONE: killa tutte e riavvia 1 sola istanza pulita
                log(f"[ALERT] sltp_engine PROLIFERAZIONE: {sltp_count} istanze attive -> cleanup + restart 1 sola")
                killed = kill_all_processes("sltp_engine")
                log(f"  [cleanup] killate {killed} istanze duplicate")
                time.sleep(2)
                if not disable_restart:
                    run_detached("sltp_engine.py")
                last_restart["sltp"] = now
            # else: sltp_count == 1, tutto ok


            # square_monitor (porta 5504, SquareStrategy mean-reversion)
            square_count = count_processes("square_monitor")
            if square_count == 0:
                    log("[ALERT] square_monitor NON attivo -> restart" if not disable_restart else "[monitor] square_monitor NON attivo (DISABLE_RESTART)")
                    if not disable_restart:
                        run_detached("square_monitor.py")
            elif square_count > 1:
                log(f"[ALERT] square_monitor PROLIFERAZIONE: {square_count} istanze attive -> cleanup + restart 1 sola")
                killed = kill_all_processes("square_monitor")
                if not disable_restart:
                    run_detached("square_monitor.py")
            # else: square_count == 1, tutto ok

            # 4. TUNNEL CHECK (Tailscale Funnel + Serve - internet + tailnet)
            if now - last_restart.get("tunnel", 0) > TUNNEL_CHECK_COOLDOWN:
                # 4a. Verifica funnel ATTIVO (non solo URL in file)
                if not tailscale_funnel_active():
                    log("[ALERT] tailscale funnel NON attivo -> restart serve+funnel")
                    restart_tailscale_funnel_and_serve()
                    last_restart["tunnel"] = now
                else:
                    # 4b. Verifica URL risponda
                    url = read_tunnel_url()
                    if url and not tunnel_alive(url):
                        log(f"[ALERT] tunnel {url} NON risponde -> restart")
                        restart_tailscale_funnel_and_serve()
                        last_restart["tunnel"] = now

            # 4c. SUPERVISOR WATCHDOG (controlla se e' vivo, rilancia se muore)
            #     FIX 2026-07-16: watchdog in background controlla se superv. e' vivo
            #     (bidirezionale: se watchdog muore, superv. lo rilancia; se superv. muore, watchdog lo rilancia)
            if not process_running("supervisor_watchdog"):
                if now - last_restart.get("watchdog", 0) > RESTART_COOLDOWN:
                    log("[ALERT] supervisor_watchdog NON attivo -> restart")
                    if not disable_restart:
                        # Lancia in detached (pythonw, no console)
                        try:
                            pyw_wd = sys.executable.replace("python.exe", "pythonw.exe")
                            if not os.path.exists(pyw_wd):
                                pyw_wd = sys.executable
                            wd_log_dir = ROOT / "webhook_listener" / "logs"
                            wd_log_dir.mkdir(parents=True, exist_ok=True)
                            wd_out = open(wd_log_dir / "_supervisor_watchdog.out.log", "a", encoding="utf-8")
                            wd_err = open(wd_log_dir / "_supervisor_watchdog.err.log", "a", encoding="utf-8")
                            subprocess.Popen(
                                [pyw_wd, "-u", "_supervisor_watchdog.py"],
                                cwd=str(ROOT),
                                env=os.environ.copy(),
                                stdout=wd_out,
                                stderr=wd_err,
                                creationflags=SUBPROC_FLAGS,
                                close_fds=False,
                            )
                            log(f"  [watchdog] lanciato con {os.path.basename(pyw_wd)}")
                        except Exception as e:
                            log(f"  [err] launch watchdog fallita: {e}")
                    last_restart["watchdog"] = now
                    # se vivo e URL OK, silenzio

            # 5. SYNC STATS con Bybit (chiude il gap "stats dormienti")
            # Chiama /api/sync-bybit sulla dashboard per allineare trades.csv
            # con le chiusure reali Bybit (manuali + trigger SL/TP non-webhook).
            if port_in_use(5503) and now - last_restart.get("stats_sync", 0) > STATS_SYNC_COOLDOWN:
                sync_stats_with_bybit()
                last_restart["stats_sync"] = now

        except KeyboardInterrupt:
            log("[STOP] KeyboardInterrupt, esco")
            return 0
        except BaseException as e:  # FIX 2026-07-16: BaseException per catturare ANCHE SystemExit/GeneratorExit
            # Includo lo stack trace completo per capire crash silenziosi (case 2026-07-16 07:05:55)
            import traceback as _tb
            log(f"[ERR] main loop: {e}")
            log(f"[ERR] traceback:\n{_tb.format_exc()}")

        # FIX 2026-07-16: heartbeat ogni 5 minuti per capire se il loop gira ancora
        # (caso 2026-07-16 07:05:55: log终止ava a "[ALERT] webhook ... restart" senza errori → heartbeat aiuta debug)
        loop_dur = time.time() - loop_start
        if now - last_restart.get("heartbeat", 0) > 300:  # 5 min
            log(f"[heartbeat] loop ok, dur={loop_dur:.1f}s, free mem ok")
            last_restart["heartbeat"] = now

        try:
            time.sleep(CHECK_INTERVAL)
        except KeyboardInterrupt:
            log("[STOP] KeyboardInterrupt durante sleep, esco")
            return 0


if __name__ == "__main__":
    sys.exit(main())
