#!/usr/bin/env python3
"""
====================================================================
 DAZN - Manager & DRM Extractor (Widevine WVD)
 Auto-Extractor: Live & Linear Streams (DAZN 1, Plus, ecc.)
 Output: dazn_channels.json & dazn_playlist.m3u
====================================================================
"""
import os
import sys
import re
import json
import time
import base64
import uuid
import hashlib
import argparse
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple, Any
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
import socket
import subprocess
import atexit

# Prova ad importare curl_cffi per impersonazione TLS Chrome (bypassa CloudFront/WAF)
try:
    from curl_cffi import requests as cffi_requests
    HAS_CURL_CFFI = True
except ImportError:
    import requests as cffi_requests
    HAS_CURL_CFFI = False

import requests
from pywidevine import PSSH, Cdm, Device

# Garantisci supporto UTF-8 su terminali Windows
if sys.platform == 'win32':
    try:
        sys.stdout.reconfigure(encoding='utf-8', errors='replace')
        sys.stderr.reconfigure(encoding='utf-8', errors='replace')
    except Exception:
        pass

# ── Colori ANSI per il terminale ──────────────────────────────────
GREEN   = '\033[32m'
YELLOW  = '\033[33m'
RED     = '\033[31m'
CYAN    = '\033[36m'
BLUE    = '\033[34m'
MAGENTA = '\033[35m'
BOLD    = '\033[1m'
DIM     = '\033[2m'
RESET   = '\033[0m'

# ── Costanti & File di Sistema (Percorsi assoluti) ─────────────────
SCRIPT_DIR       = os.path.dirname(os.path.abspath(__file__))
CONFIG_FILE      = os.path.join(SCRIPT_DIR, "dazn_config.json")
TOKEN_FILE       = os.path.join(SCRIPT_DIR, "dazn_tokens.json")
CACHE_FILE       = os.path.join(SCRIPT_DIR, "pssh_cache.json")
WVD_FILE         = os.path.join(SCRIPT_DIR, "WVD.wvd")
PROXY_FILE       = os.path.join(SCRIPT_DIR, "proxy.txt")
OUTPUT_JSON_FILE = os.path.join(SCRIPT_DIR, "dazn_channels.json")
PLAYLIST_FILE    = os.path.join(SCRIPT_DIR, "dazn_playlist.m3u")
PROXY_SCRIPT_FILE= os.path.join(SCRIPT_DIR, "dazn_proxy.py")

# ── Gestione Processo Proxy Integrato ─────────────────────────────
_PROXY_PROCESS = None

def is_port_open(port: int, host: str = "127.0.0.1") -> bool:
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.settimeout(0.5)
            return s.connect_ex((host, port)) == 0
    except Exception:
        return False

def ensure_proxy_running(port: int = 10443) -> None:
    global _PROXY_PROCESS
    if is_port_open(port):
        log(f"📡 DAZN Proxy già attivo e in ascolto sulla porta {port}.", GREEN)
        return

    if not os.path.exists(PROXY_SCRIPT_FILE):
        log(f"⚠️ Script proxy non trovato in {PROXY_SCRIPT_FILE}", YELLOW)
        return

    log(f"🚀 Avvio automatico DAZN HTTPS Proxy sulla porta {port}...", CYAN)
    try:
        _PROXY_PROCESS = subprocess.Popen(
            [sys.executable, PROXY_SCRIPT_FILE],
            cwd=SCRIPT_DIR
        )
        log(f"✅ DAZN Proxy avviato in background (PID: {_PROXY_PROCESS.pid})", GREEN)
    except Exception as e:
        log(f"❌ Impossibile avviare dazn_proxy.py: {e}", RED)

def stop_proxy() -> None:
    global _PROXY_PROCESS
    if _PROXY_PROCESS and _PROXY_PROCESS.poll() is None:
        log("🛑 Arresto DAZN Proxy...", YELLOW)
        try:
            _PROXY_PROCESS.terminate()
            _PROXY_PROCESS.wait(timeout=2)
        except Exception:
            try:
                _PROXY_PROCESS.kill()
            except Exception:
                pass

atexit.register(stop_proxy)

# Intervallo di aggiornamento predefinito: 3 ore (in secondi)
DEFAULT_REFRESH_SECONDS = 3 * 3600

# System ID ufficiale Widevine
WIDEVINE_SYSTEM_ID = "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
WIDEVINE_SYSTEM_ID_BYTES = bytes.fromhex("edef8ba979d64acea3c827dcd51d21ed")

pssh_cache: Dict[str, Any] = {}

# ── Helper Logging & Banner ───────────────────────────────────────
def log(msg: str, color: str = RESET) -> None:
    print(f"{color}[{datetime.now().strftime('%H:%M:%S')}] {msg}{RESET}", flush=True)

def print_banner() -> None:
    banner = f"""{YELLOW}
  ____     _     ______  _   _ 
 |  _ \\   / \\   |__  / \\| \\ | |
 | | | | / _ \\    / /|  \\|  \\| |
 | |_| |/ ___ \\  / /_| |\\ \\  |
 |____//_/   \\_\\/____|_| \\_|
{BLUE}{'═' * 70}{RESET}
{CYAN}  ◆ DAZN Auto-Manager · Widevine Extractor & Decryptor ◆{RESET}
{BLUE}{'═' * 70}{RESET}"""
    print(banner)

# ── Gestione Proxy (proxy.txt o dazn_config.json) ─────────────────
class ProxyManager:
    """Carica, analizza, valida e ruota i proxy da proxy.txt o da dazn_config.json"""
    def __init__(self, filepath: str = PROXY_FILE):
        self.filepath = filepath
        self.proxies: List[str] = []
        self.config_proxy: Optional[Dict[str, str]] = None
        self.current_idx = 0
        self.load()

    def parse_proxy_line(self, line: str) -> Optional[str]:
        line = line.strip()
        if not line or line.startswith('#'):
            return None
        
        if line.startswith(('http://', 'https://', 'socks5://', 'socks5h://')):
            return line
        
        parts = line.split(':')
        if len(parts) == 4:
            host, port, user, pwd = parts
            return f"http://{user}:{pwd}@{host}:{port}"
        elif len(parts) == 2:
            return f"http://{parts[0]}:{parts[1]}"
        elif '@' in line:
            return f"http://{line}"
        
        return None

    def set_config_proxy(self, proxy_conf: Any) -> None:
        if isinstance(proxy_conf, dict):
            http_p = proxy_conf.get('http') or proxy_conf.get('https')
            https_p = proxy_conf.get('https') or proxy_conf.get('http')
            if http_p:
                self.config_proxy = {"http": http_p, "https": https_p or http_p}
        elif isinstance(proxy_conf, str) and proxy_conf.strip():
            parsed = self.parse_proxy_line(proxy_conf.strip()) or proxy_conf.strip()
            self.config_proxy = {"http": parsed, "https": parsed}

    def load(self, config_proxy: Any = None) -> None:
        self.proxies = []
        if config_proxy is not None:
            self.set_config_proxy(config_proxy)

        if os.path.exists(self.filepath):
            try:
                with open(self.filepath, 'r', encoding='utf-8', errors='ignore') as f:
                    for line in f:
                        parsed = self.parse_proxy_line(line)
                        if parsed and parsed not in self.proxies:
                            self.proxies.append(parsed)
            except Exception:
                pass

        self.current_idx = 0

    def get_dict(self) -> Optional[Dict[str, str]]:
        if self.proxies:
            proxy_url = self.proxies[self.current_idx % len(self.proxies)]
            return {"http": proxy_url, "https": proxy_url}
        if self.config_proxy:
            return self.config_proxy
        return None

    def rotate(self) -> Optional[Dict[str, str]]:
        if self.proxies:
            self.current_idx = (self.current_idx + 1) % len(self.proxies)
        return self.get_dict()

    def count(self) -> int:
        return len(self.proxies) if self.proxies else (1 if self.config_proxy else 0)

    def test_all(self) -> List[Tuple[str, bool, str, float]]:
        results = []
        list_to_test = list(self.proxies)
        if not list_to_test and self.config_proxy:
            list_to_test.append(self.config_proxy['http'])

        for p in list_to_test:
            proxies_dict = {"http": p, "https": p}
            t0 = time.time()
            try:
                r = requests.get('https://api.ipify.org?format=json', proxies=proxies_dict, timeout=10)
                dur = round((time.time() - t0) * 1000, 1)
                if r.ok:
                    ip = r.json().get('ip', 'OK')
                    results.append((p, True, ip, dur))
                else:
                    results.append((p, False, f"HTTP {r.status_code}", dur))
            except Exception as e:
                dur = round((time.time() - t0) * 1000, 1)
                results.append((p, False, str(e)[:30], dur))
        return results

