#!/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")
PROXY_DATA_FILE  = os.path.join(SCRIPT_DIR, "dazn_proxy_data.json")
PLAYLIST_FILE    = os.path.join(SCRIPT_DIR, "dazn_playlist.m3u")
PLAYLIST_PROXY_FILE = os.path.join(SCRIPT_DIR, "dazn_playlist_proxy.m3u")
PROXY_SCRIPT_FILE= os.path.join(SCRIPT_DIR, "dazn_proxy.py")

# ── Set globale degli asset bloccati per Eligibility (non ritentare) ──
_eligibility_blocked_ids: set = set()

# ── 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 = 8443) -> 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": "en-GB,en;q=0.9,it;q=0.8",
        "cache-control": "no-cache",
        "content-type": "application/json",
        "origin": "https://www.dazn.com",
        "referer": "https://www.dazn.com/",
        "sec-ch-ua": '"Google Chrome";v="143", "Chromium";v="143", "Not A(Brand";v="24"',
        "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/143.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/143.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 per bypassare CloudFront WAF
        s = create_session(proxy_dict)
        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:
        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()
            }
            with open(TOKEN_FILE, 'w', encoding='utf-8') as f:
                json.dump(t_info, f, indent=2, ensure_ascii=False)
        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) -> Any:
    """Crea una sessione HTTP ottimizzata con impersonazione Chrome per bypassare CloudFront"""
    if HAS_CURL_CFFI:
        s = cffi_requests.Session(impersonate="chrome120")
    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) -> str:
    """Costruisce l'endpoint Playback con SessionId e timestamp freschi"""
    dazn_cfg = config.get('dazn', {})
    asset_id = channel.get('id', '4hqcjx5zv91f1bdx71io4bnrl')
    app_version = dazn_cfg.get('app_version', '0.147.8')
    player_id = dazn_cfg.get('player_id', '@dazn/peng-html5-core/web/web')
    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": app_version,
        "DrmType": "WIDEVINE",
        "Format": "MPEG-DASH",
        "PlayerId": player_id,
        "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 personalizzati e bypass WAF"""
    dazn_cfg = config.get('dazn', {})
    payload = decode_jwt_payload(token)
    user_id = (payload.get("user") if payload else None) or dazn_cfg.get("daznid", "790f7b77-25f4-4cd8-bc68-15c600c9eb89")
    device_id = (payload.get("deviceId") if payload else None) or dazn_cfg.get("device_id", "")
        
    headers = {
        "Authorization": f"Bearer {token}",
        "Origin": "https://www.dazn.com",
        "Referer": "https://www.dazn.com/",
        "sec-ch-ua": dazn_cfg.get("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("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-dazn-device": device_id,
        "x-daznid": user_id
    }

    s = create_session(proxy_dict)
    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", "790f7b77-25f4-4cd8-bc68-15c600c9eb89")
        device_id = tokens_payload.get("deviceId") or dazn_cfg.get("device_id", "")
        
        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-dazn-device": device_id,
            "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], max_retries: int = 8) -> Dict[str, Any]:
    """
    Esegue l'estrazione completa per un singolo canale DAZN con retry intelligente:
    se un flusso restituisce 'Solo Manifest' (nessuna chiave DRM al primo colpo),
    riprova automaticamente esplorando tutti i CDN disponibili e ripetendo la richiesta
    finché la chiave non viene estratta con successo.
    """
    proxy_dict = proxy_manager.get_dict()
    viewer_id = tokens_payload.get('viewerId', '')
    channel_name = channel.get('name', 'DAZN Channel')
    
    last_composed_manifest_url = ""
    last_raw_manifest_url = ""
    last_cdn_name = ""
    last_resolution = "fhd"
    last_actual_resolution = "unknown"
    last_cdn_token = {}
    last_la_url = ""
    last_cookie_hdr = ""
    last_mpv_cmd = ""
    last_ffplay_cmd = ""
    last_asset = {}
    kid, key = None, None

    for attempt in range(1, max_retries + 1):
        playback_url = build_playback_url(channel, config, viewer_id)
        if attempt == 1:
            log(f"Richiesta Playback per '{channel_name}'...", CYAN)
        else:
            log(f"🔁 [{channel_name}] Tentativo {attempt}/{max_retries}: riprovo estrazione Playback & DRM...", YELLOW)

        playback_data, err = request_playback_info(playback_url, token, config, proxy_dict)
        if err or not playback_data:
            # Eligibility not allowed: marca per esclusione totale dal JSON e non ritentare
            if err and "Eligibility" in str(err):
                log(f"⛔ [{channel_name}] Eligibility not allowed — evento escluso dal JSON.", YELLOW)
                _eligibility_blocked_ids.add(channel.get("id"))
                return {
                    "id": channel.get("id"),
                    "name": channel_name,
                    "status": "ELIGIBILITY_BLOCKED",
                    "error": err
                }
            # Errori definitivi (Pin, Entitlement, Geo) — non insistere
            if err and any(term in str(err) for term in ("Pin", "Entitlement", "Geo", "not valid")):
                log(f"Errore Playback non recuperabile: {err}", RED)
                return {
                    "id": channel.get("id"),
                    "name": channel_name,
                    "status": "ERROR",
                    "error": err
                }
            # Concurrency e altri errori temporanei — riprova
            if err:
                log(f"⚠️ [{channel_name}] Errore temporaneo: {err} — riprovo...", YELLOW)
            if attempt < 2:
                time.sleep(1.5)
                continue
            log(f"Errore Playback: {err}", RED)
            return {
                "id": channel.get("id"),
                "name": channel_name,
                "status": "ERROR",
                "error": err
            }

        asset = playback_data.get("Asset", {})
        if asset.get("Title"):
            channel_name = asset.get("Title")
        details_list = playback_data.get("PlaybackDetails", [])
        last_asset = asset

        if not details_list:
            if attempt < 2:
                time.sleep(1.5)
                continue
            log(f"Nessun PlaybackDetails disponibile nella risposta", RED)
            return {
                "id": channel.get("id"),
                "name": channel_name,
                "status": "NO_STREAMS",
                "error": "Nessun PlaybackDetails trovato"
            }

        # Ordina i CDN secondo le preferenze: Fastly e 'ac' prioritari, poi Akamai
        pref_order = ['dcs-fs-live', 'dct-fs-live', 'dca-fs-live', 'fs', 'dcs-ac-live', 'dct-ac-live', 'dca-ac-live', 'ac', 'dct-ak-live', 'dcs-ak-live', 'ak']
        def cdn_score(d):
            c_name = d.get('CdnName', '').lower()
            for idx, pref in enumerate(pref_order):
                if pref in c_name:
                    return idx
            return 999

        sorted_details = sorted(details_list, key=cdn_score)

        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")

        for d in sorted_details:
            raw_manifest_url = d.get("ManifestUrl", "")
            la_url = d.get("LaUrl", "")
            cdn_token = d.get("CdnToken", {})
            resolution = d.get("Resolution", "fhd")
            cdn_name = d.get("CdnName", "")

            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

            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=15)
                mpd_text = r_mpd.text if r_mpd.ok else ""
            except Exception:
                mpd_text = ""

            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

                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
                    )

            # Salva gli ultimi dettagli validi per il fallback
            last_composed_manifest_url = composed_manifest_url
            last_raw_manifest_url = raw_manifest_url
            last_cdn_name = cdn_name
            last_resolution = resolution
            last_actual_resolution = actual_resolution
            last_cdn_token = cdn_token
            last_la_url = la_url

            cookie_hdr = f"{token_name}={token_val}" if (token_name and token_val) else ""
            last_cookie_hdr = cookie_hdr
            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}"'
            last_mpv_cmd = mpv_cmd
            last_ffplay_cmd = ffplay_cmd

            if kid and key:
                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)
                break

        if kid and key:
            break

        if attempt < max_retries:
            log(f"⚠️ [{channel_name}] Solo Manifest (chiavi DRM non ancora pronte). Nuovo tentativo {attempt + 1}/{max_retries} tra 2s...", YELLOW)
            time.sleep(2)

    status = "OK" if (kid and key) else ("MANIFEST_ONLY" if last_composed_manifest_url else "ERROR")
    result_entry = {
        "logo": channel.get("logo", ""),
        "event_id": last_asset.get("EventId", ""),
        "proxy_manifest": f"https://addictedbytheproject.nl:8443/play/{channel.get('id', last_asset.get('Id'))}/stream.mpd",
        "name": channel_name,
        "clear_key": f"{kid}:{key}" if (kid and key) else None,
        "epg": channel.get("epg"),
    }
    # Campi interni necessari per il funzionamento del proxy e delle playlist (non salvati nel JSON)
    result_entry["_internal"] = {
        "id": channel.get("id", last_asset.get("Id")),
        "status": status,
        "manifest_url": last_composed_manifest_url,
        "base_manifest_url": last_raw_manifest_url,
        "cdn_name": last_cdn_name,
        "resolution": last_resolution,
        "actual_resolution": last_actual_resolution,
        "cdn_token": last_cdn_token,
        "license_url": last_la_url,
        "kid": kid,
        "key": key,
        "key_string": f"{kid}:{key}" if (kid and key) else None,
        "cookie_header": f"Cookie: {last_cookie_hdr}" if last_cookie_hdr else None,
        "mpv_cmd": last_mpv_cmd,
        "mpv_command": last_mpv_cmd,
        "ffplay_command": last_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, channels: Optional[List[Dict[str, Any]]] = None, include_events: bool = True) -> Dict[str, Any]:
    """Estrae tutti i canali/eventi 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)

    # Se i canali non vengono passati, usa la configurazione base + eventi EPG
    if channels is None:
        base_channels = config.get('dazn', {}).get('channels', []).copy()
        if include_events:
            live_events = fetch_live_events_from_epg()
            base_ids = {ch.get("id") for ch in base_channels}
            for ev in live_events:
                if ev["id"] not in base_ids:
                    base_channels.append(ev)
        channels = base_channels

    if not channels:
        log("Nessun canale o evento da estrarre", YELLOW)
        return {}

    # Filtra gli asset bloccati per Eligibility (non ritentarli)
    channels = [ch for ch in channels if ch.get("id") not in _eligibility_blocked_ids]

    log(f"🚀 Avvio estrazione di {len(channels)} canali/eventi DAZN...", CYAN)
    results = []
    m3u_lines = ["#EXTM3U x-tvg-url=\"\""]
    found = 0

    first_ffplay_cmd = None
    first_mpv_cmd = None

    # Helper: ottieni status da result (può essere al top level per errori, o in _internal per successi)
    def _get_status(r):
        return r.get("status") or r.get("_internal", {}).get("status")
    def _get_internal(r, key, default=""):
        return r.get("_internal", {}).get(key, default)

    for i, ch in enumerate(channels, 1):
        name = ch.get('name', ch.get('id', f'Canale {i}'))
        print(f"  [{i:2d}/{len(channels)}] {name:<35}", end=" ", flush=True)

        res = extract_channel(ch, config, token, tokens_payload)
        results.append(res)

        st = _get_status(res)
        if st == "ELIGIBILITY_BLOCKED":
            print(f"{YELLOW}⛔ Eligibility not allowed (escluso){RESET}")
        elif st == "OK":
            kid = _get_internal(res, "kid", "")
            key = _get_internal(res, "key", "")
            print(f"{GREEN}✅ OK ({kid[:8]}...:{key[:8]}...){RESET}")
            found += 1
        elif st == "MANIFEST_ONLY":
            print(f"{YELLOW}⚠️  Solo Manifest (in coda per sweep di retry){RESET}")
        else:
            print(f"{RED}❌ Errore ({res.get('error', 'N/A')}){RESET}")

    # Sweep dedicato per canali rimasti a 'Solo Manifest': riprova ad estrarre le chiavi
    manifest_only_indices = [idx for idx, r in enumerate(results) if _get_status(r) == "MANIFEST_ONLY"]
    if manifest_only_indices:
        log(f"🔄 Rilevati {len(manifest_only_indices)} canali con 'Solo Manifest'. Avvio sweep di retry dedicato...", CYAN)
        time.sleep(2)
        for idx in manifest_only_indices:
            target_ch = channels[idx]
            ch_title = target_ch.get('name', 'Evento')
            log(f"🔁 Secondo tentativo di estrazione chiavi per '{ch_title}'...", YELLOW)
            retry_res = extract_channel(target_ch, config, token, tokens_payload, max_retries=6)
            if _get_status(retry_res) == "OK":
                results[idx] = retry_res
                found += 1
                log(f"✅ Chiave recuperata con successo per '{ch_title}': {_get_internal(retry_res, 'key_string')}", GREEN)

    # Sweep dedicato per canali con 'Concurrency max locations violation': usa account alternativi
    concurrency_indices = [
        idx for idx, r in enumerate(results)
        if r.get("error") and "Concurrency" in str(r.get("error", ""))
    ]
    if concurrency_indices:
        # Carica tutti gli account disponibili da accounts.txt
        accounts_file = os.path.join(os.path.dirname(__file__), 'accounts.txt')
        alt_accounts = []
        if os.path.exists(accounts_file):
            try:
                with open(accounts_file, 'r', encoding='utf-8') as f:
                    alt_accounts = [l.strip() for l in f if ':' in l and not l.startswith('#')]
            except Exception:
                pass

        if not alt_accounts:
            log(f"⚠️ {len(concurrency_indices)} canali bloccati per Concurrency ma nessun account alternativo in accounts.txt.", YELLOW)
        else:
            log(f"🔄 Sweep Concurrency: {len(concurrency_indices)} canali bloccati. Provo con account alternativi da accounts.txt...", CYAN)
            
            # Cicla gli account disponibili per i canali bloccati
            alt_token_cache = {}  # email -> (token, payload) — evita login multipli per lo stesso account
            account_idx = 0
            
            for idx in concurrency_indices:
                target_ch = channels[idx]
                ch_title = target_ch.get('name', 'Evento')
                
                # Prova fino a max 3 account diversi per canale
                resolved = False
                attempts_per_channel = min(3, len(alt_accounts))
                
                for _ in range(attempts_per_channel):
                    acct_line = alt_accounts[account_idx % len(alt_accounts)]
                    account_idx += 1
                    email, password = acct_line.split(':', 1)
                    
                    # Login (o usa token già ottenuto per questo account)
                    if email in alt_token_cache:
                        alt_token, alt_payload = alt_token_cache[email]
                    else:
                        log(f"🔑 [{ch_title}] Login con account alternativo {email}...", CYAN)
                        proxy_dict = proxy_manager.get_dict()
                        status, alt_token = dazn_login(email, password, proxy_dict)
                        if status != "SUCCESS" or not alt_token:
                            log(f"❌ Login fallito per {email}, provo il prossimo account...", RED)
                            alt_token_cache[email] = (None, {})
                            continue
                        _, _, alt_payload = check_token_status(alt_token)
                        alt_token_cache[email] = (alt_token, alt_payload)
                        log(f"✅ Login riuscito con {email}", GREEN)
                    
                    if not alt_token:
                        continue
                    
                    # Prova a estrarre il canale con il token alternativo
                    log(f"🔁 [{ch_title}] Retry con account {email}...", YELLOW)
                    retry_res = extract_channel(target_ch, config, alt_token, alt_payload, max_retries=3)
                    st = _get_status(retry_res)
                    
                    if st == "OK":
                        results[idx] = retry_res
                        found += 1
                        log(f"✅ [{ch_title}] Recuperato con successo via {email}: {_get_internal(retry_res, 'key_string')}", GREEN)
                        resolved = True
                        break
                    elif st == "MANIFEST_ONLY":
                        results[idx] = retry_res
                        log(f"⚠️ [{ch_title}] Solo Manifest via {email} (senza chiavi DRM)", YELLOW)
                        resolved = True
                        break
                    elif retry_res.get("error") and "Concurrency" in str(retry_res.get("error", "")):
                        log(f"⏳ [{ch_title}] Ancora Concurrency con {email}, provo un altro account...", DIM)
                    else:
                        results[idx] = retry_res
                        log(f"❌ [{ch_title}] Errore con {email}: {retry_res.get('error', 'N/A')}", RED)
                        break
                
                if not resolved:
                    log(f"⚠️ [{ch_title}] Non recuperabile con nessun account alternativo.", YELLOW)

    # Riepilogo finale Concurrency
    still_concurrency = sum(1 for r in results if r.get("error") and "Concurrency" in str(r.get("error", "")))
    if still_concurrency:
        log(f"⚠️ {still_concurrency} canali ancora bloccati per Concurrency dopo aver provato tutti gli account.", YELLOW)

    # Costruzione delle playlist M3U (Diretta CDN e Proxy Locale)
    m3u_lines = ["#EXTM3U x-tvg-url=\"\""]
    proxy_m3u_lines = ["#EXTM3U x-tvg-url=\"\""]
    ua = config.get("dazn", {}).get("headers", {}).get("user-agent", "")

    for ch, res in zip(channels, results):
        st = _get_status(res)
        if st in ("OK", "MANIFEST_ONLY"):
            kid = _get_internal(res, "kid", "")
            key = _get_internal(res, "key", "")
            manifest = _get_internal(res, "manifest_url", "")
            proxy_manifest = res.get("proxy_manifest", "")
            logo = res.get("logo") or ch.get("logo", "")
            logo_attr = f' tvg-logo="{logo}"' if logo else ""
            is_live_event = bool(ch.get("epg"))
            group_name = "DAZN Eventi Live" if is_live_event else "DAZN Canali"

            if not first_ffplay_cmd and st == "OK":
                first_ffplay_cmd = _get_internal(res, "ffplay_command")
                first_mpv_cmd = _get_internal(res, "mpv_command")

            # 1. Playlist M3U Diretta CDN (Widevine)
            token_val = _get_internal(res, "cdn_token", {}).get("Value", "") if isinstance(_get_internal(res, "cdn_token", {}), dict) else ""
            token_name = _get_internal(res, "cdn_token", {}).get("Name", "dazn-token") if isinstance(_get_internal(res, "cdn_token", {}), dict) else "dazn-token"
            cookie_hdr = f"&Cookie={token_name}={token_val}" if (token_name and token_val) else ""
            ch_id = _get_internal(res, "id", "")

            m3u_lines.append(f'#EXTINF:-1 tvg-id="{ch_id}" tvg-name="{res.get("name")}"{logo_attr} group-title="{group_name}",{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.manifest_update_parameter=full')
            m3u_lines.append('#KODIPROP:inputstream.adaptive.live_delay=8')
            m3u_lines.append('#KODIPROP:inputstream.adaptive.live_delay_type=fixed')
            if kid and key:
                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/{cookie_hdr}')
                m3u_lines.append(f'#EXTVLCOPT:http-user-agent={ua}')
                m3u_lines.append('#EXTVLCOPT:http-referrer=https://www.dazn.com/')
            m3u_lines.append(manifest)

            # 2. Playlist M3U Proxy Locale HTTPS (ClearKey decifrato)
            proxy_m3u_lines.append(f'#EXTINF:-1 tvg-id="{ch_id}" tvg-name="{res.get("name")}"{logo_attr} group-title="{group_name}",{res.get("name")}')
            proxy_m3u_lines.append('#KODIPROP:inputstream=inputstream.adaptive')
            proxy_m3u_lines.append('#KODIPROP:inputstream.adaptive.manifest_type=mpd')
            if kid and key:
                proxy_m3u_lines.append('#KODIPROP:inputstream.adaptive.license_type=clearkey')
                proxy_m3u_lines.append(f'#KODIPROP:inputstream.adaptive.license_key={kid}:{key}')
            proxy_m3u_lines.append(proxy_manifest)

    # Filtra i risultati: escludi ELIGIBILITY_BLOCKED completamente
    clean_channels = []   # Per dazn_channels.json (solo i 6 campi utente)
    proxy_channels = []   # Per dazn_proxy_data.json (dati completi per il proxy)
    for r in results:
        st = _get_status(r)
        if st in ("OK", "MANIFEST_ONLY"):
            internal = r.get("_internal", {})
            # JSON pulito: solo i 6 campi richiesti dall'utente
            clean_channels.append({
                "logo": r.get("logo", ""),
                "event_id": r.get("event_id", ""),
                "proxy_manifest": r.get("proxy_manifest", ""),
                "name": r.get("name", ""),
                "clear_key": r.get("clear_key"),
                "epg": r.get("epg"),
            })
            # Dati completi per il proxy
            proxy_entry = {
                "logo": r.get("logo", ""),
                "event_id": r.get("event_id", ""),
                "proxy_manifest": r.get("proxy_manifest", ""),
                "name": r.get("name", ""),
                "clear_key": r.get("clear_key"),
                "epg": r.get("epg"),
            }
            proxy_entry.update(internal)
            proxy_channels.append(proxy_entry)
    
    # I canali falliti escludono quelli bloccati per Eligibility
    failed_channels = [r for r in results if _get_status(r) not in ("OK", "MANIFEST_ONLY", "ELIGIBILITY_BLOCKED")]

    eligibility_count = sum(1 for r in results if _get_status(r) == "ELIGIBILITY_BLOCKED")
    if eligibility_count:
        log(f"⛔ {eligibility_count} eventi esclusi per Eligibility not allowed (non saranno ritentati).", YELLOW)

    now_ts = datetime.now().isoformat()

    # Salva dazn_channels.json (JSON pulito con solo i 6 campi)
    output_data = {
        "updated_at": now_ts,
        "total_channels": len(channels),
        "keys_found": found,
        "channels": clean_channels,
    }
    with open(OUTPUT_JSON_FILE, 'w', encoding='utf-8') as f:
        json.dump(output_data, f, indent=2, ensure_ascii=False)

    # Salva dazn_proxy_data.json (dati completi per il proxy)
    proxy_output = {
        "updated_at": now_ts,
        "total_channels": len(channels),
        "keys_found": found,
        "proxy_used": proxy_manager.get_dict(),
        "channels": proxy_channels,
        "failed_channels": failed_channels
    }
    with open(PROXY_DATA_FILE, 'w', encoding='utf-8') as f:
        json.dump(proxy_output, f, indent=2, ensure_ascii=False)

    # Salva dazn_playlist.m3u (Diretto CDN)
    with open(PLAYLIST_FILE, 'w', encoding='utf-8') as f:
        f.write("\n".join(m3u_lines) + "\n")

    # Salva dazn_playlist_proxy.m3u (Proxy Locale HTTPS)
    with open(PLAYLIST_PROXY_FILE, 'w', encoding='utf-8') as f:
        f.write("\n".join(proxy_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 attivi)", 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"
    now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
    
    # Finestra temporale ieri -> domani per coprire fusi orari ed eventi a cavallo della mezzanotte
    start_date_str = (now_utc - timedelta(days=1)).strftime("%Y-%m-%d")
    end_date_str = (now_utc + timedelta(days=1)).strftime("%Y-%m-%d")
    
    # Calcola fuso orario locale in minuti
    try:
        offset_sec = datetime.now().astimezone().utcoffset().total_seconds()
        tz_offset = str(int(offset_sec // 60))
    except Exception:
        tz_offset = "120"

    params = {
        "country": "it",
        "languageCode": "it",
        "openBrowse": "true",
        "timeZoneOffset": tz_offset,
        "brand": "dazn",
        "startDate": start_date_str,
        "endDate": end_date_str,
    }
    
    live_channels = []
    
    # Cache valida per 5 minuti (300 secondi)
    if _epg_cache_data and (time.time() - _epg_cache_time) < 300:
        data = _epg_cache_data
    else:
        proxy_dict = proxy_manager.get_dict()
        try:
            r = requests.get(url, params=params, proxies=proxy_dict, 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 (aggiornato ogni 5 min).", GREEN)
        except Exception as e:
            log(f"Errore eccezione EPG: {e}", RED)
            return []
            
    tiles = data.get("Tiles", [])
    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:
                try:
                    start_dt = datetime.strptime(start_str.replace("Z", "").split(".")[0], "%Y-%m-%dT%H:%M:%S")
                    if end_str:
                        end_dt = datetime.strptime(end_str.replace("Z", "").split(".")[0], "%Y-%m-%dT%H:%M:%S")
                    else:
                        end_dt = start_dt + timedelta(hours=3)
                        
                    # Salta i canali lineari perpetui (anno >= 2100)
                    if end_dt.year >= 2100:
                        continue

                    # Includi eventi che iniziano nei prossimi 15 minuti o attualmente in corso (fino a 15 minuti post-evento)
                    if (start_dt - timedelta(minutes=15)) <= now_utc <= (end_dt + timedelta(minutes=15)):
                        asset_id = tile.get("AssetId")
                        if not asset_id:
                            continue

                        title = tile.get("Title", f"Event {asset_id}")
                        img_id = tile.get("Image", {}).get("Id") if isinstance(tile.get("Image"), dict) else None
                        logo_url = f"https://image.discovery.indazn.com/eu/v2/eu/image/?id={img_id}" if img_id else ""
                        
                        live_count += 1
                        live_channels.append({
                            "id": asset_id,
                            "name": title,
                            "logo": logo_url,
                            "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, include_events: bool = True) -> None:
    """Esegue l'estrazione all'avvio e ripete periodicamente monitorando EPG e canali"""
    print_banner()
    config = load_config()
    proxy_manager.load(config_proxy=config.get('dazn', {}).get('proxy'))

    # Leggi intervallo programmato da config (default 3 ore)
    refresh_hours = config.get('loop', {}).get('refresh_interval_hours', interval_hours)
    refresh_seconds = refresh_hours * 3600

    # Il loop si sveglia ogni 60s per controllare variazioni nell'EPG
    check_interval = 60
    
    # Conserviamo la lista originale dei canali statici
    base_channels = config.get('dazn', {}).get('channels', []).copy()
    last_known_live_ids = set()
    last_full_extraction_time = 0.0
    force_extraction = True

    # Avvia automaticamente il proxy DAZN locale / remoto
    ensure_proxy_running(port=8443)

    while True:
        try:
            now = time.time()

            # 1. Recupera eventi Live dall'EPG se abilitato
            if include_events:
                live_events = fetch_live_events_from_epg()
                current_live_ids = {ev["id"] for ev in live_events}
            else:
                live_events = []
                current_live_ids = set()
            
            # 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)
                elif len(current_live_ids) < len(last_known_live_ids):
                    log(f"ℹ️  {len(last_known_live_ids) - len(current_live_ids)} eventi live sono terminati.", DIM)
                
                # Unisci canali base + canali live correnti
                new_channel_list = base_channels.copy()
                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
                last_known_live_ids = current_live_ids
                force_extraction = True

            # 2. Rinnovo dinamico CDN gestito on-demand dal proxy (dazn_proxy.py)
            # Le chiavi ClearKey sono permanenti. Il rinnovo dei token CDN a 200s viene
            # gestito on-demand dal proxy solo per i canali attivamente riprodotti, evitando
            # di saturare tutti gli account DAZN con estrazioni globali continue ogni 3 minuti.

            # Controllo Intervallo Programmato (es. ogni 3 ore da dazn_config.json)
            if (now - last_full_extraction_time) >= refresh_seconds:
                if last_full_extraction_time > 0:
                    log(f"⏰ Intervallo programmato ({refresh_hours}h) trascorso. Rinnovo generale canali...", CYAN)
                force_extraction = True

            # 3. Estrazione solo all'avvio, su nuovi/finiti eventi, o alla scadenza programmata
            if force_extraction:
                print(f"\n{BLUE}{'═' * 70}{RESET}")
                log("🔄 CICLO DI ESTRAZIONE CHIAVI & MANIFEST (Live + Lineari)", CYAN)
                print(f"{BLUE}{'═' * 70}{RESET}")

                target_channels = config['dazn']['channels']
                output_data = extract_and_save_all(cli_token=cli_token, channels=target_channels, include_events=False)
                last_full_extraction_time = time.time()
                force_extraction = False
                log(f"✨ Estrazione completata! Monitoraggio EPG attivo (prossimo controllo tra {check_interval}s).", GREEN)
            else:
                log(f"💤 Nessun nuovo evento live nell'EPG. In attesa (prossimo controllo tra {check_interval}s)...", DIM)

            # Controllo liveness del proxy
            if not is_port_open(8443):
                ensure_proxy_running(port=8443)

            # 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")
    parser.add_argument("--no-events", action="store_true", help="Non includere eventi live dall'EPG (solo canali statici)")
    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

    include_events = not args.no_events

    if args.once:
        print_banner()
        extract_and_save_all(cli_token=args.token, include_events=include_events)
        return

    # Di default se eseguito interattivamente o con --loop, avvia il loop automatico
    run_auto_loop(cli_token=args.token, include_events=include_events)

if __name__ == '__main__':
    main()

