#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
local_proxy.py – mini server HTTP locale per servire manifest HLS a Kodi.

Risolve tre problemi del CDN ai.the-sunmoon.site:

  1. DECOY SEGMENTS: il manifest mescola .ts reali con .png/.jpg falsi.
     Il proxy filtra le coppie #EXTINF + URL decoy.

  2. CHIAVE AES-128 RELATIVA: URI="/key/..." riscritta in
     URI="http://127.0.0.1:19223/tor-key/key/..." e la chiave viene
     fetchata via Tor.

  3. AUTO-REFRESH: aggiorna il manifest ogni _REFRESH_SEC secondi.
"""

import re
import threading
import time
import logging
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urljoin, urlparse, quote, unquote, parse_qs
from typing import Optional, Callable

log = logging.getLogger('local_proxy')

# ── Collega local_proxy al sistema di log di Kodi ───────────────────────────
# Senza questo il logger scrive nel vuoto e il debug è impossibile.
try:
    import xbmc as _xbmc

    class _KodiHandler(logging.Handler):
        _LEVEL_MAP = {
            logging.DEBUG:    _xbmc.LOGDEBUG,
            logging.INFO:     _xbmc.LOGINFO,
            logging.WARNING:  _xbmc.LOGWARNING,
            logging.ERROR:    _xbmc.LOGERROR,
            logging.CRITICAL: _xbmc.LOGFATAL,
        }

        def emit(self, record):
            kodi_level = self._LEVEL_MAP.get(record.levelno, _xbmc.LOGDEBUG)
            _xbmc.log('[local_proxy] ' + self.format(record), kodi_level)

    _kodi_handler = _KodiHandler()
    _kodi_handler.setFormatter(logging.Formatter('%(levelname)s %(message)s'))
    log.addHandler(_kodi_handler)
    log.setLevel(logging.DEBUG)
    log.propagate = False
except ImportError:
    # Fuori da Kodi (unit test, CLI): logging standard
    logging.basicConfig(level=logging.DEBUG,
                        format='[local_proxy] %(levelname)s %(message)s')

_PORT        = 19223
_REFRESH_SEC = 4

_REAL_EXTS  = ('.ts', '.aac', '.mp4', '.fmp4', '.m4s')
_DECOY_EXTS = ('.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp',
               '.css', '.js', '.html', '.htm', '.ico', '.txt')

_server_instance = None
_server_lock     = threading.Lock()
_refresh_thread  = None
_generation      = 0   # incrementato ad ogni serve_manifest; i thread vecchi si autotermina

_state = {
    'manifest'   : b'',
    'fetch_url'  : '',
    'base_url'   : '',
    'referer'    : '',
    'get_func'   : None,
    'key_cache'  : {},   # path_str -> url_str  (prima del fetch)
                         # path_str -> bytes     (dopo il fetch)
    'seg_cache'  : {},   # url_str  -> bytes     (segmenti pre-fetchati)
    'running'    : False,
    'generation' : 0,    # copia locale della generazione corrente
}


import re as _re2
# Segmenti reali camuffati da immagine che il CDN usa in pratica:
#   seg_{server}_{hash}     es. seg_nfs_b19032e9
#   seg_nfs_{hash}          stessa struttura, server=nfs
# La regex è volutamente larga: qualsiasi seg_ con almeno un _ e almeno 4 char dopo
_REAL_DISGUISED_RE = _re2.compile(r'seg_[a-z0-9]+_[0-9a-f]{4,}', _re2.IGNORECASE)


def _url_is_decoy(url):
    low  = url.lower()
    path = low.split('?')[0]
    # seg_{server}_{hash} = segmento reale camuffato da immagine → NON decoy
    if _REAL_DISGUISED_RE.search(low):
        return False
    if any(path.endswith(ext) for ext in _DECOY_EXTS):
        return True
    if 'response-content-type=image%2f' in low or 'response-content-type=image/' in low:
        return True
    for ext in _DECOY_EXTS:
        if (ext + '"') in low or (ext + '%22') in low or (ext + "'") in low:
            return True
    return False


def _count_real_segments(m3u8_text):
    """Conta quanti segmenti .ts reali ci sono nel manifest (esclude decoy)."""
    count = 0
    for line in m3u8_text.splitlines():
        s = line.strip()
        if not s or s.startswith('#'):
            continue
        if not _url_is_decoy(s) and any(ext in s.lower() for ext in _REAL_EXTS):
            count += 1
    return count


def _rewrite_key_uri(line, base_url):
    def replace_uri(m):
        uri = m.group(1)
        if not uri.startswith('http'):
            uri = urljoin(base_url, uri)
        parsed   = urlparse(uri)
        key_path = parsed.path
        if parsed.query:
            key_path += '?' + parsed.query

        # Il manifest usa URI relativi (/key/...) → il dominio è quello del CDN
        # (ai.the-sunmoon.site). Il redirect key2→key.keylocking.ru nel JS del
        # browser si applica solo ai manifest che usano key2.keylocking.ru
        # esplicitamente come URI — in quel caso usiamo il dominio canonico.
        if 'key2.keylocking.ru' in uri:
            canonical_url = uri.replace('key2.keylocking.ru', 'key.keylocking.ru')
        else:
            canonical_url = uri  # mantieni l'URL già risolto (base_url + path)

        _state['key_cache'][parsed.path] = canonical_url   # salva URL canonico
        return 'URI="http://127.0.0.1:{}/tor-key{}"'.format(_PORT, parsed.path)
    return re.sub(r'URI="([^"]+)"', replace_uri, line)


def _process_manifest(raw, base_url):
    lines = raw.splitlines()
    out   = []
    i     = 0
    while i < len(lines):
        line = lines[i]

        if line.startswith('#EXT-X-KEY'):
            out.append(_rewrite_key_uri(line, base_url))
            i += 1
            continue

        if line.startswith('#EXTINF'):
            extinf_line = line
            i += 1
            while i < len(lines) and lines[i].startswith('#EXT-X-BYTERANGE'):
                extinf_line += '\n' + lines[i]
                i += 1
            if i >= len(lines):
                break
            seg_url = lines[i].strip()
            i += 1
            if seg_url and not seg_url.startswith('#'):
                if not seg_url.startswith('http'):
                    seg_url = urljoin(base_url, seg_url)
                if _url_is_decoy(seg_url):
                    log.debug('Decoy filtrato: %s', seg_url[:100])
                    continue
                # Riscrivi il segmento reale attraverso il proxy locale:
                # FFmpegDirect non può fetchare Firebase/CDN direttamente (403),
                # il proxy lo fetcherà via Tor e lo passerà al player.
                seg_url = 'http://127.0.0.1:{}/seg?url={}'.format(
                    _PORT, quote(seg_url, safe=''))
            out.append(extinf_line)
            out.append(seg_url)
            continue

        out.append(line)
        i += 1

    return '\n'.join(out).encode('utf-8')


def _do_refresh(my_generation=None):
    fn   = _state.get('get_func')
    url  = _state.get('fetch_url')
    ref  = _state.get('referer')
    base = _state.get('base_url') or url
    if not (fn and url):
        return
    # Verifica generazione: il thread deve confrontare la SUA generazione
    # (my_generation ricevuta al lancio) con quella globale corrente.
    # NON usare _state['generation'] perché serve_manifest() la aggiorna
    # subito → un vecchio thread leggerebbe il nuovo valore e non uscirebbe mai.
    if my_generation is not None and my_generation != _generation:
        log.debug('_do_refresh: generazione %d obsoleta (corrente %d), uscita',
                  my_generation, _generation)
        return
    try:
        resp = fn(url, referer=ref, timeout=12)
        if resp and '#EXTM3U' in resp.text:
            # Ricontrolla dopo la fetch (potrebbe essere passato del tempo)
            if my_generation is not None and my_generation != _generation:
                return
            old_bytes = {k: v for k, v in _state['key_cache'].items()
                         if k.endswith(':bytes')}
            _state['manifest'] = _process_manifest(resp.text, base)
            _state['key_cache'].update(old_bytes)
            _prefetch_keys()
            log.debug('Manifest aggiornato – %d byte', len(_state['manifest']))
    except Exception as exc:
        log.warning('Refresh fallito: %s', exc)


def _refresh_loop(my_generation):
    """Gira finché la generazione corrente coincide con la propria.
    Quando serve_manifest() avvia un nuovo canale, incrementa _generation
    e tutti i thread precedenti escono al prossimo ciclo senza bisogno di join."""
    global _generation
    while _generation == my_generation:
        time.sleep(_REFRESH_SEC)
        if _generation != my_generation:
            break
        _do_refresh(my_generation)
    log.debug('Thread refresh generazione %d terminato (corrente: %d)',
              my_generation, _generation)


def _fetch_segment(seg_url):
    """
    Fetcha un segmento .ts e lo restituisce come bytes.

    Strategia per CDN:
    - Firebase Storage / AWS S3 (URL pre-firmati): prova DIRETTO prima.
      Gli exit node Tor sono bannati da questi CDN, ma gli URL firmati
      sono pubblicamente accessibili senza autenticazione.
    - Altri URL: prova Tor prima (bypass blocchi AGCOM), poi diretto.
    """
    _DIRECT_FIRST_HOSTS = (
        'firebasestorage.googleapis.com',
        'storage.googleapis.com',
        's3.amazonaws.com',
        '.s3.',             # bucket.s3.region.amazonaws.com
        'cgdream-general',  # S3 custom domain visto nei log
    )
    use_direct_first = any(h in seg_url for h in _DIRECT_FIRST_HOSTS)

    ref = _state.get('referer', '')
    ua  = ('Mozilla/5.0 (Linux; Android 10; K) '
           'AppleWebKit/537.36 (KHTML, like Gecko) '
           'Chrome/114.0.0.0 Mobile Safari/537.36')
    headers = {'Referer': ref, 'Origin': ref.rstrip('/'), 'User-Agent': ua}

    def _try_direct():
        try:
            import requests as req
            r = req.get(seg_url, headers=headers, timeout=15)
            r.raise_for_status()
            log.debug('Segmento diretto: %d byte (%s)', len(r.content), seg_url[:80])
            return r.content
        except Exception as exc:
            log.warning('Segment diretto fallito: %s', exc)
            return None

    def _try_tor():
        try:
            import requests
            from tor_manager import TOR_PROXY
            r = requests.get(seg_url,
                             proxies={'http': TOR_PROXY, 'https': TOR_PROXY},
                             headers=headers, timeout=15)
            r.raise_for_status()
            log.debug('Segmento via Tor: %d byte (%s)', len(r.content), seg_url[:80])
            return r.content
        except Exception as exc:
            log.warning('Segment Tor fallito: %s', exc)
            return None

    if use_direct_first:
        log.debug('Segmento Firebase/S3 – tentativo diretto: %s', seg_url[:80])
        data = _try_direct() or _try_tor()
    else:
        data = _try_tor() or _try_direct()

    if not data:
        log.error('Segmento non disponibile: %s', seg_url[:120])
    return data


def _fetch_key_raw(real_path, key_url):
    """
    Fetcha la chiave AES e la restituisce come bytes.

    Ordine di tentativo:
    1. Connessione DIRETTA (IP del client) — funziona se l'IP è già in whitelist
       tramite il /verify reCAPTCHA del sito. Il key server restituisce la chiave
       CORRETTA solo agli IP whitelistati. Con Tor exit node restituisce byte fake.
    2. Via Tor come fallback (utile per bypass AGCOM, ma chiave potrebbe essere fake).
    """
    ref = _state.get('referer', '')
    ua  = ('Mozilla/5.0 (Linux; Android 10; K) '
           'AppleWebKit/537.36 (KHTML, like Gecko) '
           'Chrome/114.0.0.0 Mobile Safari/537.36')
    headers = {'Referer': ref, 'Origin': ref.rstrip('/'), 'User-Agent': ua}

    # Tentativo 1: DIRETTO (IP del client → whitelist reCAPTCHA)
    try:
        import requests as req
        r = req.get(key_url, headers=headers, timeout=8)
        r.raise_for_status()
        log.info('Chiave AES diretta: %d byte', len(r.content))
        return r.content
    except Exception as exc:
        log.warning('Key fetch diretto fallito: %s', exc)

    # Tentativo 2: via Tor
    try:
        import requests
        from tor_manager import TOR_PROXY
        r2 = requests.get(key_url,
                          proxies={'http': TOR_PROXY, 'https': TOR_PROXY},
                          headers=headers, timeout=10)
        r2.raise_for_status()
        log.info('Chiave AES via Tor: %d byte', len(r2.content))
        return r2.content
    except Exception as exc2:
        log.error('Key fetch fallito: %s', exc2)
        return None


class _ProxyHandler(BaseHTTPRequestHandler):

    def do_HEAD(self):
        """Risponde ai HEAD senza body – evita il 501 nel log."""
        path = self.path.split('?')[0]
        if path == '/stream.m3u8':
            content = _state['manifest']
            if not content:
                self.send_response(503); self.end_headers(); return
            self.send_response(200)
            self.send_header('Content-Type', 'application/x-mpegURL')
            self.send_header('Content-Length', str(len(content)))
            self.send_header('Cache-Control', 'no-cache, no-store')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
        else:
            self.send_response(404); self.end_headers()

    def do_GET(self):
        # Preserva query string per /seg, strip per gli altri path
        raw_path  = self.path
        path      = raw_path.split('?')[0]

        if path == '/stream.m3u8':
            content = _state['manifest']
            if not content:
                self.send_response(503); self.end_headers(); return
            self.send_response(200)
            self.send_header('Content-Type', 'application/x-mpegURL')
            self.send_header('Content-Length', str(len(content)))
            self.send_header('Cache-Control', 'no-cache, no-store')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            self.wfile.write(content)
            return

        if path == '/seg':
            # Segmento .ts proxato — controlla prima la cache pre-fetch
            qs_str  = raw_path.split('?', 1)[1] if '?' in raw_path else ''
            params  = parse_qs(qs_str)
            seg_url = params.get('url', [''])[0]
            if not seg_url:
                self.send_response(400); self.end_headers(); return

            # Cerca in cache (pre-fetchato in background)
            cached_seg = _state['seg_cache'].get(seg_url)
            if cached_seg:
                log.debug('Segmento da cache: %d byte', len(cached_seg))
                seg_bytes = cached_seg
                del _state['seg_cache'][seg_url]  # libera memoria dopo uso
            else:
                seg_bytes = _fetch_segment(seg_url)

            if seg_bytes:
                # Pre-decritta il segmento AES-128 se necessario.
                # FFmpegDirect riceve TS plaintext → nessun problema di chiave sbagliata.
                seg_bytes, was_decrypted = _decrypt_segment_if_needed(seg_bytes, seg_url)
                if was_decrypted:
                    log.info('Segment decrittato, servo plaintext TS')
                self.send_response(200)
                self.send_header('Content-Type', 'video/MP2T')
                self.send_header('Content-Length', str(len(seg_bytes)))
                self.send_header('Access-Control-Allow-Origin', '*')
                self.end_headers()
                self.wfile.write(seg_bytes)
            else:
                log.error('Segmento non disponibile: %s', seg_url[:120])
                self.send_response(502); self.end_headers()
            return

        if path.startswith('/tor-key/'):
            real_path = path[len('/tor-key'):]   # es. /key/premium51/5913102
            key_url   = _state['key_cache'].get(real_path)
            if not key_url or not isinstance(key_url, str):
                base    = _state.get('base_url', '')
                key_url = urljoin(base, real_path) if base else ''
            if not key_url:
                self.send_response(404); self.end_headers(); return

            # ── Attendi fino a 1.5 s che il pre-fetch metta la chiave in cache ──
            # FFmpegDirect ha un timeout interno di ~2.2 s; aspettare qui evita
            # un fetch on-demand lento che farebbe scadere quel timeout.
            _WAIT_DEADLINE = 1.5   # secondi
            _POLL_INTERVAL = 0.05  # secondi tra i poll
            deadline = time.time() + _WAIT_DEADLINE
            while time.time() < deadline:
                if _state['key_cache'].get(real_path + ':bytes'):
                    break
                time.sleep(_POLL_INTERVAL)

            # Controlla cache bytes (popolata dal pre-fetch o dall'attesa sopra)
            cached = _state['key_cache'].get(real_path + ':bytes')
            if cached:
                key_bytes = cached
            else:
                log.warning('Chiave non in cache dopo %.1fs di attesa, '
                            'fetch on-demand per %s', _WAIT_DEADLINE, real_path)
                key_bytes = _fetch_key_raw(real_path, key_url)
                if key_bytes:
                    _state['key_cache'][real_path + ':bytes'] = key_bytes

            if key_bytes:
                self.send_response(200)
                self.send_header('Content-Type', 'application/octet-stream')
                self.send_header('Content-Length', str(len(key_bytes)))
                self.send_header('Access-Control-Allow-Origin', '*')
                self.end_headers()
                self.wfile.write(key_bytes)
            else:
                log.error('Chiave non disponibile per %s', real_path)
                self.send_response(404); self.end_headers()
            return

        self.send_response(404); self.end_headers()

    def log_message(self, fmt, *args):
        log.debug('HTTP ' + fmt, *args)


def _extract_first_real_segments(manifest_bytes, n=2):
    """Estrae i primi N URL di segmenti reali (non decoy) dal manifest processato.
    Il manifest è già stato processato da _process_manifest, quindi i segmenti
    reali sono già nella forma http://127.0.0.1:PORT/seg?url=..."""
    urls = []
    for line in manifest_bytes.decode('utf-8', errors='ignore').splitlines():
        line = line.strip()
        if line.startswith('http://127.0.0.1') and '/seg?url=' in line:
            urls.append(line)
            if len(urls) >= n:
                break
    return urls