proxy_manager = ProxyManager(PROXY_FILE)

# ── Gestione Cache PSSH & Configurazione ───────────────────────────
def load_config() -> Dict[str, Any]:
    default_config = {
        "dazn": {
            "token": "",
            "email": "",
            "password": "",
            "device_id": "57b6a530-70a4-4d65-b412-46a1a5c22b38",
            "daznid": "66125bf7-0b92-47fc-b618-93cfea67f55c",
            "app_version": "0.147.8",
            "player_id": "@dazn/peng-html5-core/web/web",
            "language": "it",
            "proxy": {
                "http": "",
                "https": ""
            },
            "headers": {
                "referer": "https://www.dazn.com/",
                "sec-ch-ua": '"Chromium";v="152", "Not?A_Brand";v="24", "Google Chrome";v="152"',
                "sec-ch-ua-mobile": "?0",
                "sec-ch-ua-platform": '"Windows"',
                "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36",
                "x-brand": "DAZN"
            },
            "license_headers": {
                "content-type": "application/octet-stream",
                "referer": "https://www.dazn.com/",
                "sec-ch-ua": '"Chromium";v="152", "Not?A_Brand";v="24", "Google Chrome";v="152"',
                "sec-ch-ua-mobile": "?0",
                "sec-ch-ua-platform": '"Windows"',
                "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36",
                "x-brand": "DAZN"
            },
            "channels": [
                {
                    "id": "4hqcjx5zv91f1bdx71io4bnrl",
                    "name": "DAZN 1",
                    "playback_url": ""
                }
            ]
        },
        "loop": {
            "refresh_interval_hours": 3
        }
    }

    if not os.path.exists(CONFIG_FILE):
        with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
            json.dump(default_config, f, indent=2, ensure_ascii=False)
        cfg = default_config
    else:
        try:
            with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
                cfg = json.load(f)
            if 'dazn' not in cfg:
                cfg['dazn'] = default_config['dazn']
        except Exception:
            cfg = default_config

    if 'dazn' in cfg and 'proxy' in cfg['dazn']:
        proxy_manager.set_config_proxy(cfg['dazn']['proxy'])

    return cfg

def save_config(cfg: Dict[str, Any]) -> None:
    try:
        current = {}
        if os.path.exists(CONFIG_FILE):
            with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
                current = json.load(f)
        current.update(cfg)
        with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
            json.dump(current, f, indent=2, ensure_ascii=False)
    except Exception as e:
        log(f"Errore salvataggio config: {e}", RED)

def load_cache() -> None:
    global pssh_cache
    if os.path.exists(CACHE_FILE):
        try:
            with open(CACHE_FILE, 'r', encoding='utf-8') as f:
                pssh_cache = json.load(f)
        except Exception:
            pssh_cache = {}

def save_cache() -> None:
    with open(CACHE_FILE, 'w', encoding='utf-8') as f:
        json.dump(pssh_cache, f, indent=2, ensure_ascii=False)

# ── Gestione Token JWT DAZN ───────────────────────────────────────
def decode_jwt_payload(token: str) -> Dict[str, Any]:
    """Decodifica il payload di un JWT DAZN senza richiedere librerie esterne"""
    try:
        parts = token.strip().split('.')
        if len(parts) != 3:
            return {}
        payload_b64 = parts[1]
        rem = len(payload_b64) % 4
        if rem > 0:
            payload_b64 += '=' * (4 - rem)
        decoded = base64.urlsafe_b64decode(payload_b64)
        return json.loads(decoded.decode('utf-8'))
    except Exception:
        return {}

def check_token_status(token: str) -> Tuple[bool, int, Dict[str, Any]]:
    """Verifica validità e scadenza del JWT DAZN"""
    payload = decode_jwt_payload(token)
    if not payload:
        return False, 0, {}
    exp = payload.get('exp', 0)
    now = int(time.time())
    remaining = exp - now
    is_valid = remaining > 60  # Almeno 1 minuto di validità residua
    return is_valid, remaining, payload

import random
import string

