# -*- coding: utf-8 -*-
from __future__ import unicode_literals

"""Motor VOD do OnePlay dirigido pelo contrato Stremio do HdHub.

Escopo exclusivo: descobrir e normalizar streams de filmes e séries. O módulo
não conhece EPG, M3U, TV ao vivo, Pluto, DNS ou services.

Arquitetura:
* O manifest.json define tipos, recurso stream e formatos de identificador.
* A interface escolhe explicitamente IMDb, TMDB ou Kitsu conforme o manifesto.
* Descoberta não remove uma fonte só pelo nome, host ou rótulo "download".
  Esses itens permanecem visíveis como seleção manual/cautelosa.
* Apenas ausência de URL, esquema inválido, arquivo compactado ou URL vencida
  impedem a entrega ao Kodi.
* IMDb, TMDB e Kitsu são consultados uma vez por abertura, de forma
  sequencial e controlada. A interface exibe todos os resultados em uma lista
  única, preservando a etiqueta de origem em cada fonte.
* HTTP 429 é compartilhado entre execuções plugin:// via estado pequeno sem
  URLs assinadas; não há cache negativo de título, série ou episódio.
"""

import re
import time
import ipaddress
try:
    from urllib.parse import quote, urlparse, parse_qs
except ImportError:  # pragma: no cover
    from urllib import quote
    from urlparse import urlparse, parse_qs

try:
    import requests as _requests
except Exception:  # pragma: no cover
    _requests = None

try:
    from resources.lib import cache_store as _cache_store
except Exception:  # pragma: no cover
    _cache_store = None


DEFAULT_MANIFEST_URL = (
    'https://hdhub.thevolecitor.qzz.io/'
    'eyJ0b3Jib3giOiJ1bnNldCIsInF1YWxpdGllcyI6IjIxNjBwLDEwODBwLDcyMHAsNDgwcCIs'
    'InNvcnQiOiJkZXNjIiwiY29udGVudCI6ImxhdGluLHBvcnR1Z3Vlc2UsZnJlbmNoLGl0YWxpYW4s'
    'YXNpYW4ifQ/manifest.json'
)

MANIFEST_CACHE_NAMESPACE = 'vod_hdhub_manifest_v2'
MANIFEST_CACHE_KEY = 'active_manifest'
MANIFEST_TTL_SECONDS = 6 * 3600
MANIFEST_STALE_SECONDS = 7 * 86400

# Uma única chamada curta por rota. Não há retry automático: resposta lenta ou
# falha de transporte não deve multiplicar carga nem prender o diálogo Kodi.
REQUEST_TIMEOUT = (2.5, 6.0)

# URLs assinadas ficam somente no processo durante a abertura atual.
SESSION_STREAM_TTL_SECONDS = 45.0

# Estado persistente mínimo, sem URL: protege o provider e a interface quando
# ele sinaliza rate-limit em execuções Kodi separadas.
PROVIDER_HEALTH_NAMESPACE = 'vod_hdhub_provider_health_v2'
PROVIDER_HEALTH_RATE_KEY = 'global_rate_limit'
PROVIDER_RATE_LIMIT_DEFAULT_SECONDS = 45
PROVIDER_RATE_LIMIT_MIN_SECONDS = 10
PROVIDER_RATE_LIMIT_MAX_SECONDS = 180

SAFE_PROXY_HEADER_NAMES = {
    'user-agent': 'User-Agent',
    'referer': 'Referer',
    'origin': 'Origin',
    'accept': 'Accept',
    'accept-language': 'Accept-Language',
}
# Cabeçalhos de sessão só são aceitos quando vierem declarados pelo próprio
# Stream Object em behaviorHints.proxyHeaders.request. Eles não entram em
# plugin:// nem em logs: a ponte loopback do proxy os guarda somente em memória.
SESSION_PROXY_HEADER_NAMES = {
    'cookie': 'Cookie',
    'authorization': 'Authorization',
    'x-api-key': 'X-Api-Key',
    'x-auth-token': 'X-Auth-Token',
}
MAX_PROXY_HEADER_VALUE_LENGTH = 512
MAX_SESSION_PROXY_HEADER_VALUE_LENGTH = 4096
MAX_PROXY_HEADERS_TOTAL_LENGTH = 8192

ARCHIVE_SUFFIXES = ('.zip', '.rar', '.7z', '.iso', '.tar', '.gz')
MEDIA_SUFFIXES = ('.mp4', '.mkv', '.webm', '.avi', '.mov', '.m4v', '.ts')

# Taxonomia de apresentação: famílias vistas nos próprios Stream Objects do
# HdHub e nos hosts que eles apontam. Esses nomes melhoram a leitura da lista,
# mas nunca são usados para ocultar fontes.
SOURCE_FAMILY_FALLBACK = 'Fonte externa'
IMDB_RE = re.compile(r'^tt\d{5,}$', re.I)
TMDB_RE = re.compile(r'^\d+$')
BTIH_HEX_RE = re.compile(r'^[A-F0-9]{40}$', re.I)
BTIH_BASE32_RE = re.compile(r'^[A-Z2-7]{32}$', re.I)
MAX_TORRENT_TRACKERS = 12
MAX_TORRENT_DISPLAY_NAME = 180


class VodEngineError(Exception):
    pass