def _prefetch_segments(seg_proxy_urls, my_generation):
    """
    Pre-fetcha i primi segmenti .ts in background prima che FFmpegDirect li
    richieda. Firebase/S3 possono richiedere 3-4s; FFmpegDirect ha un timeout
    di ~2.2s → senza prefetch fallisce sempre al primo segmento.
    """
    from urllib.parse import unquote, urlparse as _up, parse_qs as _pqs
    for proxy_url in seg_proxy_urls:
        if _generation != my_generation:
            break
        # Estrai l'URL originale dal parametro ?url=
        qs = proxy_url.split('?', 1)[1] if '?' in proxy_url else ''
        params = {}
        for part in qs.split('&'):
            if '=' in part:
                k, v = part.split('=', 1)
                params[k] = v
        seg_url = unquote(params.get('url', ''))
        if not seg_url:
            continue
        if seg_url in _state['seg_cache']:
            log.debug('Segmento già in cache: %s', seg_url[:80])
            continue
        log.info('Pre-fetch segmento: %s', seg_url[:80])
        data = _fetch_segment(seg_url)
        if _generation != my_generation:
            break
        if data:
            _state['seg_cache'][seg_url] = data
            log.info('Segmento pre-fetchato: %d byte', len(data))
        else:
            log.warning('Pre-fetch segmento fallito: %s', seg_url[:80])