def dazn_login(email: str, password: str, proxy_dict: Optional[Dict[str, str]] = None) -> Tuple[str, Optional[str]]:
    """Effettua il login su DAZN e restituisce lo stato (SUCCESS, RETRY, FAIL) e il token."""
    url = "https://authentication-prod.ar.indazn.com/v5/SignIn"
    
    s_val = ''.join(random.choices(string.digits, k=4))
    k_val = ''.join(random.choices(string.digits, k=3))
    
    payload = {
        "Email": email,
        "Password": password,
        "Platform": "web",
        "DeviceId": f"0{s_val}e5ed",
        "ProfilingSessionId": f"2bdz6s9k{k_val}ydg5ujkhink9"
    }
    
    headers = {
        "accept": "*/*",
        "accept-language": "it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7",
        "cache-control": "no-cache",
        "content-type": "application/json",
        "origin": "https://www.dazn.com",
        "referer": "https://www.dazn.com/",
        "sec-ch-ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-platform": '"Windows"',
        "sec-fetch-dest": "empty",
        "sec-fetch-mode": "cors",
        "sec-fetch-site": "cross-site",
        "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
        "x-dazn-ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 signin/undefined hyper/0.14.0 (web; production; it)"
    }
    
    log(f"🔑 Tentativo di Login in corso per {email}...", CYAN)
    try:
        # Usa curl_cffi con impersonazione Chrome per bypassare CloudFront WAF
        s = create_session(proxy_dict, impersonate="chrome120")
        r = s.post(url, json=payload, headers=headers, timeout=15)
        text = r.text
        
        log(f"🔍 DEBUG Risposta Login per {email}: HTTP {r.status_code} - {text[:300]}", DIM)
        
        # KEYCHECK Logic (OpenBullet)
        if "Request blocked" in text or "CloudFront" in text or r.status_code == 403:
            log("🛑 CloudFront WAF ha bloccato la richiesta! Riprovo...", YELLOW)
            return "RETRY", None
            
        if "InvalidPassword" in text or "AccountBlocked" in text:
            return "FAIL", None
        if "Request limiting policy has been breached" in text or "Security policy has been breached" in text:
            return "RETRY", None
        if "SignedIn" in text or "Token" in text:
            try:
                token = r.json().get("Token") or r.json().get("AuthToken", {}).get("Token")
                if token:
                    return "SUCCESS", token
            except Exception:
                pass
                
        # Fallback check
        if r.ok:
            try:
                token = r.json().get("Token") or r.json().get("AuthToken", {}).get("Token")
                if token: return "SUCCESS", token
            except Exception:
                pass
                
        return "FAIL", None
    except Exception as e:
        err_msg = str(e)
        if "SSL_ERROR_SYSCALL" in err_msg or "Connection closed abruptly" in err_msg or "(35)" in err_msg:
            log(f"🛑 DAZN CloudFront WAF ha terminato bruscamente la connessione TLS!", RED)
            log(f"   Motivo: L'IP del server (VPS/Datacenter o estero) è bloccato dall'anti-bot di DAZN.", YELLOW)
            log(f"   Soluzione 1: Copia 'dazn_tokens.json' (generato da Windows) sul server Linux.", YELLOW)
            log(f"   Soluzione 2: Inserisci un proxy italiano (residenziale) in proxy.txt.", YELLOW)
        else:
            log(f"❌ Eccezione durante il Login: {e}", RED)
        return "RETRY", None

def get_token(config: Dict[str, Any], cli_token: Optional[str] = None) -> str:
    """Recupera il token attivo dal file tokens, dalla config, oppure esegue il login se configurato"""
    token = cli_token
    if not token and os.path.exists(TOKEN_FILE):
        try:
            t_data = json.load(open(TOKEN_FILE, 'r', encoding='utf-8'))
            t_cand = t_data.get('token', '')
            if t_cand:
                val, rem, _ = check_token_status(t_cand)
                if val:
                    token = t_cand
                else:
                    log(f"⚠️ Token salvato in {TOKEN_FILE} è scaduto (scaduto da {-rem}s)", YELLOW)
        except Exception:
            pass

    if not token:
        token = config.get('dazn', {}).get('token', '')

    if token:
        val, rem, payload = check_token_status(token)
        if not val:
            token = "" # Forza il reset se è scaduto anche quello della config

    # Se non c'è un token valido e abbiamo le credenziali, tentiamo il login
    if not token:
        accounts_file = os.path.join(os.path.dirname(__file__), 'accounts.txt')
        if os.path.exists(accounts_file):
            log(f"⏳ Nessun token valido trovato, avvio Login automatico da {accounts_file}...", YELLOW)
            try:
                with open(accounts_file, 'r', encoding='utf-8') as f:
                    lines = [l.strip() for l in f if ':' in l and not l.startswith('#')]
            except Exception:
                lines = []
                
            for line in lines:
                email, password = line.split(':', 1)
                
                retries = 3
                while retries > 0:
                    proxy_dict = proxy_manager.get_dict()
                    status, new_token = dazn_login(email, password, proxy_dict)
                    
                    if status == "SUCCESS" and new_token:
                        token = new_token
                        log("✅ Login completato con successo! Nuovo token acquisito.", GREEN)
                        try:
                            with open(TOKEN_FILE, 'w', encoding='utf-8') as f:
                                json.dump({"token": token}, f, indent=2)
                        except Exception:
                            pass
                        break
                    elif status == "RETRY":
                        log(f"⚠️ Rate limit o errore per {email}, cambio proxy e riprovo... ({retries-1} tentativi rimasti)", YELLOW)
                        proxy_manager.rotate()
                        retries -= 1
                        time.sleep(2)
                    else:
                        log(f"❌ Account {email} fallito (Invalid o Blocked), passo al successivo...", RED)
                        break
                        
                if token:
                    break
            
            if not token:
                log("❌ Nessun account in accounts.txt è risultato valido o funzionante.", RED)
        else:
            # Fallback legacy: prova config.json
            email = config.get('dazn', {}).get('email', '')
            password = config.get('dazn', {}).get('password', '')
            if email and password:
                proxy_dict = proxy_manager.get_dict()
                st, tk = dazn_login(email, password, proxy_dict)
                if tk:
                    token = tk
                    try:
                        with open(TOKEN_FILE, 'w', encoding='utf-8') as f:
                            json.dump({"token": token}, f, indent=2)
                    except Exception:
                        pass
            
    if token:
        val, rem, payload = check_token_status(token)
        if val:
            mins = rem // 60
            hrs = mins // 60
            log(f"🔑 Token DAZN valido · Scadenza tra {hrs}h {mins % 60}m (ViewerID: {payload.get('viewerId', 'N/A')})", GREEN)
            # Sincronizza token_file
            t_info = {
                "token": token,
                "viewer_id": payload.get("viewerId", ""),
                "daznid": payload.get("user", ""),
                "device_id": payload.get("deviceId", ""),
                "country": payload.get("country", "it"),
                "user_status": payload.get("userstatus", ""),
                "issued": payload.get("issued", 0),
                "exp": payload.get("exp", 0),
                "updated_at": datetime.now().isoformat()
            }
            try:
                with open(TOKEN_FILE, 'w', encoding='utf-8') as f:
                    json.dump(t_info, f, indent=2, ensure_ascii=False)
            except Exception as write_err:
                log(f"⚠️ Impossibile aggiornare {TOKEN_FILE} ({write_err}). Procedo con il token in memoria.", YELLOW)
        else:
            log(f"⚠️ ATTENZIONE: Il token fornito è SCADUTO o NON VALIDO!", RED)
            log(f"Aggiorna il token in {CONFIG_FILE} o usa --token <nuovo_token>", YELLOW)

    return token

# ── Client HTTP con TLS Impersonation ──────────────────────────────
def create_session(proxy_dict: Optional[Dict[str, str]] = None, impersonate: str = "chrome120") -> Any:
    """Crea una sessione HTTP ottimizzata con impersonazione Chrome per bypassare CloudFront"""
    if HAS_CURL_CFFI:
        try:
            s = cffi_requests.Session(impersonate=impersonate)
        except Exception:
            s = cffi_requests.Session(impersonate="chrome")
    else:
        s = requests.Session()
    if proxy_dict:
        s.proxies = proxy_dict
    return s