class HdHubVodEngine(object):
    """Resolvedor stream-only testável sem Kodi."""

    def __init__(self, manifest_url=DEFAULT_MANIFEST_URL, requests_module=None,
                 cache_api=None, logger=None, clock=None):
        self.manifest_url = (manifest_url or DEFAULT_MANIFEST_URL).strip()
        self.requests = requests_module or _requests
        self.cache = cache_api if cache_api is not None else _cache_store
        self.logger = logger
        self.clock = clock or time.time
        self._session_streams = {}

    def _log(self, message, level=1):
        if self.logger is None:
            return
        try:
            self.logger(message, level)
        except TypeError:
            try:
                self.logger(message)
            except Exception:
                pass
        except Exception:
            pass

    # ------------------------------------------------------------------
    # Cache pequeno, sem URLs assinadas
    # ------------------------------------------------------------------
    def _cache_fresh_value(self, namespace, key):
        if self.cache is None:
            return None
        try:
            record = self.cache.get_record(namespace, key)
            if self.cache.is_fresh(record, now_ts=int(self.clock())):
                return record.get('value')
        except Exception:
            return None
        return None

    def _cache_put(self, namespace, key, value, ttl_seconds):
        if self.cache is None:
            return False
        try:
            return bool(self.cache.set_record(namespace, key, value, ttl_seconds))
        except Exception:
            return False

    @staticmethod
    def _retry_after_seconds(headers):
        try:
            raw = (headers or {}).get('Retry-After') or (headers or {}).get('retry-after') or ''
            seconds = int(str(raw).strip())
        except Exception:
            seconds = PROVIDER_RATE_LIMIT_DEFAULT_SECONDS
        return max(PROVIDER_RATE_LIMIT_MIN_SECONDS,
                   min(PROVIDER_RATE_LIMIT_MAX_SECONDS,
                       seconds or PROVIDER_RATE_LIMIT_DEFAULT_SECONDS))

    def _provider_rate_remaining(self):
        value = self._cache_fresh_value(PROVIDER_HEALTH_NAMESPACE, PROVIDER_HEALTH_RATE_KEY)
        if not isinstance(value, dict):
            return 0
        try:
            until = float(value.get('until') or 0)
        except Exception:
            until = 0
        return max(0, int(round(until - self.clock())))

    def _remember_provider_rate_limit(self, headers):
        seconds = self._retry_after_seconds(headers)
        self._cache_put(
            PROVIDER_HEALTH_NAMESPACE,
            PROVIDER_HEALTH_RATE_KEY,
            {'until': self.clock() + seconds, 'reason': 'http-429'},
            seconds
        )
        return seconds

    # ------------------------------------------------------------------
    # Manifest contract
    # ------------------------------------------------------------------
    @staticmethod
    def _resource_names(resources):
        names = set()
        for resource in resources or []:
            if isinstance(resource, str) and resource.strip():
                names.add(resource.strip().lower())
            elif isinstance(resource, dict):
                value = resource.get('name') or resource.get('resource') or ''
                if isinstance(value, str) and value.strip():
                    names.add(value.strip().lower())
        return names

    @staticmethod
    def _as_text_list(value):
        if not isinstance(value, (list, tuple)):
            return []
        return [item.strip() for item in value if isinstance(item, str) and item.strip()]

    @classmethod
    def _stream_resource_contract(cls, manifest):
        """Extrai o contrato do resource ``stream`` do manifesto Stremio.

        O protocolo permite ``resources`` como string simples ou objeto com
        ``types``/``idPrefixes`` próprios. Os dois últimos também podem faltar:
        nesse caso os valores globais, quando existirem, são usados; sem prefixo
        declarado o resource aceita identificadores de qualquer família válida.
        """
        global_types = set(cls._as_text_list(manifest.get('types')))
        global_prefixes = cls._as_text_list(manifest.get('idPrefixes'))
        names = cls._resource_names(manifest.get('resources'))
        stream_types = set()
        stream_prefixes = []
        accept_any_prefix = False
        stream_seen = False

        for resource in manifest.get('resources') or []:
            if isinstance(resource, str):
                if resource.strip().lower() != 'stream':
                    continue
                stream_seen = True
                stream_types.update(global_types)
                if global_prefixes:
                    stream_prefixes.extend(global_prefixes)
                else:
                    accept_any_prefix = True
                continue

            if not isinstance(resource, dict):
                continue
            name = resource.get('name') or resource.get('resource') or ''
            if not isinstance(name, str) or name.strip().lower() != 'stream':
                continue
            stream_seen = True
            resource_types = set(cls._as_text_list(resource.get('types')))
            stream_types.update(resource_types or global_types)
            if 'idPrefixes' in resource:
                resource_prefixes = cls._as_text_list(resource.get('idPrefixes'))
                if resource_prefixes:
                    stream_prefixes.extend(resource_prefixes)
                else:
                    accept_any_prefix = True
            elif global_prefixes:
                stream_prefixes.extend(global_prefixes)
            else:
                accept_any_prefix = True

        unique_prefixes = []
        seen_prefixes = set()
        for prefix in stream_prefixes:
            key = prefix.lower()
            if key not in seen_prefixes:
                seen_prefixes.add(key)
                unique_prefixes.append(prefix)
        return {
            'has_stream': bool(stream_seen and 'stream' in names),
            'types': stream_types,
            'id_prefixes': tuple(unique_prefixes),
            'accept_any_id_prefix': bool(accept_any_prefix),
            'stream_only': names == set(('stream',)),
        }

    @staticmethod
    def _manifest_root(manifest_url):
        value = (manifest_url or '').strip()
        suffix = '/manifest.json'
        if not value.lower().endswith(suffix):
            return ''
        root = value[:-len(suffix)].rstrip('/')
        parsed = urlparse(root)
        return root if parsed.scheme in ('http', 'https') and parsed.netloc else ''

    @classmethod
    def validate_manifest(cls, manifest, manifest_url=DEFAULT_MANIFEST_URL):
        if not isinstance(manifest, dict):
            return None, 'manifesto não é objeto JSON'
        addon_id = manifest.get('id')
        version = manifest.get('version')
        if not isinstance(addon_id, str) or not addon_id.strip():
            return None, 'manifesto sem id'
        if not isinstance(version, str) or not version.strip():
            return None, 'manifesto sem version'

        stream_contract = cls._stream_resource_contract(manifest)
        if not stream_contract['has_stream']:
            return None, 'manifesto não declara resource stream'
        supported_types = stream_contract['types'].intersection(set(('movie', 'series')))
        if not supported_types:
            return None, 'manifesto não declara movie/series para stream'
        root = cls._manifest_root(manifest_url)
        if not root:
            return None, 'URL do manifesto inválida'
        return {
            'id': addon_id.strip(),
            'version': version.strip(),
            'root': root,
            'types': supported_types,
            'id_prefixes': stream_contract['id_prefixes'],
            'accept_any_id_prefix': stream_contract['accept_any_id_prefix'],
            'stream_only': stream_contract['stream_only'],
        }, ''

    def _cache_record(self):
        try:
            return self.cache.get_record(MANIFEST_CACHE_NAMESPACE, MANIFEST_CACHE_KEY) if self.cache else None
        except Exception:
            return None

    def _record_is_stale_usable(self, record):
        try:
            return bool(record) and int(self.clock()) - int(record.get('updated_at') or 0) <= MANIFEST_STALE_SECONDS
        except Exception:
            return False

    def _validate_cached_manifest(self, record):
        value = record.get('value') if isinstance(record, dict) else None
        contract, _reason = self.validate_manifest(value, self.manifest_url)
        return contract

    def get_contract(self):
        record = self._cache_record()
        try:
            if self.cache and self.cache.is_fresh(record, now_ts=int(self.clock())):
                contract = self._validate_cached_manifest(record)
                if contract:
                    self._log('VOD manifesto: cache válido {} v{}.'.format(
                        contract['id'], contract['version']), 1)
                    return contract, 'cache'
        except Exception:
            pass

        headers = {'Accept': 'application/json'}
        if isinstance(record, dict):
            etag = (record.get('etag') or '').strip()
            modified = (record.get('last_modified') or '').strip()
            if etag:
                headers['If-None-Match'] = etag
            if modified:
                headers['If-Modified-Since'] = modified

        response = None
        try:
            if self.requests is None:
                raise VodEngineError('módulo requests indisponível')
            response = self.requests.get(
                self.manifest_url, headers=headers, timeout=REQUEST_TIMEOUT,
                allow_redirects=True
            )
            status = int(getattr(response, 'status_code', 0) or 0)
            if status == 304 and record:
                contract = self._validate_cached_manifest(record)
                if contract:
                    try:
                        response_headers = getattr(response, 'headers', {}) or {}
                        self.cache.touch_record(
                            MANIFEST_CACHE_NAMESPACE, MANIFEST_CACHE_KEY, MANIFEST_TTL_SECONDS,
                            etag=response_headers.get('ETag', record.get('etag', '')),
                            last_modified=response_headers.get('Last-Modified', record.get('last_modified', '')),
                        )
                    except Exception:
                        pass
                    return contract, 'revalidado'
            if status != 200:
                raise VodEngineError('HTTP {}'.format(status or 'erro'))
            manifest = response.json()
            contract, reason = self.validate_manifest(manifest, self.manifest_url)
            if not contract:
                raise VodEngineError(reason)
            try:
                response_headers = getattr(response, 'headers', {}) or {}
                self.cache.set_record(
                    MANIFEST_CACHE_NAMESPACE, MANIFEST_CACHE_KEY, manifest, MANIFEST_TTL_SECONDS,
                    etag=response_headers.get('ETag', '') or response_headers.get('Etag', ''),
                    last_modified=response_headers.get('Last-Modified', ''),
                )
            except Exception:
                pass
            prefix_mode = 'livre' if contract.get('accept_any_id_prefix') else ','.join(contract.get('id_prefixes') or ())
            self._log('VOD manifesto: contrato validado {} v{}; stream-only={}; ids={}.'.format(
                contract['id'], contract['version'], contract['stream_only'], prefix_mode or 'nenhum'), 1)
            return contract, 'rede'
        except Exception as exc:
            contract = self._validate_cached_manifest(record) if self._record_is_stale_usable(record) else None
            if contract:
                self._log('VOD manifesto: rede indisponível ({}), usando contrato local.'.format(
                    exc.__class__.__name__), 2)
                return contract, 'stale'
            self._log('VOD manifesto indisponível: {}.'.format(exc), 3)
            return None, 'manifesto indisponível'
        finally:
            try:
                if response is not None:
                    response.close()
            except Exception:
                pass

    # ------------------------------------------------------------------
    # Identifiers and routes
    # ------------------------------------------------------------------
    @staticmethod
    def _identifier_kind(identifier):
        value = (identifier or '').strip()
        lower = value.lower()
        if IMDB_RE.match(value):
            return 'imdb'
        if lower.startswith('tmdb:') and TMDB_RE.match(value.split(':', 1)[1] or ''):
            return 'tmdb'
        if lower.startswith('kitsu:') and value.split(':', 1)[1].strip():
            return 'kitsu'
        return ''

    @staticmethod
    def _prefix_for_kind(kind):
        return {'imdb': 'tt', 'tmdb': 'tmdb:', 'kitsu': 'kitsu:'}.get(kind, '')

    @classmethod
    def _manifest_accepts_identifier(cls, contract, identifier):
        kind = cls._identifier_kind(identifier)
        prefix = cls._prefix_for_kind(kind)
        if not prefix:
            return False
        if contract.get('accept_any_id_prefix'):
            return True
        value = (identifier or '').lower()
        return any(value.startswith(str(item).lower()) for item in (contract.get('id_prefixes') or ()))

    @staticmethod
    def _normalize_imdb(value):
        value = (value or '').strip()
        return 'tt{}'.format(value[2:]) if IMDB_RE.match(value) else ''

    @staticmethod
    def _normalize_tmdb(value):
        value = str(value or '').strip()
        if value.lower().startswith('tmdb:'):
            value = value.split(':', 1)[1].strip()
        return 'tmdb:{}'.format(value) if TMDB_RE.match(value) else ''

    @staticmethod
    def _normalize_kitsu(value):
        value = str(value or '').strip()
        if not value:
            return ''
        return value if value.lower().startswith('kitsu:') else 'kitsu:{}'.format(value)

    def build_identifier_plan(self, contract, tmdb_id, imdb_id='', kitsu_id='',
                              prefer_kitsu=False, route_hint=''):
        """Monta identificadores aceitos pelo manifesto, sem conversão por nome.

        ``route_hint`` seleciona uma única rota explícita da interface. O modo
        vazio continua disponível apenas como compatibilidade e escolhe uma
        rota padrão sem combinar respostas de providers diferentes.
        """
        candidates = []
        imdb = self._normalize_imdb(imdb_id)
        tmdb = self._normalize_tmdb(tmdb_id)
        kitsu = self._normalize_kitsu(kitsu_id)

        if route_hint == 'imdb':
            candidates = [imdb]
        elif route_hint == 'tmdb':
            candidates = [tmdb]
        elif route_hint == 'kitsu':
            candidates = [kitsu]
        else:
            if prefer_kitsu and kitsu:
                candidates.append(kitsu)
            if imdb:
                candidates.append(imdb)
            if not prefer_kitsu and kitsu:
                candidates.append(kitsu)
            if tmdb:
                candidates.append(tmdb)

        plan, seen = [], set()
        for identifier in candidates:
            if identifier and identifier not in seen and self._manifest_accepts_identifier(contract, identifier):
                seen.add(identifier)
                plan.append(identifier)
        return plan

    def get_route_capabilities(self, media_type, tmdb_id, imdb_id='', kitsu_id=''):
        """Descreve as três rotas do manifesto para a tela de escolha.

        A função não consulta streams nem tenta descobrir Kitsu por título.
        Kitsu só é habilitado quando o catálogo já disponibiliza um ID exato.
        """
        contract, contract_state = self.get_contract()
        result = {
            'contract': contract,
            'contract_state': contract_state,
            'routes': [],
        }
        if not contract:
            for kind in ('imdb', 'tmdb', 'kitsu'):
                result['routes'].append({
                    'kind': kind,
                    'identifier': '',
                    'available': False,
                    'reason': 'Manifesto HdHub indisponível.',
                })
            return result

        if media_type not in ('movie', 'series') or media_type not in contract.get('types', set()):
            for kind in ('imdb', 'tmdb', 'kitsu'):
                result['routes'].append({
                    'kind': kind,
                    'identifier': '',
                    'available': False,
                    'reason': 'O manifesto não suporta este tipo de conteúdo.',
                })
            return result

        raw_by_kind = {
            'imdb': imdb_id,
            'tmdb': tmdb_id,
            'kitsu': kitsu_id,
        }
        missing_reason = {
            'imdb': 'O TMDB não informou um IMDb válido para este conteúdo.',
            'tmdb': 'O conteúdo não possui um ID TMDB válido.',
            'kitsu': 'O catálogo atual não informou um ID Kitsu exato para este conteúdo.',
        }
        normalizers = {
            'imdb': self._normalize_imdb,
            'tmdb': self._normalize_tmdb,
            'kitsu': self._normalize_kitsu,
        }
        for kind in ('imdb', 'tmdb', 'kitsu'):
            identifier = normalizers[kind](raw_by_kind.get(kind, ''))
            if not identifier:
                result['routes'].append({
                    'kind': kind,
                    'identifier': '',
                    'available': False,
                    'reason': missing_reason[kind],
                })
                continue
            if not self._manifest_accepts_identifier(contract, identifier):
                result['routes'].append({
                    'kind': kind,
                    'identifier': identifier,
                    'available': False,
                    'reason': 'O manifesto HdHub não declara suporte a este identificador.',
                })
                continue
            result['routes'].append({
                'kind': kind,
                'identifier': identifier,
                'available': True,
                'reason': '',
            })
        return result

    @staticmethod
    def build_stream_url(contract, media_type, identifier, season=None, episode=None):
        if media_type not in ('movie', 'series') or media_type not in contract.get('types', set()):
            return ''
        if not HdHubVodEngine._manifest_accepts_identifier(contract, identifier):
            return ''
        root = contract.get('root') or ''
        encoded_id = quote(identifier, safe=':')
        if media_type == 'series':
            try:
                season = int(season)
                episode = int(episode)
            except (TypeError, ValueError):
                return ''
            if season < 0 or episode < 1:
                return ''
            return '{}/stream/series/{}:{}:{}.json'.format(root, encoded_id, season, episode)
        return '{}/stream/movie/{}.json'.format(root, encoded_id)

    # ------------------------------------------------------------------
    # Stream object normalization
    # ------------------------------------------------------------------
    @staticmethod
    def _host(value):
        try:
            host = (urlparse(value or '').netloc or '').lower().split(':', 1)[0]
            return host[4:] if host.startswith('www.') else host
        except Exception:
            return ''

    @staticmethod
    def _is_archive(value):
        try:
            path = (urlparse(value or '').path or '').lower()
        except Exception:
            path = (value or '').lower()
        return path.endswith(ARCHIVE_SUFFIXES)

    @staticmethod
    def _expiry_timestamp(value):
        try:
            query = parse_qs(urlparse(value or '').query, keep_blank_values=False)
        except Exception:
            return None
        for key in ('expire', 'expires', 'expiry', 'expiration', 'exp'):
            for raw in query.get(key) or []:
                try:
                    stamp = float(raw)
                except (TypeError, ValueError):
                    continue
                if stamp > 100000000000:
                    stamp /= 1000.0
                if 978307200 <= stamp <= 4102444800:
                    return stamp
        return None

    def _is_expired(self, value):
        stamp = self._expiry_timestamp(value)
        return bool(stamp is not None and stamp <= self.clock() + 5.0)

    @staticmethod
    def _clean_text(value):
        if not isinstance(value, str):
            return ''
        return value.replace('\n', ' ').replace('\t', ' ').strip()

    @classmethod
    def _raw_text(cls, raw):
        values = []
        for key in ('name', 'title', 'description'):
            value = cls._clean_text(raw.get(key) if isinstance(raw, dict) else '')
            if value:
                values.append(value)
        return ' '.join(values)

    @classmethod
    def _display_text(cls, raw):
        for key in ('title', 'name', 'description'):
            value = cls._clean_text(raw.get(key) if isinstance(raw, dict) else '')
            if value:
                return value
        return 'Fonte HdHub'

    @classmethod
    def _extract_size_label(cls, raw):
        text = cls._raw_text(raw)
        match = re.search(r'(?<![0-9a-z])(\d+(?:[.,]\d+)?\s*(?:tb|gb|mb|kb))(?![0-9a-z])', text, re.I)
        if not match:
            return ''
        value = re.sub(r'\s+', ' ', match.group(1).strip())
        unit = value[-2:].upper()
        return '{} {}'.format(value[:-2].strip().replace(',', '.'), unit)

    @classmethod
    def _source_family(cls, raw, target=''):
        """Normaliza o nome do espelho sem inferir reproduzibilidade.

        O HdHub pode colocar a família no name/title/description ou apenas no
        host da URL. A taxonomia serve exclusivamente para exibição, ordenação
        informativa e logs; não bloqueia nenhuma fonte.
        """
        text = cls._raw_text(raw).lower()
        host = cls._host(target)
        probe = '{} {}'.format(text, host)
        if re.search(r'\bfsl(?:[\s._-]*v?2)\b|fslv2|fsl-buckets', probe):
            return 'FSL v2'
        if re.search(r'\bfsl\b|hub\.latent\.click|hub\.pyramid\.surf|hub\.airbender\.buzz', probe):
            return 'FSL'
        if 'pixeldrain' in probe:
            return 'Pixeldrain'
        if 'hubcloud' in probe:
            return 'HubCloud'
        if 'bzzhr' in probe:
            return 'Bzzhr'
        if 'castle' in probe or 'flkow' in probe:
            return 'Castle'
        if 'pelisplushd' in probe:
            return 'PelisPlusHD'
        if re.search(r'\b(?:10gbps\s+)?download(?:\s+only)?\b', probe):
            return 'Download'
        if re.search(r'\b(?:server|servidor)\b', probe):
            return 'Servidor'

        # Alguns provedores usam [NomeDaFonte] no título. Extraímos apenas um
        # token limpo e ignoramos qualidade, tamanho e rótulos genéricos.
        ignored = set(('hdhub', 'hls', 'dash', 'server', 'download', 'fsl', 'fslv2',
                       '1080p', '720p', '480p', '2160p', '4k', 'auto'))
        for candidate in re.findall(r'\[([^\]]{2,40})\]', cls._raw_text(raw)):
            clean = re.sub(r'\s+', ' ', candidate).strip(' -|•')
            lowered = clean.lower()
            if not clean or lowered in ignored:
                continue
            if re.match(r'^(?:\d+(?:[.,]\d+)?\s*(?:kb|mb|gb|tb)|\d{3,4}p)$', lowered, re.I):
                continue
            return clean
        return SOURCE_FAMILY_FALLBACK

    @staticmethod
    def _download_only(raw):
        values = (raw.get('name', ''), raw.get('title', ''), raw.get('description', ''))
        text = ' '.join(str(value or '') for value in values).lower().replace('_', ' ')
        markers = ('download only', 'download-only', 'somente download', 'apenas download',
                   'para download', 'baixar somente', '10gbps download', '[download]',
                   '[ download ]', 'download |', 'download -')
        return any(marker in text for marker in markers)

    @classmethod
    def _stream_proxy_headers(cls, raw):
        """Extrai somente cabeçalhos declarados pelo Stream Object.

        Cabeçalhos comuns (UA/Referer) seguem no fluxo legado. Cookie e
        Authorization ficam limitados a uma allowlist e serão enviados ao proxy
        por uma ponte loopback efêmera, nunca serializados em plugin://.
        """
        try:
            hints = raw.get('behaviorHints') or {}
            proxy_headers = hints.get('proxyHeaders') or {}
            raw_headers = proxy_headers.get('request') or {}
        except Exception:
            raw_headers = {}
        if not isinstance(raw_headers, dict):
            return {}
        result = {}
        total = 0
        for key, value in raw_headers.items():
            try:
                lowered = str(key or '').strip().lower()
                canonical = (SAFE_PROXY_HEADER_NAMES.get(lowered) or
                             SESSION_PROXY_HEADER_NAMES.get(lowered))
                clean_value = str(value or '').strip()
            except Exception:
                continue
            if not canonical or not clean_value or '\r' in clean_value or '\n' in clean_value:
                continue
            max_len = (MAX_SESSION_PROXY_HEADER_VALUE_LENGTH
                       if lowered in SESSION_PROXY_HEADER_NAMES
                       else MAX_PROXY_HEADER_VALUE_LENGTH)
            if len(clean_value) > max_len or (total + len(clean_value)) > MAX_PROXY_HEADERS_TOTAL_LENGTH:
                continue
            result[canonical] = clean_value
            total += len(clean_value)
        return result

    @staticmethod
    def _stream_not_web_ready(raw):
        try:
            return bool((raw.get('behaviorHints') or {}).get('notWebReady') is True)
        except Exception:
            return False

    @staticmethod
    def _source_kind(value, raw):
        try:
            parsed = urlparse(value)
            location = '{}?{}'.format(parsed.path or '', parsed.query or '').lower()
        except Exception:
            location = (value or '').lower()
        hints = '{} {} {}'.format(
            raw.get('name', '') or '', raw.get('title', '') or '', raw.get('description', '') or ''
        ).lower()
        if '.m3u8' in location or 'application/x-mpegurl' in hints or '[hls' in hints:
            return 'hls'
        if '.mpd' in location or 'application/dash+xml' in hints or '[dash' in hints:
            return 'dash'
        if any(suffix in location for suffix in MEDIA_SUFFIXES):
            return 'progressive'
        return 'unknown'

    @staticmethod
    def _normalize_btih(value):
        """Aceita somente BTIH hex/base32. Não repassa dados livres ao magnet."""
        try:
            candidate = str(value or '').strip().upper()
        except Exception:
            return ''
        if BTIH_HEX_RE.match(candidate) or BTIH_BASE32_RE.match(candidate):
            return candidate
        return ''

    @staticmethod
    def _torrent_file_idx(raw):
        try:
            value = raw.get('fileIdx')
            if value is None or value == '':
                return None
            index = int(value)
            if index < 0 or index > 100000:
                return None
            return index
        except Exception:
            return None

    @staticmethod
    def _torrent_trackers(raw):
        """Extrai somente tracker:http(s)/udp declarados pelo Stream Object.

        Nós DHT são deliberadamente deixados para o motor PureTorrent, que já
        possui descoberta DHT própria. Nenhum host livre ou URI externo é
        convertido em tracker.
        """
        values = raw.get('sources') if isinstance(raw, dict) else None
        if not isinstance(values, (list, tuple)):
            return ()
        trackers = []
        seen = set()
        for item in values:
            try:
                value = str(item or '').strip()
            except Exception:
                continue
            if not value or len(value) > 1024 or '\r' in value or '\n' in value:
                continue
            if not value.lower().startswith('tracker:'):
                continue
            tracker_url = value.split(':', 1)[1].strip()
            try:
                parsed = urlparse(tracker_url)
                scheme = (parsed.scheme or '').lower()
                host = (parsed.hostname or '').strip()
                port = parsed.port
            except Exception:
                continue
            # O contrato Stream Object aceita tracker:http e tracker:udp.
            # Destinos locais nunca devem atravessar a ponte OnePlay -> PureTorrent.
            if scheme not in ('udp', 'http') or not host:
                continue
            host_lower = host.lower().rstrip('.')
            if host_lower == 'localhost' or host_lower.endswith('.local'):
                continue
            try:
                ip_value = ipaddress.ip_address(host_lower)
                if not ip_value.is_global:
                    continue
            except ValueError:
                pass
            if port is not None and not (1 <= int(port) <= 65535):
                continue
            normalized = tracker_url
            key = normalized.lower()
            if key in seen:
                continue
            seen.add(key)
            trackers.append(normalized)
            if len(trackers) >= MAX_TORRENT_TRACKERS:
                break
        return tuple(trackers)

    @classmethod
    def _torrent_magnet(cls, raw, info_hash):
        """Monta um magnet portátil para o PureTorrent, sem campos arbitrários."""
        btih = cls._normalize_btih(info_hash)
        if not btih:
            return ''
        try:
            display = cls._display_text(raw)
        except Exception:
            display = 'Fonte HdHub'
        display = re.sub(r'[\x00-\x1f\x7f]+', ' ', display or '').strip()
        display = display[:MAX_TORRENT_DISPLAY_NAME] or 'Fonte HdHub'
        query = [
            ('xt', 'urn:btih:{}'.format(btih)),
            ('dn', display),
        ]
        for tracker in cls._torrent_trackers(raw):
            query.append(('tr', tracker))
        return 'magnet:?' + '&'.join(
            '{}={}'.format(quote(str(key), safe=''), quote(str(value), safe=''))
            for key, value in query
        )

    @staticmethod
    def _stream_target(raw):
        """Seleciona a referência mais explícita do Stream Object.

        A especificação Stremio aceita URL, externalUrl, ytId, infoHash e
        objetos de arquivo. Somente URL HTTP(S) pode seguir para o player Kodi
        nativo; as outras variantes permanecem visíveis como informativas.
        """
        url = raw.get('url')
        if isinstance(url, str) and url.strip():
            return 'url', url.strip()
        external = raw.get('externalUrl')
        if isinstance(external, str) and external.strip():
            return 'external-url', external.strip()
        yt_id = raw.get('ytId')
        if isinstance(yt_id, str) and yt_id.strip():
            return 'youtube', yt_id.strip()
        info_hash = raw.get('infoHash')
        if isinstance(info_hash, str) and info_hash.strip():
            return 'torrent', info_hash.strip()
        for key in ('rarUrls', 'zipUrls', '7zipUrls', 'tgzUrls', 'tarUrls', 'nzbUrl'):
            value = raw.get(key)
            if not value:
                continue
            # Mantém um identificador específico para não deduplicar dois
            # objetos de arquivo diferentes na mesma resposta do provider.
            if isinstance(value, (list, tuple)):
                value = value[0] if value else key
            return 'archive', str(value or key)
        return '', ''

    @staticmethod
    def _target_state(target_kind, scheme='', archive=False, expired=False):
        if archive:
            return 'arquivo compactado — somente informativa'
        if expired:
            return 'URL expirada — somente informativa'
        if target_kind == 'external-url':
            return 'link externo — abrir fora do Kodi'
        if target_kind == 'youtube':
            return 'referência YouTube — requer integração externa'
        if target_kind == 'torrent':
            return 'torrent — abrir manualmente no PureTorrent'
        if target_kind == 'archive':
            return 'objeto de arquivo — não suportado pelo player VOD'
        if scheme and scheme not in ('http', 'https'):
            return 'protocolo {} — não suportado pelo player VOD'.format(scheme)
        return ''

    def _normalize_stream(self, raw, identifier, request_rank):
        """Normaliza e preserva todos os Stream Objects compreensíveis.

        Não há remoção por host, rótulo, download ou formato cauteloso. Quando
        uma fonte não puder ser entregue ao VideoPlayer Kodi, ela é apresentada
        como item informativo com o motivo técnico, sem fingir ausência de
        conteúdo no HdHub.
        """
        if not isinstance(raw, dict):
            return None, 'item não é objeto'

        target_kind, target = self._stream_target(raw)
        if not target_kind:
            return None, 'objeto sem destino de stream'

        url = target if target_kind in ('url', 'external-url') else ''
        host = self._host(url)
        kind = 'unknown'
        playable = False
        state = ''
        archive = False
        expired = False
        scheme = ''
        if target_kind == 'url':
            try:
                scheme = (urlparse(url).scheme or '').lower()
            except Exception:
                scheme = ''
            archive = self._is_archive(url)
            expired = self._is_expired(url)
            kind = self._source_kind(url, raw)
            if archive:
                kind = 'archive'
            elif scheme in ('http', 'https') and not expired:
                playable = True
            state = self._target_state(target_kind, scheme=scheme, archive=archive, expired=expired)
        else:
            kind = target_kind
            state = self._target_state(target_kind)

        family = self._source_family(raw, url or target)
        torrent_magnet = ''
        torrent_file_idx = None
        torrent_trackers = ()
        if target_kind == 'torrent':
            torrent_magnet = self._torrent_magnet(raw, target)
            torrent_file_idx = self._torrent_file_idx(raw)
            torrent_trackers = self._torrent_trackers(raw)
            if not torrent_magnet:
                state = 'torrent com infoHash inválido — somente informativa'

        caution_reasons = []
        if self._download_only(raw):
            caution_reasons.append('download')
        if playable and kind == 'unknown':
            caution_reasons.append('link-opaco')
        # URLs FSL por token não trazem necessariamente extensão. Elas já
        # funcionaram como Matroska no Kodi, mas permanecem manuais até uma
        # validação real da sessão — rótulo não é garantia de autoplay.
        if playable and kind == 'unknown' and family in ('FSL', 'FSL v2'):
            caution_reasons.append('fsl-token')
        if self._stream_not_web_ready(raw):
            caution_reasons.append('not-web-ready')
        if (host == 'hubcloud.cx' or host.endswith('.hubcloud.cx') or
                host == 'bzzhr.co' or host.endswith('.bzzhr.co')):
            caution_reasons.append('intermediario')
        if not playable:
            caution_reasons.append('informativa')

        return {
            'url': url,
            'name': raw.get('name', '') or '',
            'title': self._display_text(raw),
            'description': raw.get('description', '') or '',
            '__vod_raw_name': self._clean_text(raw.get('name', '')),
            '__vod_raw_title': self._clean_text(raw.get('title', '')),
            '__vod_raw_description': self._clean_text(raw.get('description', '')),
            '__vod_target_type': target_kind,
            '__vod_target_value': target,
            '__vod_torrent_magnet': torrent_magnet,
            '__vod_torrent_file_idx': torrent_file_idx,
            '__vod_torrent_trackers': torrent_trackers,
            '__vod_playable': bool(playable),
            '__vod_info_state': state,
            '__vod_source_family': family,
            '__vod_source_host': host,
            '__vod_size_label': self._extract_size_label(raw),
            '__request_label': self._identifier_kind(identifier) or 'provider',
            '__request_rank': int(request_rank),
            '__request_qualities': [],
            '__vod_kind': kind,
            '__vod_provider_id': identifier,
            '__vod_provider_id_kind': self._identifier_kind(identifier),
            '__vod_provider': 'HdHub',
            '__vod_request_headers': self._stream_proxy_headers(raw),
            '__vod_caution': bool(caution_reasons),
            '__vod_caution_reasons': tuple(caution_reasons),
        }, ''

    # ------------------------------------------------------------------
    # HTTP / resolver
    # ------------------------------------------------------------------
    def _fetch_streams(self, stream_url):
        remaining = self._provider_rate_remaining()
        if remaining > 0:
            return [], 'rate-limit-cooldown', {'retry_after': remaining}
        response = None
        try:
            if self.requests is None:
                raise VodEngineError('módulo requests indisponível')
            response = self.requests.get(
                stream_url,
                headers={'Accept': 'application/json'},
                timeout=REQUEST_TIMEOUT,
                allow_redirects=True,
            )
            status = int(getattr(response, 'status_code', 0) or 0)
            headers = getattr(response, 'headers', {}) or {}
            if status == 429:
                return [], 'http-429', {'retry_after': self._remember_provider_rate_limit(headers)}
            if status != 200:
                return [], 'http-{}'.format(status or 'erro'), {}
            payload = response.json()
            streams = payload.get('streams', []) if isinstance(payload, dict) else None
            if not isinstance(streams, list):
                return [], 'json-invalido', {}
            return streams, 'ok', {}
        except Exception as exc:
            return [], 'transporte-{}'.format(exc.__class__.__name__), {}
        finally:
            try:
                if response is not None:
                    response.close()
            except Exception:
                pass

    @staticmethod
    def _session_key(media_type, tmdb_id, season, episode, identifier):
        return '{}|{}|{}|{}|{}'.format(media_type, tmdb_id, season or '', episode or '', identifier)

    def _session_stream_get(self, key):
        record = self._session_streams.get(key)
        if not record or record.get('until', 0) <= self.clock():
            self._session_streams.pop(key, None)
            return None
        return list(record.get('streams') or [])

    def _session_stream_put(self, key, streams):
        ttl = SESSION_STREAM_TTL_SECONDS
        expiries = [self._expiry_timestamp(item.get('url', '')) for item in streams if isinstance(item, dict)]
        expiries = [stamp for stamp in expiries if stamp]
        if expiries:
            ttl = min(ttl, max(1.0, min(expiries) - self.clock() - 10.0))
        if ttl > 0:
            self._session_streams[key] = {'until': self.clock() + ttl, 'streams': list(streams)}

    @staticmethod
    def _reason_for_empty(audit, rejection_counts, rate_remaining=0):
        joined = ' '.join(audit or [])
        if 'http-429' in joined or 'rate-limit-cooldown' in joined:
            seconds = max(1, int(rate_remaining or PROVIDER_RATE_LIMIT_DEFAULT_SECONDS))
            return 'O HdHub limitou temporariamente as consultas. Aguarde cerca de {} segundos e tente novamente.'.format(seconds)
        if 'transporte-' in joined:
            return 'O HdHub não respondeu agora. Tente novamente em instantes.'
        if rejection_counts:
            ordered = sorted(rejection_counts.items(), key=lambda pair: (-pair[1], pair[0]))
            summary = ', '.join('{} ({})'.format(reason, count) for reason, count in ordered[:3])
            return 'O HdHub respondeu, mas alguns objetos não tinham destino de stream utilizável: {}.'.format(summary)
        return 'O HdHub não publicou fontes para este título agora.'

    @staticmethod
    def _audit_has_rate_limit(audit):
        return any(item in ('imdb=http-429', 'imdb=rate-limit-cooldown',
                            'tmdb=http-429', 'tmdb=rate-limit-cooldown',
                            'kitsu=http-429', 'kitsu=rate-limit-cooldown')
                   or 'http-429' in str(item)
                   or 'rate-limit-cooldown' in str(item)
                   for item in (audit or ()))

    @staticmethod
    def _audit_has_transport_or_http_error(audit):
        for item in audit or ():
            value = str(item or '')
            if 'transporte-' in value or re.search(r'=http-(?:[45]\d\d|erro)', value):
                return True
        return False

    def resolve_all_routes(self, media_type, tmdb_id, imdb_id='', season=None,
                           episode=None, kitsu_id='', progress_callback=None):
        """Consulta todas as rotas conhecidas em uma única abertura de título.

        Os resultados preservam a origem por provider, mas a interface os exibe
        em uma lista única com rótulo de procedência. A execução é sequencial e
        curta (IMDb -> TMDB -> Kitsu), não paralela, para evitar rajada de
        conexões no mesmo endpoint HdHub. Uma resposta 429 interrompe somente
        as rotas ainda pendentes e deixa o motivo explícito à interface.
        """
        capabilities = self.get_route_capabilities(
            media_type, tmdb_id, imdb_id=imdb_id, kitsu_id=kitsu_id
        )
        contract = capabilities.get('contract')
        route_by_kind = {
            item.get('kind'): item for item in (capabilities.get('routes') or ())
            if isinstance(item, dict)
        }
        result = {
            'contract': contract,
            'contract_state': capabilities.get('contract_state', ''),
            'routes': [],
            'audit': [],
        }
        if not contract:
            for kind in ('imdb', 'tmdb', 'kitsu'):
                capability = route_by_kind.get(kind, {})
                result['routes'].append({
                    'kind': kind,
                    'identifier': capability.get('identifier', ''),
                    'available': False,
                    'state': 'unavailable',
                    'streams': [],
                    'reason': capability.get('reason') or 'Manifesto HdHub indisponível.',
                    'audit': [],
                })
            return result

        available_count = sum(
            1 for kind in ('imdb', 'tmdb', 'kitsu')
            if bool(route_by_kind.get(kind, {}).get('available'))
        )
        completed = 0
        rate_limited = False
        rate_reason = ''

        for kind in ('imdb', 'tmdb', 'kitsu'):
            capability = route_by_kind.get(kind, {})
            identifier = capability.get('identifier', '')
            base = {
                'kind': kind,
                'identifier': identifier,
                'available': bool(capability.get('available')),
                'streams': [],
                'audit': [],
                'reason': capability.get('reason', ''),
                'state': 'unavailable',
            }
            if not base['available']:
                result['routes'].append(base)
                continue

            if rate_limited:
                base.update({
                    'state': 'limited',
                    'reason': rate_reason or 'Consulta suspensa pela proteção temporária do HdHub.',
                })
                result['routes'].append(base)
                continue

            if progress_callback is not None:
                try:
                    progress_callback(completed, max(1, available_count), kind, 'consultando')
                except Exception:
                    pass

            resolution = self.resolve(
                media_type, tmdb_id, imdb_id=imdb_id, season=season,
                episode=episode, kitsu_id=kitsu_id, prefer_kitsu=False,
                route_hint=kind, contract_override=contract
            )
            completed += 1
            audit = list(resolution.get('audit') or [])
            streams = list(resolution.get('streams') or [])
            base.update({
                'streams': streams,
                'audit': audit,
                'reason': resolution.get('reason', ''),
                'state': 'ready' if streams else 'empty',
            })
            if self._audit_has_rate_limit(audit):
                base['state'] = 'limited'
                rate_limited = True
                rate_reason = base['reason'] or 'O HdHub limitou temporariamente novas consultas.'
            elif not streams and self._audit_has_transport_or_http_error(audit):
                base['state'] = 'error'
            result['routes'].append(base)
            result['audit'].extend(audit)

        if progress_callback is not None:
            try:
                progress_callback(available_count, max(1, available_count), '', 'concluido')
            except Exception:
                pass
        self._log('VOD HdHub consolidado: {}.'.format(', '.join(
            '{}={}'.format(item.get('kind'), len(item.get('streams') or ()))
            for item in result['routes']
        )), 1)
        return result

    def resolve(self, media_type, tmdb_id, imdb_id='', season=None, episode=None,
                kitsu_id='', prefer_kitsu=False, route_hint='', contract_override=None):
        """Resolve uma rota HdHub por vez, preservando a origem das fontes.

        A interface escolhe explicitamente ``imdb``, ``tmdb`` ou ``kitsu``.
        Sem dica, por compatibilidade, a primeira rota disponível é usada sem
        fallback automático: fontes de identificadores diferentes não se misturam.
        """
        if isinstance(contract_override, dict):
            # A busca consolidada já validou o manifesto nesta mesma abertura.
            # Reutilizar o contrato evita três downloads redundantes do manifest.
            contract, contract_state = contract_override, 'batch'
        else:
            contract, contract_state = self.get_contract()
        if not contract:
            return {'streams': [], 'reason': 'manifesto HdHub indisponível', 'audit': [contract_state]}
        if media_type not in ('movie', 'series') or media_type not in contract['types']:
            return {'streams': [], 'reason': 'tipo não suportado pelo manifesto', 'audit': ['tipo-invalido']}
        if media_type == 'series' and (season is None or episode is None):
            return {'streams': [], 'reason': 'temporada ou episódio ausente', 'audit': ['episodio-invalido']}

        route_hint = route_hint if route_hint in ('', 'imdb', 'tmdb', 'kitsu') else ''
        plan = self.build_identifier_plan(
            contract, tmdb_id, imdb_id, kitsu_id, prefer_kitsu, route_hint=route_hint
        )
        # Mesmo em chamadas antigas sem ``vod_route``, não combinar respostas
        # de IMDb/TMDB/Kitsu. A lista da interface agora apresenta cada rota.
        if not route_hint:
            plan = plan[:1]
        if not plan:
            return {'streams': [], 'reason': 'nenhum identificador aceito pelo manifesto', 'audit': ['sem-id']}

        audit, rejection_counts = [], {}
        rejected_total = 0
        rate_remaining = 0

        for index, identifier in enumerate(plan):
            identifier_kind = self._identifier_kind(identifier) or 'id'
            url = self.build_stream_url(contract, media_type, identifier, season, episode)
            if not url:
                audit.append('{}=url-invalida'.format(identifier_kind))
                continue

            key = self._session_key(media_type, tmdb_id, season, episode, identifier)
            raw_streams = self._session_stream_get(key)
            outcome, meta = 'session-cache', {}
            if raw_streams is None:
                raw_streams, outcome, meta = self._fetch_streams(url)
                # Guarda inclusive uma resposta vazia válida. Assim a tela
                # consolidada IMDb/TMDB/Kitsu não consulta novamente a mesma
                # rota quando o usuário apenas abre o grupo do provider.
                if outcome == 'ok':
                    self._session_stream_put(key, raw_streams)

            if outcome in ('http-429', 'rate-limit-cooldown'):
                rate_remaining = int(meta.get('retry_after') or self._provider_rate_remaining() or PROVIDER_RATE_LIMIT_DEFAULT_SECONDS)
                audit.append('{}={}'.format(identifier_kind, outcome))
                self._log('VOD HdHub limitou consultas; proteção local por {}s.'.format(rate_remaining), 1)
                break
            if outcome not in ('ok', 'session-cache'):
                audit.append('{}={}'.format(identifier_kind, outcome))
                # Falha de rede ou 5xx não prova que o outro identificador é
                # melhor e não deve duplicar uma requisição problemática.
                if outcome in ('http-400', 'http-404') and route_hint != 'tmdb':
                    continue
                break

            normalized, local_reasons, seen = [], {}, set()
            for raw in raw_streams:
                item, reason = self._normalize_stream(raw, identifier, index)
                if item is None:
                    rejected_total += 1
                    local_reasons[reason] = local_reasons.get(reason, 0) + 1
                    rejection_counts[reason] = rejection_counts.get(reason, 0) + 1
                    continue
                # URL direta, link externo, torrent e arquivo possuem
                # destinos diferentes. Dedupe por URL vazia apagaria fontes
                # informativas válidas; preservamos o par tipo + destino.
                dedupe_key = (
                    item.get('__vod_target_type', ''),
                    item.get('url') or item.get('__vod_target_value', ''),
                )
                if dedupe_key in seen:
                    continue
                seen.add(dedupe_key)
                normalized.append(item)

            audit.append('{}={}/{}{}'.format(
                identifier_kind, len(normalized), len(raw_streams),
                ' cache' if outcome == 'session-cache' else ''
            ))
            if local_reasons:
                summary = ', '.join('{}={}'.format(k, v) for k, v in sorted(local_reasons.items()))
                self._log('VOD HdHub resposta {}: exibíveis={}/{}; objetos sem destino={}.'.format(
                    identifier_kind, len(normalized), len(raw_streams), summary), 1)

            if normalized:
                self._session_stream_put(key, raw_streams)
                caution_count = sum(1 for item in normalized if item.get('__vod_caution'))
                informative_count = sum(1 for item in normalized if not item.get('__vod_playable', True))
                self._log('VOD HdHub resolveu {} via {}: {} fontes visíveis ({} manuais; {} informativas; {} sem destino).'.format(
                    media_type, identifier_kind, len(normalized), caution_count, informative_count, rejected_total), 1)
                return {
                    'streams': normalized,
                    'reason': '',
                    'identifier': identifier,
                    'identifier_kind': identifier_kind,
                    'manifest_id': contract['id'],
                    'manifest_version': contract['version'],
                    # Mantido por compatibilidade com rotas antigas. A UI não
                    # usa mais fallback oculto; ela oferece todas as três rotas.
                    'alternative_route_available': False,
                    'audit': audit,
                }

            # Sem qualquer fonte apresentável: aqui sim a segunda rota pode
            # recuperar cobertura. Não há dedução por rótulo download/host,
            # pois tais itens já seriam apresentados como manuais.
            if route_hint:
                break
            continue

        reason = self._reason_for_empty(audit, rejection_counts, rate_remaining)
        self._log('VOD HdHub sem fonte para {}: {}.'.format(media_type, ', '.join(audit)), 1)
        return {'streams': [], 'reason': reason, 'audit': audit}


_DEFAULT_ENGINE = None


def get_default_engine():
    global _DEFAULT_ENGINE
    if _DEFAULT_ENGINE is None:
        def _kodi_log(message, level=1):
            try:
                from resources.lib.common import log, log_debug
                log('[VOD Engine] {}'.format(message), level=level)
                log_debug(message, component='HdHub')
            except Exception:
                pass
        _DEFAULT_ENGINE = HdHubVodEngine(logger=_kodi_log)
    return _DEFAULT_ENGINE


def reset_default_engine_for_tests():
    global _DEFAULT_ENGINE
    _DEFAULT_ENGINE = None