def _parse_iv(iv_hex):
    """
    Converte l'IV da stringa a 16 byte (128 bit), gestendo tutti i formati
    che i CDN HLS usano in pratica:

      • '0x00000000000000000000000000000000'  → hex con prefisso 0x
      • '00000000000000000000000000000000'    → hex senza prefisso
      • '5913335'                             → numero decimale (sequenza segmento)
      • '0x5A3B1C'                            → numero hex breve (stesso significato)

    Il risultato è sempre zero-padded a 16 byte (big-endian), come previsto
    dallo standard HLS §4.3.2.4.
    """
    s = iv_hex.strip()

    # Rimuovi eventuale prefisso 0x/0X
    has_prefix = s.lower().startswith('0x')
    if has_prefix:
        s = s[2:]

    # Rimuovi caratteri non hex
    s_clean = re.sub(r'[^0-9a-fA-F]', '', s)

    # Caso 1: stringa hex pura (lunghezza originale > 8 char o ha prefisso 0x)
    # Caso 2: numero decimale (nessun prefisso 0x e caratteri non-hex presenti,
    #          oppure la stringa originale è un numero decimale puro)
    original_no_prefix = iv_hex.strip()
    if has_prefix:
        original_no_prefix = original_no_prefix[2:]

    # Se la stringa (senza prefisso) contiene solo cifre decimali E
    # non ha mai contenuto lettere a-f → è un numero decimale
    is_decimal = re.match(r'^[0-9]+$', original_no_prefix.strip()) and not has_prefix

    if is_decimal:
        # Interpreta come intero decimale → converti a 16 byte big-endian
        try:
            iv_int   = int(original_no_prefix.strip())
            iv_bytes = iv_int.to_bytes(16, 'big')
            log.debug('IV decimale %s → %s', original_no_prefix.strip(),
                      iv_bytes.hex())
            return iv_bytes
        except Exception as exc:
            log.error('IV decimale non convertibile (%s): %s', iv_hex, exc)
            return None
    else:
        # Interpreta come stringa esadecimale, zero-pad a 32 cifre = 16 byte
        try:
            padded   = s_clean.zfill(32)[-32:]   # prendi solo le ultime 32 cifre
            iv_bytes = bytes.fromhex(padded)
            log.debug('IV hex %s → %s', iv_hex.strip(), iv_bytes.hex())
            return iv_bytes
        except Exception as exc:
            log.error('IV hex non convertibile (%s): %s', iv_hex, exc)
            return None