# ── Risoluzione Stream & DRM Widevine ─────────────────────────────
def is_widevine_pssh(pssh_b64: str) -> bool:
    """Verifica se una stringa PSSH Base64 appartiene a Widevine"""
    try:
        raw = base64.b64decode(pssh_b64)
        if len(raw) >= 28 and raw[12:28] == WIDEVINE_SYSTEM_ID_BYTES:
            return True
        p = PSSH(pssh_b64)
        return str(p.system_id).lower() == WIDEVINE_SYSTEM_ID
    except Exception:
        return False

def extract_widevine_pssh(mpd_content: str) -> Tuple[Optional[str], Optional[str]]:
    """Estrae il blocco PSSH Widevine e il relativo KID dal manifesto DASH MPD"""
    try:
        root = ET.fromstring(mpd_content)
    except Exception as e:
        log(f"Errore parsing XML del manifesto DASH: {e}", RED)
        return None, None

    candidates = []
    adaptation_sets = [elem for elem in root.iter() if elem.tag.endswith('AdaptationSet')]
    if not adaptation_sets:
        adaptation_sets = [root]

    for aset in adaptation_sets:
        mime = (aset.attrib.get('mimeType') or aset.attrib.get('contentType') or '').lower()
        is_video = 'video' in mime or 'mp4' in mime

        cps = [elem for elem in aset.iter() if elem.tag.endswith('ContentProtection')]
        for cp in cps:
            scheme_id = cp.attrib.get('schemeIdUri', '').lower()
            is_wv_scheme = WIDEVINE_SYSTEM_ID in scheme_id

            pssh_nodes = [elem for elem in cp.iter() if elem.tag.endswith('pssh')]
            for node in pssh_nodes:
                text = (node.text or '').strip()
                if not text:
                    continue
                if is_wv_scheme or is_widevine_pssh(text):
                    candidates.append({
                        'pssh': text,
                        'is_video': is_video,
                        'is_wv_scheme': is_wv_scheme
                    })

    if not candidates:
        for elem in root.iter():
            if elem.tag.endswith('pssh') and elem.text and elem.text.strip():
                text = elem.text.strip()
                if is_widevine_pssh(text):
                    candidates.append({
                        'pssh': text,
                        'is_video': True,
                        'is_wv_scheme': True
                    })

    if not candidates:
        return None, None

    candidates.sort(key=lambda c: (c['is_video'], c['is_wv_scheme']), reverse=True)
    best_pssh = candidates[0]['pssh']

    expected_kid = None
    try:
        p = PSSH(best_pssh)
        if p.key_ids:
            first_kid = p.key_ids[0]
            if hasattr(first_kid, 'hex') and callable(getattr(first_kid, 'hex')):
                expected_kid = first_kid.hex()
            elif hasattr(first_kid, 'hex') and isinstance(first_kid.hex, str):
                expected_kid = first_kid.hex
            elif isinstance(first_kid, bytes):
                expected_kid = first_kid.hex()
            else:
                expected_kid = str(first_kid).replace('-', '')
    except Exception:
        pass

    return best_pssh, expected_kid

def build_playback_url(channel: Dict[str, Any], config: Dict[str, Any], viewer_id: str, platform: str = "tv") -> str:
    """Costruisce l'endpoint Playback. platform='tv' per FHD, platform='web' per fallback."""
    if channel.get('playback_url') and platform == "web":
        base_url = channel['playback_url']
        # Se ha già tutti i parametri, aggiorna solo il SessionId per garantire freschezza
        p = urlparse(base_url)
        q = parse_qs(p.query)
        asset_id = channel.get('id') or (q.get('AssetId', [''])[0])
        ts = int(time.time() * 1000)
        rnd_hex = uuid.uuid4().hex[:6].upper()
        v_id = viewer_id or 'unknown'
        q['SessionId'] = [f"{ts}-{v_id}-{asset_id}-{rnd_hex}"]
        if 'MtaLanguageCode' in q:
            q['MtaLanguageCode'] = ['']
        return urlunparse((p.scheme, p.netloc, p.path, p.params, urlencode(q, doseq=True), p.fragment))

    asset_id = channel.get('id', '4hqcjx5zv91f1bdx71io4bnrl')

    if platform == "tv":
        # Parametri TV (come lo script SX che ottiene FHD)
        params = {
            "AppVersion": "0.95.6",
            "DrmType": "WIDEVINE",
            "Format": "MPEG-DASH",
            "PlayerId": "dazn",
            "Platform": "tv",
            "LanguageCode": "en",
            "Model": "unknown",
            "Secure": "true",
            "Manufacturer": "tlc",
            "PlayReadyInitiator": "false",
            "Capabilities": "mta",
            "AssetId": asset_id,
            "MtaLanguageCode": ""
        }
    else:
        # Parametri Web (fallback sicuro, funziona sempre)
        dazn_cfg = config.get('dazn', {})
        lang = dazn_cfg.get('language', 'it')
        v_id = viewer_id or 'unknown'
        ts = int(time.time() * 1000)
        rnd_hex = uuid.uuid4().hex[:6].upper()
        session_id = f"{ts}-{v_id}-{asset_id}-{rnd_hex}"
        params = {
            "AppVersion": "0.147.8",
            "DrmType": "WIDEVINE",
            "Format": "MPEG-DASH",
            "PlayerId": "@dazn/peng-html5-core/web/web",
            "Platform": "web",
            "Model": "unknown",
            "Secure": "true",
            "Manufacturer": "microsoft",
            "PlayReadyInitiator": "false",
            "Capabilities": "hcst,mta",
            "AssetId": asset_id,
            "MtaLanguageCode": "",
            "LanguageCode": lang,
            "SessionId": session_id
        }
    return f"https://api.playback.indazn.com/v5/Playback?{urlencode(params)}"

def request_playback_info(playback_url: str, token: str, config: Dict[str, Any], proxy_dict: Optional[Dict[str, str]]) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
    """Effettua la chiamata all'endpoint Playback DAZN con headers identici allo script SX funzionante"""
    # Estrai deviceId dal JWT (come fa lo script SX)
    payload = decode_jwt_payload(token)
    device_id = payload.get("deviceId", "") if payload else ""
        
    headers = {
        "accept": "application/json, text/plain, */*",
        "accept-language": "en-US,en;q=0.9",
        "authorization": f"Bearer {token}",
        "origin": "https://www.dazn.com",
        "referer": "https://www.dazn.com/",
        "sec-ch-ua": '"Not)A;Brand";v="99", "Google Chrome";v="127", "Chromium";v="127"',
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-platform": '"Windows"',
        "sec-fetch-dest": "empty",
        "sec-fetch-mode": "cors",
        "sec-fetch-site": "cross-site",
        "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
        "x-dazn-device": device_id,
    }

    s = create_session(proxy_dict, impersonate="chrome124")
    try:
        r = s.get(playback_url, headers=headers, timeout=25)
        if not r.ok:
            try:
                err_json = r.json()
                msg = err_json.get('odata.error', {}).get('message', {}).get('value') or r.text[:200]
            except Exception:
                msg = r.text[:200]
            return None, f"HTTP {r.status_code}: {msg}"
        
        return r.json(), None
    except Exception as e:
        return None, str(e)