def _aes128_cbc_decrypt_raw(data, key_bytes, iv_bytes):
    """
    Decritta AES-128-CBC via ctypes (OpenSSL o Windows BCrypt).
    Accetta iv_bytes gia' come bytes (16 byte). ~7ms per 2MB.
    """
    if not data or len(data) % 16 != 0:
        return None

    import ctypes

    # ── OpenSSL ────────────────────────────────────────────────────────
    for libname in ('libcrypto-3-x64.dll', 'libcrypto-1_1-x64.dll',
                    'libcrypto.dll', 'libcrypto.so.3',
                    'libcrypto.so.1.1', 'libcrypto.so'):
        try:
            lib = ctypes.CDLL(libname)
            lib.EVP_CIPHER_CTX_new.restype   = ctypes.c_void_p
            lib.EVP_CIPHER_CTX_free.argtypes = [ctypes.c_void_p]
            lib.EVP_aes_128_cbc.restype      = ctypes.c_void_p
            lib.EVP_DecryptInit_ex.argtypes  = [
                ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p,
                ctypes.c_char_p, ctypes.c_char_p]
            lib.EVP_DecryptInit_ex.restype   = ctypes.c_int
            lib.EVP_DecryptUpdate.argtypes   = [
                ctypes.c_void_p, ctypes.c_char_p,
                ctypes.POINTER(ctypes.c_int), ctypes.c_char_p, ctypes.c_int]
            lib.EVP_DecryptUpdate.restype    = ctypes.c_int
            lib.EVP_DecryptFinal_ex.argtypes = [
                ctypes.c_void_p, ctypes.c_char_p, ctypes.POINTER(ctypes.c_int)]
            lib.EVP_DecryptFinal_ex.restype  = ctypes.c_int
            lib.EVP_CIPHER_CTX_set_padding.argtypes = [ctypes.c_void_p, ctypes.c_int]
            ctx    = lib.EVP_CIPHER_CTX_new()
            cipher = lib.EVP_aes_128_cbc()
            lib.EVP_DecryptInit_ex(ctx, cipher, None, key_bytes, iv_bytes)
            lib.EVP_CIPHER_CTX_set_padding(ctx, 0)
            out_buf = ctypes.create_string_buffer(len(data) + 16)
            out_len = ctypes.c_int(0)
            lib.EVP_DecryptUpdate(ctx, out_buf, ctypes.byref(out_len), data, len(data))
            total   = out_len.value
            fin_len = ctypes.c_int(0)
            lib.EVP_DecryptFinal_ex(
                ctx,
                ctypes.cast(ctypes.addressof(out_buf) + total, ctypes.c_char_p),
                ctypes.byref(fin_len))
            total += fin_len.value
            lib.EVP_CIPHER_CTX_free(ctx)
            result = bytes(out_buf.raw[:total])
            if result:
                pad = result[-1]
                if 1 <= pad <= 16:
                    result = result[:-pad]
            log.debug('AES decrypt: OpenSSL %s (%d byte)', libname, len(result))
            return result
        except Exception:
            continue

    # ── Windows BCrypt ─────────────────────────────────────────────────
    try:
        bcrypt = ctypes.windll.bcrypt
        STATUS_SUCCESS = 0
        alg = ctypes.c_void_p()
        kh  = ctypes.c_void_p()
        bcrypt.BCryptOpenAlgorithmProvider(
            ctypes.byref(alg), 'AES\0'.encode('utf-16-le'), None, 0)
        bcrypt.BCryptSetProperty(
            alg, 'ChainingMode\0'.encode('utf-16-le'),
            'ChainingModeCBC\0'.encode('utf-16-le'), 32, 0)
        obj_sz = ctypes.c_ulong(0)
        res_sz = ctypes.c_ulong(0)
        bcrypt.BCryptGetProperty(alg, 'ObjectLength\0'.encode('utf-16-le'),
                                 ctypes.byref(obj_sz), 4, ctypes.byref(res_sz), 0)
        key_obj = ctypes.create_string_buffer(obj_sz.value)
        bcrypt.BCryptGenerateSymmetricKey(
            alg, ctypes.byref(kh), key_obj, obj_sz.value,
            key_bytes, len(key_bytes), 0)
        iv_buf  = ctypes.create_string_buffer(iv_bytes, 16)
        out_buf = ctypes.create_string_buffer(len(data))
        out_len = ctypes.c_ulong(0)
        status  = bcrypt.BCryptDecrypt(
            kh, data, len(data), None, iv_buf, 16,
            out_buf, len(data), ctypes.byref(out_len), 0)
        bcrypt.BCryptDestroyKey(kh)
        bcrypt.BCryptCloseAlgorithmProvider(alg, 0)
        if status == STATUS_SUCCESS:
            result = bytes(out_buf.raw[:out_len.value])
            if result:
                pad = result[-1]
                if 1 <= pad <= 16:
                    result = result[:-pad]
            log.debug('AES decrypt: Windows BCrypt (%d byte)', len(result))
            return result
    except AttributeError:
        pass
    except Exception as exc:
        log.warning('BCrypt fallito: %s', exc)

    log.error('AES decrypt: nessun backend ctypes disponibile')
    return None