def get_keys_wvd(pssh_b64: str, asset_id: str, la_url: str, config: Dict[str, Any], tokens_payload: Dict[str, Any], expected_kid: Optional[str] = None, proxy_dict: Optional[Dict[str, str]] = None) -> Tuple[Optional[str], Optional[str]]:
    """Decifra la chiave di contenuto Widevine usando WVD.wvd e l'endpoint di Licenza DAZN"""
    global pssh_cache

    if expected_kid:
        cache_key = f"{asset_id}:{expected_kid.lower()}"
    else:
        cache_key = f"{asset_id}:{hashlib.sha256(pssh_b64.encode()).hexdigest()[:16]}"

    if cache_key in pssh_cache:
        cached = pssh_cache[cache_key]
        return cached.get('kid'), cached.get('key')

    if not os.path.exists(WVD_FILE):
        log(f"ERRORE: File CDM {WVD_FILE} non trovato!", RED)
        return None, None

    try:
        device = Device.load(WVD_FILE)
        cdm = Cdm.from_device(device)
        session = cdm.open()
        challenge = cdm.get_license_challenge(session, PSSH(pssh_b64))

        dazn_cfg = config.get('dazn', {})
        user_id = tokens_payload.get("user") or dazn_cfg.get("daznid", "66125bf7-0b92-47fc-b618-93cfea67f55c")
        
        lic_headers = {
            "Content-Type": "application/octet-stream",
            "Origin": "https://www.dazn.com",
            "Referer": "https://www.dazn.com/",
            "sec-ch-ua": dazn_cfg.get("license_headers", {}).get("sec-ch-ua", '"Chromium";v="152", "Not?A_Brand";v="24", "Google Chrome";v="152"'),
            "sec-ch-ua-mobile": "?0",
            "sec-ch-ua-platform": '"Windows"',
            "user-agent": dazn_cfg.get("license_headers", {}).get("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36"),
            "x-brand": "DAZN",
            "x-correlation-id": str(uuid.uuid4()),
            "x-daznid": user_id
        }

        s = create_session(proxy_dict)
        r = s.post(la_url, headers=lic_headers, data=challenge, timeout=30)
        if not r.ok:
            log(f"Chiamata License fallita (HTTP {r.status_code}): {r.text[:200]}", RED)
            cdm.close(session)
            return None, None

        cdm.parse_license(session, r.content)

        content_keys: List[Tuple[str, str]] = []
        for k in cdm.get_keys(session):
            if k.type != 'SIGNING':
                k_kid = k.kid.hex if hasattr(k.kid, 'hex') and isinstance(k.kid.hex, str) else (k.kid.hex() if hasattr(k.kid, 'hex') else str(k.kid).replace('-', ''))
                k_key = k.key.hex() if callable(getattr(k.key, 'hex', None)) else getattr(k.key, 'hex', str(k.key))
                content_keys.append((k_kid.lower(), k_key.lower()))
        cdm.close(session)

        if not content_keys:
            log("Nessuna chiave di contenuto trovata nella licenza DRM", RED)
            return None, None

        kid, key = None, None
        if expected_kid:
            for c_kid, c_key in content_keys:
                if c_kid == expected_kid.lower():
                    kid, key = c_kid, c_key
                    break

        if not kid and content_keys:
            kid, key = content_keys[0]

        if kid and key:
            pssh_cache[cache_key] = {'kid': kid, 'key': key}
            save_cache()
            log(f"Chiave DRM estratta con successo: {kid}:{key}", GREEN)
        return kid, key
    except Exception as e:
        log(f"Errore licenza DRM Widevine: {e}", RED)
        return None, None