def _aes128_cbc_decrypt(data, key_bytes, iv_hex):
    """Wrapper: converte iv_hex stringa -> bytes e delega a _aes128_cbc_decrypt_raw."""
    iv_bytes = _parse_iv(iv_hex)
    if iv_bytes is None:
        log.error('IV non valido: %r', iv_hex)
        return None
    return _aes128_cbc_decrypt_raw(data, key_bytes, iv_bytes)


def _is_valid_ts(data):
    """Verifica che i dati inizino con byte di sincronizzazione TS (0x47)."""
    return bool(data and len(data) >= 188 and data[0] == 0x47)


def _hls_create_iv(sn):
    """
    Replica esatta di hls.js createInitializationVector(segmentNumber).

    Da hls.mjs (riga 7070-7076):
        function createInitializationVector(segmentNumber) {
          const uint8View = new Uint8Array(16);
          for (let i = 12; i < 16; i++) {
            uint8View[i] = segmentNumber >> 8 * (15 - i) & 0xff;
          }
          return uint8View;
        }

    Il numero di sequenza e' scritto solo nei byte 12-15 (ultimi 4, big-endian).
    I byte 0-11 sono sempre zero.
    """
    iv = bytearray(16)
    for i in range(12, 16):
        iv[i] = (sn >> (8 * (15 - i))) & 0xff
    return bytes(iv)


def _extract_iv_and_seq_from_manifest(seg_proxy_url=None):
    """
    Scansiona il manifest processato e restituisce (method, iv_hex_or_None, sn).

    Replica la logica di hls.js (Fragment.getDecryptData):
    - traccia #EXT-X-MEDIA-SEQUENCE come startSN
    - assegna sn = startSN + indice_frammento  (come fa currentSN in hls.js)
    - per ogni segmento tiene il #EXT-X-KEY corrente (method + IV)
    - se seg_proxy_url e' fornito, restituisce i dati per QUEL segmento specifico
      (matching sull'URL); altrimenti restituisce i dati dell'ultimo segmento

    Due casi HLS (par. 4.3.2.4):
      1. IV esplicito:   #EXT-X-KEY:METHOD=AES-128,...,IV=0xABCD...
      2. IV implicito:   #EXT-X-KEY:METHOD=AES-128,...   (nessun IV=)
         -> IV = _hls_create_iv(sn)  (replica createInitializationVector di hls.js)
    """
    manifest_text = _state.get('manifest', b'').decode('utf-8', errors='ignore')
    lines = manifest_text.splitlines()

    current_sn     = 0   # equivalente a currentSN in hls.js parser
    current_method = None
    current_iv_hex = None

    # Risultato da restituire: dati del segmento richiesto (o dell'ultimo)
    result_method  = None
    result_iv_hex  = None
    result_sn      = 0

    for line in lines:
        line = line.strip()

        # #EXT-X-MEDIA-SEQUENCE -> inizializza currentSN (= startSN in hls.js)
        if line.startswith('#EXT-X-MEDIA-SEQUENCE:'):
            try:
                current_sn = int(line.split(':', 1)[1])
            except ValueError:
                pass
            continue

        # #EXT-X-KEY -> aggiorna method e IV correnti
        if line.startswith('#EXT-X-KEY'):
            m_method = re.search(r'METHOD=([A-Z0-9-]+)', line)
            current_method = m_method.group(1) if m_method else None
            m_iv = re.search(r'IV=([0-9a-fA-Fx]+)', line)
            current_iv_hex = m_iv.group(1) if m_iv else None
            continue

        # Riga URL segmento (non commento, non vuota)
        if not line or line.startswith('#'):
            continue

        # Questo e' un segmento: sn corrente = current_sn (come in hls.js)
        result_method  = current_method
        result_iv_hex  = current_iv_hex
        result_sn      = current_sn

        # Se cerchiamo un segmento specifico, controlla il match sull'URL
        if seg_proxy_url and seg_proxy_url in line:
            break   # trovato: esci subito con i dati di questo sn

        current_sn += 1   # incrementa come fa hls.js dopo aver assegnato il frammento

    return result_method, result_iv_hex, result_sn