def extract_channel(channel: Dict[str, Any], config: Dict[str, Any], token: str, tokens_payload: Dict[str, Any]) -> Dict[str, Any]:
    """Esegue l'estrazione completa per un singolo canale DAZN"""
    # Usiamo il proxy per tutte le richieste (Playback, License, MPD, ecc)
    proxy_dict = proxy_manager.get_dict()
    viewer_id = tokens_payload.get('viewerId', '')
    
    ch_name = channel.get('name', 'DAZN Channel')
    log(f"Richiesta Playback per '{ch_name}'...", CYAN)
    
    # Strategia: prova prima TV (FHD), se 403 fallback a web
    playback_data = None
    err = None
    used_platform = "tv"
    
    for attempt_platform in ["tv", "web"]:
        playback_url = build_playback_url(channel, config, viewer_id, platform=attempt_platform)
        playback_data, err = request_playback_info(playback_url, token, config, proxy_dict)
        
        if playback_data and not err:
            used_platform = attempt_platform
            if attempt_platform == "tv":
                log(f"📺 Modalità TV (FHD) riuscita per '{ch_name}'", GREEN)
            break
        
        if err and "403" in str(err) and attempt_platform == "tv":
            log(f"⚠️ TV bloccato (403), fallback a modalità Web per '{ch_name}'...", YELLOW)
            time.sleep(0.5)
            continue
        else:
            break  # Errore non-403, non riprovare
    
    if err or not playback_data:
        log(f"Errore Playback: {err}", RED)
        return {
            "id": channel.get("id"),
            "name": channel.get("name", "DAZN"),
            "status": "ERROR",
            "error": err
        }

    asset = playback_data.get("Asset", {})
    channel_name = asset.get("Title") or channel.get("name") or "DAZN Channel"
    details_list = playback_data.get("PlaybackDetails", [])

    if not details_list:
        log(f"Nessun PlaybackDetails disponibile nella risposta", RED)
        return {
            "id": channel.get("id"),
            "name": channel_name,
            "status": "NO_STREAMS",
            "error": "Nessun PlaybackDetails trovato"
        }

    # ── DEBUG: Logga TUTTE le opzioni PlaybackDetails disponibili ──
    res_priority = {'uhd': 4, 'fhd': 3, 'hd': 2, 'sd': 1}
    log(f"📊 PlaybackDetails disponibili: {len(details_list)} opzioni", CYAN)
    for idx, d in enumerate(details_list):
        d_res = d.get('Resolution', '?').lower()
        d_cdn = d.get('CdnName', '?')
        d_manifest = d.get('ManifestUrl', '')[:80]
        log(f"   [{idx}] Risoluzione={d_res.upper()} CDN={d_cdn} URL={d_manifest}...", DIM)

    # ── Strategia selezione: PRIMA filtra per risoluzione massima, POI per CDN preferito ──
    # Ordina per risoluzione decrescente
    sorted_details = sorted(details_list, key=lambda d: res_priority.get(d.get('Resolution', '').lower(), 0), reverse=True)
    best_resolution = sorted_details[0].get('Resolution', '').lower() if sorted_details else ''
    
    # Filtra solo i PlaybackDetails con la risoluzione migliore
    best_res_details = [d for d in sorted_details if d.get('Resolution', '').lower() == best_resolution]
    
    # Tra quelli con risoluzione migliore, scegli il CDN preferito
    chosen_detail = best_res_details[0]
    cdn_preferences = ['dct-fs-live', 'dcs-fs-live', 'dct-ak-live', 'dcs-ak-live', 'fs', 'ak']
    for pref in cdn_preferences:
        found_cdn = False
        for d in best_res_details:
            if pref in d.get('CdnName', '').lower():
                chosen_detail = d
                found_cdn = True
                break
        if found_cdn:
            break

    raw_manifest_url = chosen_detail.get("ManifestUrl", "")
    la_url = chosen_detail.get("LaUrl", "")
    cdn_token = chosen_detail.get("CdnToken", {})
    resolution = chosen_detail.get("Resolution", "fhd")
    cdn_name = chosen_detail.get("CdnName", "")
    log(f"✅ Selezionato: Risoluzione={resolution.upper()} CDN={cdn_name}", GREEN)

    # Costruisci l'MPD URL completo componendo il parametro token nella query string
    token_name = cdn_token.get("Name", "dazn-token")
    token_val = cdn_token.get("Value", "")
    if token_name and token_val:
        sep = '&' if '?' in raw_manifest_url else '?'
        composed_manifest_url = f"{raw_manifest_url}{sep}{token_name}={token_val}"
    else:
        composed_manifest_url = raw_manifest_url

    # Scarica MPD per estrarre la PSSH Widevine
    s = create_session(proxy_dict)
    dazn_headers = config.get("dazn", {}).get("headers", {})
    ua = dazn_headers.get("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36")
    mpd_headers = {
        "Origin": "https://www.dazn.com",
        "Referer": "https://www.dazn.com/",
        "User-Agent": ua,
        "sec-ch-ua": dazn_headers.get("sec-ch-ua", '"Chromium";v="152", "Not?A_Brand";v="24", "Google Chrome";v="152"'),
        "sec-ch-ua-mobile": dazn_headers.get("sec-ch-ua-mobile", "?0"),
        "sec-ch-ua-platform": dazn_headers.get("sec-ch-ua-platform", '"Windows"')
    }
    if token_name and token_val:
        mpd_headers["Cookie"] = f"{token_name}={token_val}"

    try:
        r_mpd = s.get(composed_manifest_url, headers=mpd_headers, timeout=25)
        mpd_text = r_mpd.text if r_mpd.ok else ""
    except Exception as e:
        log(f"Errore download manifesto MPD: {e}", RED)
        mpd_text = ""

    kid, key = None, None
    actual_resolution = "unknown"
    max_bandwidth = 0
    if mpd_text:
        try:
            root = ET.fromstring(mpd_text)
            for aset in root.findall('.//{urn:mpeg:dash:schema:mpd:2011}AdaptationSet'):
                if aset.get('contentType') == 'video':
                    max_h = 0
                    max_fps = 25
                    for rep in aset.findall('{urn:mpeg:dash:schema:mpd:2011}Representation'):
                        h = rep.get('height')
                        fps = rep.get('frameRate')
                        bw = rep.get('bandwidth')
                        if h and h.isdigit():
                            if int(h) >= max_h:
                                max_h = int(h)
                                if fps and fps.isdigit():
                                    max_fps = int(fps)
                        if bw and bw.isdigit() and int(bw) > max_bandwidth:
                            max_bandwidth = int(bw)
                    if max_h > 0:
                        actual_resolution = f"{max_h}p{max_fps if max_fps > 25 else ''}"
                    break
        except Exception:
            pass

        bw_str = f" @ {max_bandwidth // 1000} kbps" if max_bandwidth else ""
        log(f"Risoluzione stream: {actual_resolution}{bw_str} (Profilo API: {resolution.upper()})", GREEN if "1080" in actual_resolution else YELLOW)

        pssh_b64, expected_kid = extract_widevine_pssh(mpd_text)
        if pssh_b64 and la_url:
            kid, key = get_keys_wvd(
                pssh_b64=pssh_b64,
                asset_id=channel.get("id", asset.get("Id", "")),
                la_url=la_url,
                config=config,
                tokens_payload=tokens_payload,
                expected_kid=expected_kid,
                proxy_dict=proxy_dict
            )

    # Prepara comandi di riproduzione mpv ed ffplay (con Cookie per autorizzare tutti i frammenti .m4s DASH)
    cookie_hdr = f"{token_name}={token_val}" if (token_name and token_val) else ""
    mpv_cmd = f'mpv "{composed_manifest_url}" --user-agent="{ua}"'
    if cookie_hdr:
        mpv_cmd += f' --http-header-fields="Cookie: {cookie_hdr}"'
    if kid and key:
        mpv_cmd += f' --demuxer-lavf-o="decryption_key={kid}:{key}"'
        ffplay_cmd = f'ffplay -user_agent "{ua}"'
        if cookie_hdr:
            ffplay_cmd += f' -headers "Cookie: {cookie_hdr}"'
        ffplay_cmd += f' -cenc_decryption_key {key} "{composed_manifest_url}"'
    else:
        ffplay_cmd = f'ffplay -user_agent "{ua}"'
        if cookie_hdr:
            ffplay_cmd += f' -headers "Cookie: {cookie_hdr}"'
        ffplay_cmd += f' "{composed_manifest_url}"'

    result_entry = {
        "id": channel.get("id", asset.get("Id")),
        "name": channel_name,
        "proxy_manifest": f"https://addictedbytheproject.nl:10443/play/{channel.get('id', asset.get('Id'))}/stream.mpd",
        "clear_key": f"{kid}:{key}" if (kid and key) else None,
        "epg": channel.get("epg"),
        "event_id": asset.get("EventId", ""),
        "status": "OK" if (kid and key) else ("MANIFEST_ONLY" if composed_manifest_url else "ERROR"),
        "manifest_url": composed_manifest_url,
        "base_manifest_url": raw_manifest_url,
        "cdn_name": cdn_name,
        "resolution": resolution,
        "actual_resolution": actual_resolution,
        "cdn_token": cdn_token,
        "license_url": la_url,
        "kid": kid,
        "key": key,
        "key_string": f"{kid}:{key}" if (kid and key) else None,
        "cookie_header": f"Cookie: {cookie_hdr}" if cookie_hdr else None,
        "mpv_command": mpv_cmd,
        "ffplay_command": ffplay_cmd,
        "extracted_at": datetime.now().isoformat()
    }
    return result_entry