def _decrypt_segment_if_needed(seg_data, seg_url=None):
    """
    Se il manifest ha una chiave AES-128 per questo segmento, decritta i dati.
    Restituisce (data, decrypted) dove decrypted=True se la decrittazione e' avvenuta.

    Gestisce correttamente:
    - IV esplicito nel manifest (IV=0x...)
    - IV implicito: usa _hls_create_iv(sn), replica esatta di hls.js
      createInitializationVector() — il sn e' quello SPECIFICO del segmento
      richiesto, non dell'ultimo nel manifest
    - IV espresso come numero decimale (alcuni CDN non-standard)
    - Fallback con sn-1 e sn+1 per assorbire off-by-one dovuti ai decoy filtrati
    """
    if not seg_data:
        return seg_data, False

    # Se gia' TS valido, niente da fare
    if _is_valid_ts(seg_data):
        return seg_data, False

    # Recupera metodo, IV e sn per il segmento specifico dal manifest
    # Passa seg_url per il match preciso (altrimenti usa l'ultimo segmento)
    method, iv_hex, sn = _extract_iv_and_seq_from_manifest(seg_url)

    if method != 'AES-128':
        log.debug('Decrypt: metodo crittografia non AES-128 (%s), skip', method)
        return seg_data, False

    # Recupera chiave dalla cache (la prima chiave a 16 byte disponibile)
    key_bytes = None
    for k, v in _state.get('key_cache', {}).items():
        if k.endswith(':bytes') and isinstance(v, (bytes, bytearray)) and len(v) == 16:
            key_bytes = v
            break

    if not key_bytes:
        log.warning('Decrypt: nessuna chiave AES in cache, decrittazione impossibile')
        return seg_data, False

    # ── Costruisci IV da usare ────────────────────────────────────────────
    if iv_hex:
        # IV esplicito nel manifest: usa _parse_iv per normalizzarlo
        iv_bytes_explicit = _parse_iv(iv_hex)
        if iv_bytes_explicit is None:
            log.error('IV esplicito non valido: %r', iv_hex)
            return seg_data, False
        log.debug('Decrypt sn=%d, IV esplicito=%s (%d byte)',
                  sn, iv_hex[:32], len(seg_data))
        decrypted = _aes128_cbc_decrypt_raw(seg_data, key_bytes, iv_bytes_explicit)
        if decrypted and _is_valid_ts(decrypted):
            log.info('Segmento decrittato OK (IV esplicito): %d->%d byte, sn=%d',
                     len(seg_data), len(decrypted), sn)
            return decrypted, True
    else:
        # IV implicito: replica esatta di hls.js createInitializationVector(sn)
        # Prova sn, sn-1, sn+1 per assorbire eventuali off-by-one
        # dovuti ai segmenti decoy filtrati dal manifest processato
        for candidate_sn in (sn, sn - 1, sn + 1):
            if candidate_sn < 0:
                continue
            iv_bytes_impl = _hls_create_iv(candidate_sn)
            log.debug('Decrypt IV implicito sn=%d (candidate=%d): %s, %d byte',
                      sn, candidate_sn, iv_bytes_impl.hex(), len(seg_data))
            decrypted = _aes128_cbc_decrypt_raw(seg_data, key_bytes, iv_bytes_impl)
            if decrypted and _is_valid_ts(decrypted):
                log.info('Segmento decrittato OK (IV implicito sn=%d): %d->%d byte',
                         candidate_sn, len(seg_data), len(decrypted))
                return decrypted, True

    # Fallback finale: IV = tutti zero
    log.debug('Retry decrypt con IV=zero')
    iv_zero = bytes(16)
    decrypted = _aes128_cbc_decrypt_raw(seg_data, key_bytes, iv_zero)
    if decrypted and _is_valid_ts(decrypted):
        log.info('Segmento decrittato OK (IV=zero): %d->%d byte', len(seg_data), len(decrypted))
        return decrypted, True

    log.warning('Decrittazione non riuscita (sn=%d, iv_hex=%s), servo dati originali',
                sn, iv_hex or 'implicito')
    return seg_data, False


_MAX_KEY_RETRIES = 3   # tentativi massimi per ogni chiave AES


def _prefetch_keys():
    """
    Pre-fetcha tutte le chiavi AES trovate nel manifest PRIMA che Kodi
    tenti di riprodurre. FFmpegDirect fetcha la chiave quasi subito dopo
    aver letto il manifest. Se la fetch via Tor avviene on-demand nel
    handler (latenza ~500ms-2s), FFmpegDirect supera il suo timeout e
    dà 'could not open file (2)'. Pre-fetchando qui la chiave è in cache
    e il handler risponde in <1 ms.

    Ritenta fino a _MAX_KEY_RETRIES volte per assorbire picchi di latenza
    o congestione del proxy Tor.
    """
    base = _state.get('base_url', '')
    for key_path, key_url in list(_state['key_cache'].items()):
        if key_path.endswith(':bytes') or not isinstance(key_url, str):
            continue
        if not key_url.startswith('http'):
            key_url = urljoin(base, key_path)

        key_bytes = None
        for attempt in range(1, _MAX_KEY_RETRIES + 1):
            log.info('Pre-fetch chiave AES (tentativo %d/%d): %s',
                     attempt, _MAX_KEY_RETRIES, key_url)
            key_bytes = _fetch_key_raw(key_path, key_url)
            if key_bytes:
                _state['key_cache'][key_path + ':bytes'] = key_bytes
                log.info('Chiave pre-fetchata OK: %d byte (tentativo %d)',
                         len(key_bytes), attempt)
                break
            log.warning('Pre-fetch tentativo %d/%d fallito per %s',
                        attempt, _MAX_KEY_RETRIES, key_path)

        if not key_bytes:
            log.error('Pre-fetch chiave definitivamente fallito dopo %d tentativi: %s',
                      _MAX_KEY_RETRIES, key_path)


def serve_manifest(fetch_url, referer, initial_content, get_func=None):
    global _server_instance, _refresh_thread, _generation

    if get_func is None:
        try:
            from functions import get as _get
            get_func = _get
        except ImportError:
            log.error('Impossibile importare functions.get')
            return None

    parsed   = urlparse(fetch_url)
    base_url = '{}://{}'.format(parsed.scheme, parsed.netloc)

    # ── Invalida TUTTI i thread di refresh precedenti in un colpo solo ──
    # Incrementare _generation fa sì che qualsiasi thread che stia girando
    # (sia il canale precedente che eventuali zombie più vecchi) esca al
    # prossimo ciclo di sleep, senza bisogno di join o flag individuali.
    _generation += 1
    my_generation = _generation
    log.debug('Nuova generazione proxy: %d (fetch_url=%s)', my_generation, fetch_url)

    _state['fetch_url']  = fetch_url
    _state['base_url']   = base_url
    _state['referer']    = referer
    _state['get_func']   = get_func
    _state['key_cache']  = {}
    _state['running']    = True
    _state['generation'] = my_generation
    # _process_manifest popola key_cache con path->url per ogni #EXT-X-KEY
    _state['manifest']  = _process_manifest(initial_content, base_url)
    _state['seg_cache']  = {}

    log.info('Manifest iniziale: %d byte', len(_state['manifest']))

    # ── Ritenta il fetch se il manifest corrente ha 0 segmenti reali ────
    # Il CDN a volte pubblica finestre con soli decoy. Aspettiamo fino a
    # _MAX_EMPTY_RETRIES aggiornamenti (max ~_MAX_EMPTY_RETRIES * 5 s)
    # finché non appare almeno un .ts reale, poi serviamo Kodi.
    _MAX_EMPTY_RETRIES = 6
    _RETRY_WAIT        = 5   # secondi tra un tentativo e l'altro

    for attempt in range(_MAX_EMPTY_RETRIES):
        n_real = _count_real_segments(initial_content if attempt == 0
                                      else getattr(_state.get('_last_raw'), 'text', ''))
        if n_real > 0:
            log.info('Manifest con %d segmenti reali (tentativo %d)', n_real, attempt)
            break
        log.warning('Manifest vuoto (solo decoy), tentativo %d/%d – attendo %ds',
                    attempt + 1, _MAX_EMPTY_RETRIES, _RETRY_WAIT)
        import time as _t
        _t.sleep(_RETRY_WAIT)
        try:
            fresh = get_func(fetch_url, referer=referer, timeout=12)
            if fresh and '#EXTM3U' in fresh.text:
                _state['_last_raw'] = fresh
                _state['manifest'] = _process_manifest(fresh.text, base_url)
                initial_content = fresh.text
        except Exception as _e:
            log.warning('Retry fetch fallito: %s', _e)
    else:
        log.error('Manifest ancora vuoto dopo %d tentativi – procedo comunque',
                  _MAX_EMPTY_RETRIES)

    # Pre-fetch chiavi AES e primo/i segmenti PRIMA di restituire l'URL a Kodi
    # Lanciamo i due pre-fetch in parallelo: chiavi (~0.6s) e segmenti (~3-4s)
    seg_urls = _extract_first_real_segments(_state['manifest'], n=2)
    seg_thread = threading.Thread(
        target=_prefetch_segments, args=(seg_urls, my_generation), daemon=True)
    seg_thread.start()
    _prefetch_keys()      # blocca fino al completamento delle chiavi
    seg_thread.join(timeout=8)  # aspetta max 8s per i segmenti (Firebase ~3-4s)

    with _server_lock:
        if _server_instance is None:
            try:
                srv = HTTPServer(('127.0.0.1', _PORT), _ProxyHandler)
                _server_instance = srv
                t = threading.Thread(target=srv.serve_forever, daemon=True)
                t.start()
                log.info('Proxy avviato su http://127.0.0.1:%d', _PORT)
            except OSError as exc:
                log.error('Porta %d occupata: %s', _PORT, exc)
                return None

        if _refresh_thread is None or not _refresh_thread.is_alive():
            _refresh_thread = threading.Thread(
                target=_refresh_loop, args=(my_generation,), daemon=True)
            _refresh_thread.start()

    return 'http://127.0.0.1:{}/stream.m3u8'.format(_PORT)


def stop():
    global _server_instance, _generation
    _generation += 1   # invalida tutti i thread di refresh attivi
    _state['running'] = False
    with _server_lock:
        if _server_instance is not None:
            _server_instance.shutdown()
            _server_instance = None