# ── Funzione Principale di Estrazione e Salvataggio ────────────────
def extract_and_save_all(cli_token: Optional[str] = None) -> Dict[str, Any]:
    """Estrae tutti i canali configurati, risolve le chiavi DRM e aggiorna JSON & M3U"""
    config = load_config()
    load_cache()
    proxy_manager.load(config_proxy=config.get('dazn', {}).get('proxy'))

    token = get_token(config, cli_token)
    if not token:
        log("ERRORE CRITICO: Nessun token DAZN disponibile! Configuralo in dazn_config.json", RED)
        return {}

    _, _, tokens_payload = check_token_status(token)
    channels = config.get('dazn', {}).get('channels', [])
    if not channels:
        log("Nessun canale configurato in dazn_config.json", YELLOW)
        return {}

    log(f"🚀 Avvio estrazione di {len(channels)} canali DAZN...", CYAN)
    results = []
    m3u_lines = ["#EXTM3U x-tvg-url=\"\""]
    found = 0

    first_ffplay_cmd = None
    first_mpv_cmd = None

    for i, ch in enumerate(channels, 1):
        name = ch.get('name', ch.get('id', f'Canale {i}'))
        print(f"  [{i:2d}/{len(channels)}] {name:<25}", end=" ", flush=True)

        res = extract_channel(ch, config, token, tokens_payload)
        results.append(res)
        # Delay tra richieste per evitare rate-limiting CloudFront (403)
        if i < len(channels):
            time.sleep(1.5)

        if res.get("status") == "OK":
            kid = res.get("kid", "")
            key = res.get("key", "")
            manifest = res.get("manifest_url", "")
            ua = config.get("dazn", {}).get("headers", {}).get("user-agent", "")

            if not first_ffplay_cmd:
                first_ffplay_cmd = res.get("ffplay_command")
                first_mpv_cmd = res.get("mpv_command")

            # Aggiungi voce alla playlist M3U compatibile Kodi
            m3u_lines.append(f'#EXTINF:-1 tvg-id="{res.get("id")}" tvg-name="{res.get("name")}" group-title="DAZN",{res.get("name")}')
            m3u_lines.append('#KODIPROP:inputstream=inputstream.adaptive')
            m3u_lines.append('#KODIPROP:inputstream.adaptive.manifest_type=mpd')
            m3u_lines.append('#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha')
            m3u_lines.append(f'#KODIPROP:inputstream.adaptive.license_key={kid}:{key}')
            if ua:
                m3u_lines.append(f'#KODIPROP:inputstream.adaptive.stream_headers=User-Agent={ua}&Referer=https://www.dazn.com/')
            m3u_lines.append(manifest)

            print(f"{GREEN}✅ OK ({kid[:8]}...:{key[:8]}...){RESET}")
            found += 1
        elif res.get("status") == "MANIFEST_ONLY":
            m3u_lines.append(f'#EXTINF:-1 tvg-id="{res.get("id")}" tvg-name="{res.get("name")}" group-title="DAZN",{res.get("name")}')
            m3u_lines.append(res.get("manifest_url", ""))
            print(f"{YELLOW}⚠️  Solo Manifest{RESET}")
        else:
            print(f"{RED}❌ Errore ({res.get('error', 'N/A')}){RESET}")

    output_data = {
        "updated_at": datetime.now().isoformat(),
        "total_channels": len(channels),
        "keys_found": found,
        "proxy_used": proxy_manager.get_dict(),
        "channels": results
    }

    # Salva dazn_channels.json
    with open(OUTPUT_JSON_FILE, 'w', encoding='utf-8') as f:
        json.dump(output_data, f, indent=2, ensure_ascii=False)

    # Salva dazn_playlist.m3u
    with open(PLAYLIST_FILE, 'w', encoding='utf-8') as f:
        f.write("\n".join(m3u_lines) + "\n")

    # Salva file batch pronti all'uso per ffplay e mpv
    if first_ffplay_cmd:
        ffplay_bat = os.path.join(SCRIPT_DIR, "play_ffplay.bat")
        with open(ffplay_bat, 'w', encoding='utf-8') as f:
            f.write(f"@echo off\n{first_ffplay_cmd}\npause\n")

    if first_mpv_cmd:
        mpv_bat = os.path.join(SCRIPT_DIR, "play_mpv.bat")
        with open(mpv_bat, 'w', encoding='utf-8') as f:
            f.write(f"@echo off\n{first_mpv_cmd}\npause\n")

    log(f"💾 Salvato con successo in: {BOLD}{OUTPUT_JSON_FILE}{RESET} ({found}/{len(channels)} canali)", GREEN)
    log(f"📋 Playlist M3U aggiornata in: {BOLD}{PLAYLIST_FILE}{RESET}", GREEN)
    if first_ffplay_cmd:
        log(f"🎬 Launcher generati: play_ffplay.bat e play_mpv.bat", CYAN)
        log(f"▶️  Comando FFplay:\n{first_ffplay_cmd}", YELLOW)
    return output_data

# Cache globale per evitare di scaricare l'EPG ad ogni ciclo
_epg_cache_data = None
_epg_cache_time = 0

def fetch_live_events_from_epg() -> List[Dict[str, Any]]:
    """Scarica l'EPG ufficiale DAZN e restituisce una lista di canali degli eventi attualmente in diretta"""
    global _epg_cache_data, _epg_cache_time
    from datetime import timezone
    url = "https://epg.discovery.indazn.com/eu/v5/epgWithDatesRange"
    target_date = datetime.now(timezone.utc).replace(tzinfo=None) # Manteniamo timezone-naive per compatibilità con il resto dello script
    date_str = target_date.strftime("%Y-%m-%d")
    end_date_str = (target_date + timedelta(days=1)).strftime("%Y-%m-%d")
    
    params = {
        "country": "it",
        "languageCode": "it",
        "openBrowse": "true",
        "timeZoneOffset": "120",
        "brand": "dazn",
        "startDate": date_str,
        "endDate": end_date_str,
    }
    
    live_channels = []
    
    if _epg_cache_data and (time.time() - _epg_cache_time) < 14400:
        data = _epg_cache_data
    else:
        proxy_dict = proxy_manager.get_dict()
        try:
            s = create_session(proxy_dict)
            epg_headers = {
                "Origin": "https://www.dazn.com",
                "Referer": "https://www.dazn.com/",
                "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
                "sec-ch-ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
                "sec-ch-ua-mobile": "?0",
                "sec-ch-ua-platform": '"Windows"'
            }
            r = s.get(url, params=params, headers=epg_headers, timeout=15)
            if not r.ok:
                log(f"Errore download EPG DAZN: HTTP {r.status_code}", RED)
                return []
            data = r.json()
            _epg_cache_data = data
            _epg_cache_time = time.time()
            log(f"📥 EPG scaricato con successo dal server DAZN (valido per 4 ore).", GREEN)
        except Exception as e:
            log(f"Errore eccezione EPG: {e}", RED)
            return []
            
    tiles = data.get("Tiles", [])
    now_utc = target_date
    
    live_count = 0
    for tile in tiles:
        if tile.get("Type") == "Live":
            start_str = tile.get("Start", "")
            end_str = tile.get("End", "")
            if start_str and end_str:
                try:
                    start_dt = datetime.strptime(start_str.replace("Z", "").split(".")[0], "%Y-%m-%dT%H:%M:%S")
                    end_dt = datetime.strptime(end_str.replace("Z", "").split(".")[0], "%Y-%m-%dT%H:%M:%S")
                    
                    # Includi eventi che iniziano nei prossimi 5 minuti o sono attualmente in corso
                    if (start_dt - timedelta(minutes=5)) <= now_utc <= end_dt:
                        asset_id = tile.get("AssetId")
                        title = tile.get("Title", f"Event {asset_id}")
                        
                        # Salta i canali lineari perpetui (anno 3000) per evitare duplicati se già nel config
                        if end_dt.year >= 2100:
                            continue
                            
                        if asset_id:
                            live_count += 1
                            live_channels.append({
                                "id": asset_id,
                                "name": title,
                                "playback_url": "",
                                "epg": {
                                    "start": start_dt.isoformat(),
                                    "end": end_dt.isoformat()
                                }
                            })
                except Exception as parse_e:
                    pass
                    
    log(f"🔎 EPG Check: {len(tiles)} programmi in palinsesto. Trovati {live_count} eventi speciali LIVE attivi.", DIM)
    return live_channels

# ── Loop Automatico ───────────────────────────────────────────────
def run_auto_loop(interval_hours: int = 3, cli_token: Optional[str] = None) -> None:
    """Esegue l'estrazione all'avvio e ripete periodicamente"""
    print_banner()
    config = load_config()
    proxy_manager.load(config_proxy=config.get('dazn', {}).get('proxy'))

    # Il loop si sveglia ogni minuto per controllare l'EPG
    check_interval = 60
    
    # Conserviamo la lista originale dei canali statici
    base_channels = config.get('dazn', {}).get('channels', []).copy()
    last_known_live_ids = set()
    force_extraction = True

    # Avvia automaticamente il proxy DAZN locale / remoto
    ensure_proxy_running(port=10443)

    while True:
        try:
            # 1. Recupera eventi Live dall'EPG
            live_events = fetch_live_events_from_epg()
            current_live_ids = {ev["id"] for ev in live_events}
            
            # Se ci sono nuovi eventi o ne sono finiti alcuni, aggiorna la configurazione
            if current_live_ids != last_known_live_ids:
                if len(current_live_ids) > len(last_known_live_ids):
                    log(f"🚨 Trovati {len(current_live_ids) - len(last_known_live_ids)} NUOVI EVENTI LIVE nell'EPG!", MAGENTA)
                
                # Unisci canali base + canali live correnti
                new_channel_list = base_channels.copy()
                # Aggiungi solo i live che non sono già tra i canali base
                base_ids = {ch.get("id") for ch in base_channels}
                for ev in live_events:
                    if ev["id"] not in base_ids:
                        new_channel_list.append(ev)
                        
                config['dazn']['channels'] = new_channel_list
                save_config(config) # Opzionale, salva per reference
                last_known_live_ids = current_live_ids
                force_extraction = True

            # 2. Controllo Scadenza Token (CdnToken)
            min_expiry = 999999
            if os.path.exists(OUTPUT_JSON_FILE):
                try:
                    with open(OUTPUT_JSON_FILE, 'r', encoding='utf-8') as f:
                        old_out = json.load(f)
                        for ch in old_out.get("channels", []):
                            cdn_tok = ch.get("cdn_token", {})
                            exp = cdn_tok.get("ExpirySeconds")
                            if exp and isinstance(exp, (int, float)) and exp > 0:
                                min_expiry = min(min_expiry, int(exp))
                except Exception:
                    pass
            
            # Se la scadenza è vicina (< 80 secondi), forza l'estrazione
            if min_expiry < 80:
                log(f"⚠️ CdnToken in scadenza ({min_expiry}s). Richiedo rinnovo chiavi.", YELLOW)
                force_extraction = True

            # 3. Estrazione solo se necessario
            if force_extraction:
                print(f"\n{BLUE}{'═' * 70}{RESET}")
                log("🔄 CICLO DI ESTRAZIONE CHIAVI & MANIFEST (Live + Lineari)", CYAN)
                print(f"{BLUE}{'═' * 70}{RESET}")

                output_data = extract_and_save_all(cli_token=cli_token)
                
                # Ricalcola scadenza dal nuovo output
                new_min_expiry = 3 * 3600
                if output_data and "channels" in output_data:
                    for ch in output_data["channels"]:
                        cdn_tok = ch.get("cdn_token", {})
                        exp = cdn_tok.get("ExpirySeconds")
                        if exp and isinstance(exp, (int, float)) and exp > 0:
                            if exp < new_min_expiry:
                                new_min_expiry = int(exp)
                
                log(f"✨ Estrazione completata! Il token scadrà tra circa {new_min_expiry}s.", GREEN)
                force_extraction = False
            else:
                log(f"💤 Nessun nuovo evento live e token valido (>80s).", DIM)

            # Controllo liveness del proxy
            if not is_port_open(10443):
                ensure_proxy_running(port=10443)

            # Il bot dorme sempre per check_interval (60s)
            time.sleep(check_interval)

        except KeyboardInterrupt:
            print(f"\n\n{YELLOW}🛑 Bot arrestato dall'utente.{RESET}\n")
            stop_proxy()
            break
        except Exception as e:
            log(f"Errore durante il ciclo: {e}", RED)
            time.sleep(30)

# ── Main Entrypoint ───────────────────────────────────────────────
def main():
    parser = argparse.ArgumentParser(description="DAZN - DRM Widevine Stream & Key Extractor")
    parser.add_argument("--once", action="store_true", help="Esegui una sola volta ed esci")
    parser.add_argument("--loop", action="store_true", help="Esegui in loop continuo con intervallo programmato")
    parser.add_argument("--token", type=str, default=None, help="Specifica o aggiorna il JWT Bearer Token")
    parser.add_argument("--asset", type=str, default=None, help="Estrai solo uno specifico AssetId")
    parser.add_argument("--url", type=str, default=None, help="Usa uno specifico URL Playback personalizzato")
    parser.add_argument("--test-proxy", action="store_true", help="Testa tutti i proxy configurati ed esci")
    parser.add_argument("--clear-cache", action="store_true", help="Cancella la cache delle chiavi pssh_cache.json")
    args = parser.parse_args()

    if args.clear_cache:
        if os.path.exists(CACHE_FILE):
            os.remove(CACHE_FILE)
            log("Cache pssh_cache.json eliminata con successo!", GREEN)
        return

    if args.test_proxy:
        cfg = load_config()
        proxy_manager.load(config_proxy=cfg.get('dazn', {}).get('proxy'))
        log(f"Test di {proxy_manager.count()} proxy in corso...", CYAN)
        results = proxy_manager.test_all()
        for p, ok, ip_msg, dur in results:
            col = GREEN if ok else RED
            log(f"  {p} -> {col}{ip_msg}{RESET} ({dur}ms)")
        return

    if args.asset or args.url:
        print_banner()
        cfg = load_config()
        token = get_token(cfg, args.token)
        _, _, tokens_payload = check_token_status(token)
        target_ch = {
            "id": args.asset or "custom_asset",
            "name": f"Asset {args.asset or 'Custom'}",
            "playback_url": args.url or ""
        }
        res = extract_channel(target_ch, cfg, token, tokens_payload)
        print("\n" + json.dumps(res, indent=2, ensure_ascii=False))
        return

    if args.once:
        print_banner()
        extract_and_save_all(cli_token=args.token)
        return

    # Di default se eseguito interattivamente, avvia l'estrazione singola o il loop
    run_auto_loop(cli_token=args.token)

if __name__ == '__main__':
    main()
