# -*- coding: utf-8 -*-
import sys
import os
import time
import json
import html
import urllib.parse
import ntpath
from urllib.request import build_opener, install_opener, urlretrieve
import re
from datetime import datetime
import base64
import hashlib
import unicodedata
import traceback
import xbmc
import xbmcgui
import xbmcplugin
import xbmcaddon
import xbmcvfs
import requests

# =========================
# Configurações do Addon
# =========================
ADDON = xbmcaddon.Addon()
ADDON_ID = ADDON.getAddonInfo('id')
ADDON_HANDLE = int(sys.argv[1])
LOGOUT_HOME_REQUEST_PROPERTY = f'{ADDON_ID}.logout_home_requested'
EXTERNAL_ORIGIN_PROPERTY = f'{ADDON_ID}.external_origin'
EXTERNAL_ORIGIN_ONEPLAY_MATRIX = 'oneplay_matrix'
ONEPLAY_MATRIX_ADDON_ID = 'plugin.video.OnePlay.Matrix'
ONEPLAY_MATRIX_ROOT_URL = 'plugin://plugin.video.OnePlay.Matrix/'

USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36'
HEADERS = {'User-Agent': USER_AGENT}
TRANSIENT_HTTP_STATUS = {429, 500, 502, 503, 504}

HOME = xbmcvfs.translatePath(ADDON.getAddonInfo('path'))
addonIcon = xbmcvfs.translatePath(os.path.join(HOME, 'icon.png'))
addonFanart = xbmcvfs.translatePath(os.path.join(HOME, 'fanart.jpg'))
if not xbmcvfs.exists(addonFanart):
    addonFanart = xbmcvfs.translatePath(os.path.join(HOME, 'fanart.png'))

MENU_ICON_DIR = xbmcvfs.translatePath(os.path.join(HOME, 'resources', 'media', 'menu_icons'))

def menu_icon_path(filename):
    return xbmcvfs.translatePath(os.path.join(MENU_ICON_DIR, filename))

MENU_ICONS = {
    'account': menu_icon_path('account.png'),
    'account_info': menu_icon_path('account_info.png'),
    'server_status': menu_icon_path('server_status.png'),
    'server_switch': menu_icon_path('server_switch.png'),
    'credentials': menu_icon_path('credentials.png'),
    'server_test_all': menu_icon_path('server_test_all.png'),
    'cleanup': menu_icon_path('cleanup.png'),
    'logout': menu_icon_path('logout.png'),
    'quick_setup': menu_icon_path('quick_setup.png'),
    'support': menu_icon_path('support.png'),
    'search': menu_icon_path('search.png'),
    'favorites': menu_icon_path('favorites.png'),
    'live': menu_icon_path('live.png'),
    'movies': menu_icon_path('movies.png'),
    'series': menu_icon_path('series.png'),
    'settings': menu_icon_path('settings.png'),
    'default': menu_icon_path('default.png'),
}

def icon_missing(icon):
    try:
        raw = str(icon or '').strip()
    except Exception:
        raw = ''
    if not raw:
        return True
    lowered = raw.lower()
    if lowered in ('none', 'null', 'false', '0'):
        return True
    return raw == addonIcon

def infer_item_icon_key(item):
    mode = str(item.get('mode') or '').strip().lower()
    stream_kind = str(item.get('stream_kind') or '').strip().lower()
    media_type = str(item.get('media_type') or '').strip().lower()
    favorite_kind = str(item.get('favorite_kind') or '').strip().lower()
    title = str(item.get('title') or item.get('favorite_title') or '').strip().lower()

    mode_map = {
        'account_connection_menu': 'account',
        'quick_setup': 'quick_setup',
        'account_info': 'account_info',
        'test_current_server': 'server_status',
        'switch_server': 'server_switch',
        'change_credentials': 'credentials',
        'test_all_servers': 'server_test_all',
        'cleanup_tools': 'cleanup',
        'logout': 'logout',
        'show_qr': 'support',
        'search': 'search',
        'favorites': 'favorites',
        'tv': 'live',
        'tv_channels': 'live',
        'movies': 'movies',
        'vod_channels': 'movies',
        'series': 'series',
        'seasons': 'series',
        'episodes': 'series',
        'settings': 'settings',
    }
    if mode in mode_map:
        return mode_map[mode]

    if stream_kind == 'live' or favorite_kind == 'live':
        return 'live'
    if stream_kind in ('vod', 'movie') or favorite_kind == 'vod' or media_type == 'movie':
        return 'movies'
    if stream_kind in ('episode', 'series') or favorite_kind == 'series' or media_type in ('tvshow', 'episode', 'season'):
        return 'series'

    if title:
        if 'informações da conta' in title or 'informacoes da conta' in title:
            return 'account_info'
        if 'trocar servidor' in title:
            return 'server_switch'
        if 'servidor atual' in title:
            return 'server_status'
        if 'alterar usuário e senha' in title or 'alterar usuario e senha' in title:
            return 'credentials'
        if 'testar todos os servidores' in title:
            return 'server_test_all'
        if title == 'limpeza' or 'limpeza' in title:
            return 'cleanup'
        if 'sair da conta' in title or 'logout' in title:
            return 'logout'
        if 'configuração rápida' in title or 'configuracao rapida' in title or 'configuração rápida' in title:
            return 'quick_setup'
        if 'whatsapp' in title or 'qr code' in title or 'suporte' in title:
            return 'support'
        if any(token in title for token in ('acesso', 'servidor', 'login', 'usuário', 'usuario')):
            return 'account'
        if 'pesquisa' in title or 'buscar' in title:
            return 'search'
        if 'favorit' in title:
            return 'favorites'
        if any(token in title for token in ('tv ao vivo', 'canais', 'canal', 'ao vivo')):
            return 'live'
        if any(token in title for token in ('filmes', 'filme', 'vod')):
            return 'movies'
        if any(token in title for token in ('séries', 'series', 'série', 'serie', 'temporada', 'episódio', 'episodio')):
            return 'series'
        if 'configura' in title or title == 'ajustes':
            return 'settings'

    return 'default'

def resolve_item_icon(item, explicit_icon=None):
    icon = explicit_icon if explicit_icon is not None else item.get('icon', '')
    if not icon_missing(icon):
        return str(icon).strip()
    key = infer_item_icon_key(item)
    candidate = MENU_ICONS.get(key) or MENU_ICONS.get('default') or addonIcon
    return candidate if candidate and xbmcvfs.exists(candidate) else addonIcon

def resolve_item_fanart(item, explicit_fanart=None):
    fanart = explicit_fanart if explicit_fanart is not None else item.get('fanart', addonFanart)
    return str(fanart or addonFanart).strip() or addonFanart
from resources.lib.server_config import (
    DNS_LIST_B64,
    b64_decode as external_b64_decode,
    get_base_url as external_get_base_url,
    get_current_server_choice as external_get_current_server_choice,
    get_server_labels as external_get_server_labels,
    get_server_name as external_get_server_name,
    get_server_summary as external_get_server_summary,
    set_server_choice as external_set_server_choice,
)
from resources.lib import live_proxy as live_proxy_helper
from resources.lib import storage_runtime, category_counts_runtime, epg_runtime
try:
    from resources.lib import http_runtime
except Exception:
    http_runtime = None
try:
    from resources.lib import dns as dns_runtime
except Exception:
    dns_runtime = None
QR_IMAGE_PATH = xbmcvfs.translatePath(os.path.join(HOME, 'resources', 'media', 'qr_whatsapp_paulo.png'))
SUPPORT_WHATSAPP = '(31) 99594-9574'
SUPPORT_CONTACT_LABEL = 'Suporte / WhatsApp'
APK_DOWNLOAD_FOLDER_ANDROID = '/storage/emulated/0/Download'
APK_ENTRIES_B64 = [
    ('OnePlay Alpha - PRINCIPAL', 'aHR0cHM6Ly9vbmVwbGF5aGQuY29tL2Rvd25sb2FkL09uZVBsYXlBbHBoYS5hcGs='),
    ('OnePlay Ibo - ESSENCIAL', 'aHR0cDovL29uZXBsYXloZC5jb20vZG93bmxvYWQvT25lUGxheUliby5hcGs='),
    ('OnePlay Xtream - BASICO', 'aHR0cDovL29uZXBsYXloZC5jb20vZG93bmxvYWQvT25lUGxheVh0cmVhbS5hcGs='),
]

USERNAME = ''
PASSWORD = ''
ENABLE_EPG = 'false'
ENABLE_ADULT = 'false'
ADULT_PIN = ''
DEFAULT_ADULT_PIN = '0000'
ADULT_SESSION_PROPERTY = f'{ADDON_ID}.adult_unlocked'
ACCOUNT_CONNECTION_DIRTY_PROPERTY = f'{ADDON_ID}.account_connection_dirty'
LOGIN_SUCCESS_SESSION_PROPERTY = f'{ADDON_ID}.login_success_shown_key'
SILENT_ACTION_MODES = {'download_apk'}

def set_account_connection_dirty(value=True):
    try:
        win = xbmcgui.Window(10000)
        if value:
            win.setProperty(ACCOUNT_CONNECTION_DIRTY_PROPERTY, 'true')
        else:
            win.clearProperty(ACCOUNT_CONNECTION_DIRTY_PROPERTY)
    except Exception:
        pass

def is_account_connection_dirty():
    try:
        return xbmcgui.Window(10000).getProperty(ACCOUNT_CONNECTION_DIRTY_PROPERTY) == 'true'
    except Exception:
        return False

def consume_account_connection_dirty():
    dirty = is_account_connection_dirty()
    if dirty:
        set_account_connection_dirty(False)
    return dirty

def _load_login_success_marker_state():
    try:
        return storage_runtime.load_json_file(
            LOGIN_SUCCESS_MARKER_PATH,
            default={},
            profile_dir=PROFILE_DIR,
            log_fn=lambda message: log(message, xbmc.LOGDEBUG)
        ) or {}
    except Exception:
        return {}

def _save_login_success_marker_state(key):
    try:
        payload = {'key': key, 'updated_at': int(time.time())}
        return storage_runtime.save_json_file(
            LOGIN_SUCCESS_MARKER_PATH,
            payload,
            profile_dir=PROFILE_DIR,
            log_fn=lambda message: log(message, xbmc.LOGDEBUG)
        )
    except Exception:
        return False

def reset_login_success_notification():
    try:
        xbmcgui.Window(10000).clearProperty(LOGIN_SUCCESS_SESSION_PROPERTY)
    except Exception:
        pass
    try:
        if xbmcvfs.exists(LOGIN_SUCCESS_MARKER_PATH):
            xbmcvfs.delete(LOGIN_SUCCESS_MARKER_PATH)
        elif os.path.exists(LOGIN_SUCCESS_MARKER_PATH):
            os.remove(LOGIN_SUCCESS_MARKER_PATH)
    except Exception:
        pass

def get_login_success_notification_key(user_info=None):
    try:
        info = user_info or {}
        username = str(info.get('username') or USERNAME or '').strip().lower()
        server_choice = str(get_current_server_choice())
        # A chave não usa exp_date de propósito: vencimento pode ser reconsultado pela API,
        # mas o aviso de login deve aparecer apenas uma vez por acesso salvo/servidor.
        return f'{server_choice}|{username}'
    except Exception:
        return 'default'

def was_login_success_notification_shown(user_info=None):
    try:
        key = get_login_success_notification_key(user_info)
        if not key:
            return False
        if xbmcgui.Window(10000).getProperty(LOGIN_SUCCESS_SESSION_PROPERTY) == key:
            return True
        marker_state = _load_login_success_marker_state()
        return str(marker_state.get('key') or '') == key
    except Exception:
        return False

def mark_login_success_notification_shown(user_info=None):
    try:
        key = get_login_success_notification_key(user_info)
        xbmcgui.Window(10000).setProperty(LOGIN_SUCCESS_SESSION_PROPERTY, key)
        _save_login_success_marker_state(key)
    except Exception:
        pass

def refresh_runtime_settings():
    global USERNAME, PASSWORD, ENABLE_EPG, ENABLE_ADULT, ADULT_PIN
    try:
        USERNAME = (ADDON.getSetting('username') or '').strip()
        PASSWORD = (ADDON.getSetting('password') or '').strip()
        ENABLE_EPG = (ADDON.getSetting('enable_epg') or 'false').lower()
        ENABLE_ADULT = (ADDON.getSetting('enable_adult') or 'false').lower()
        ADULT_PIN = get_adult_pin_value()
        if ENABLE_ADULT != 'true':
            try:
                xbmcgui.Window(10000).clearProperty(ADULT_SESSION_PROPERTY)
            except Exception:
                pass
    except Exception as e:
        log(f"Falha ao reler configurações: {e}", xbmc.LOGERROR)
        USERNAME = ''
        PASSWORD = ''
        ENABLE_EPG = 'false'
        ENABLE_ADULT = 'false'
        ADULT_PIN = DEFAULT_ADULT_PIN

def has_addon(addon_id):
    try:
        return xbmc.getCondVisibility(f'System.HasAddon({addon_id})')
    except Exception:
        return False

def strip_kodi_url_headers(url):
    try:
        return (url or '').split('|', 1)[0]
    except Exception:
        return url or ''

def ensure_play_url_headers(url, stream_kind=''):
    try:
        play = url or ''
        kind = str(stream_kind or '').lower()
        if kind == 'live' and is_live_proxy_enabled():
            return strip_kodi_url_headers(play)
        if not play:
            return play

        clean, sep, header_blob = play.partition('|')
        if not sep:
            return play if 'User-Agent=' in play else (play + '|User-Agent=' + USER_AGENT)

        try:
            parsed_headers = urllib.parse.parse_qsl(header_blob, keep_blank_values=True)
        except Exception:
            parsed_headers = []

        has_user_agent = any((str(k or '').strip().lower() == 'user-agent') for k, _v in parsed_headers)
        if has_user_agent:
            return play

        extra = urllib.parse.urlencode([('User-Agent', USER_AGENT)])
        merged = header_blob + ('&' if header_blob else '') + extra
        return clean + '|' + merged
    except Exception:
        return url or ''


def apply_video_info(list_item, info):
    info = info or {}
    try:
        tag = list_item.getVideoInfoTag()
    except Exception:
        tag = None
    try:
        title = info.get('title')
        if tag and title:
            try:
                tag.setTitle(str(title))
            except Exception:
                pass
        plot = info.get('plot')
        if tag and plot:
            try:
                tag.setPlot(str(plot))
            except Exception:
                pass
        mediatype = info.get('mediatype')
        if tag and mediatype:
            try:
                tag.setMediaType(str(mediatype))
            except Exception:
                pass
        if tag and (title or plot or mediatype):
            return
    except Exception:
        pass
    try:
        list_item.setInfo('video', info)
    except Exception:
        pass

def detect_stream_manifest_type(url):
    clean_url = strip_kodi_url_headers(url).lower()
    if '.m3u8' in clean_url or 'output=m3u8' in clean_url or 'type=m3u8' in clean_url:
        return 'hls'
    if '.mpd' in clean_url:
        return 'mpd'
    return ''

def detect_stream_extension(url):
    clean_url = strip_kodi_url_headers(url)
    if not clean_url:
        return ''
    try:
        parsed = urllib.parse.urlparse(clean_url)
        if parsed.path in ('/', '/hlsretry', '/tsdownloader') and parsed.query:
            q = urllib.parse.parse_qs(parsed.query)
            original = q.get('url', [''])[0]
            if original:
                return detect_stream_extension(urllib.parse.unquote(original))
            # Tokens opacos não carregam a extensão da origem. O proxy publica
            # somente esta dica não sensível para o Kodi classificar HLS/TS/VOD.
            media_type = str(q.get('type', [''])[0] or '').strip().lower().lstrip('.')
            if media_type in ('m3u8', 'ts', 'mp4', 'mkv', 'avi', 'mov', 'wmv', 'webm', 'm4v', 'mpg', 'mpeg', 'm4s', 'aac', 'm4a'):
                return media_type
    except Exception:
        pass
    clean_lower = clean_url.lower()
    base = clean_lower.split('?', 1)[0].split('#', 1)[0]
    _, ext = os.path.splitext(base)
    return ext.lstrip('.')

def get_live_proxy_open_mode():
    """Modo de abertura dos canais quando o proxy nativo estiver ativo.

    Valores:
    - ask: pergunta na hora de abrir o canal;
    - m3u8: força HLS/M3U8;
    - ts: força MPEG-TS.
    """
    try:
        raw = (ADDON.getSetting('proxy_live_open_mode') or '').strip().lower()
        if raw:
            if raw in ('1', 'hls (.m3u8)', 'm3u8 (hls)', 'm3u8', 'hls', '.m3u8'):
                return 'm3u8'
            if raw in ('2', 'mpeg-ts (.ts)', 'mpeg-ts', 'mpegts', 'ts', '.ts'):
                return 'ts'
            return 'ask'
    except Exception:
        pass

    # Compatibilidade com o seletor antigo:
    # proxy_live_format: 0=M3U8, 1=MPEG-TS.
    try:
        legacy = (ADDON.getSetting('proxy_live_format') or '').strip().lower()
        if legacy in ('1', 'mpeg-ts (.ts)', 'mpeg-ts', 'mpegts', 'ts', '.ts'):
            return 'ts'
    except Exception:
        pass
    return 'm3u8'


def get_live_proxy_stream_extension():
    """Extensão usada para montar a lista sem abrir diálogo durante a navegação."""
    try:
        if is_live_proxy_enabled() and get_live_proxy_open_mode() == 'ts':
            return 'ts'
    except Exception:
        pass
    return 'm3u8'


def build_live_stream_url_with_extension(base_url, username, password, stream_id, ext='m3u8'):
    ext = (ext or 'm3u8').lower().strip().lstrip('.')
    if ext not in ('m3u8', 'ts'):
        ext = 'm3u8'
    return f'{str(base_url or "").rstrip("/")}/live/{username}/{password}/{stream_id}.{ext}', ext


def build_live_stream_url(base_url, username, password, stream_id):
    ext = 'm3u8'
    try:
        if is_live_proxy_enabled():
            ext = get_live_proxy_stream_extension()
    except Exception:
        ext = 'm3u8'
    return build_live_stream_url_with_extension(base_url, username, password, stream_id, ext)


def force_live_url_extension(url, ext='m3u8'):
    """Troca .m3u8/.ts preservando querystring, fragmento e headers Kodi."""
    ext = (ext or 'm3u8').lower().strip().lstrip('.')
    if ext not in ('m3u8', 'ts'):
        ext = 'm3u8'

    raw = url or ''
    clean, sep, header_blob = raw.partition('|')
    if not clean:
        return raw

    try:
        parsed = urllib.parse.urlsplit(clean)
        path = parsed.path or ''
        if re.search(r'\.(m3u8|ts)$', path, flags=re.IGNORECASE):
            path = re.sub(r'\.(m3u8|ts)$', '.' + ext, path, flags=re.IGNORECASE)
            clean = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, path, parsed.query, parsed.fragment))
        else:
            clean = re.sub(r'\.(m3u8|ts)(?=($|[?#]))', '.' + ext, clean, flags=re.IGNORECASE)
    except Exception:
        clean = re.sub(r'\.(m3u8|ts)(?=($|[?#]))', '.' + ext, clean, flags=re.IGNORECASE)

    if sep and header_blob:
        return clean + '|' + header_blob
    return clean


def resolve_live_proxy_play_extension(title=''):
    """Resolve M3U8/MPEG-TS somente no momento do play, igual ao fluxo Matrix."""
    if not is_live_proxy_enabled():
        return None

    mode = get_live_proxy_open_mode()
    if mode in ('m3u8', 'ts'):
        return mode

    heading = 'Modo de abertura do canal'
    options = ['HLS (.m3u8)', 'MPEG-TS (.ts)']
    try:
        selected = xbmcgui.Dialog().select(heading, options)
    except Exception:
        selected = -1

    selected_map = {0: 'm3u8', 1: 'ts'}
    selected_ext = selected_map.get(selected, '')
    # Registro de auditoria: confirma a escolha realmente retornada pelo Kodi
    # sem escrever URL, usuário ou senha no log.
    try:
        selected_label = 'HLS (.m3u8)' if selected_ext == 'm3u8' else 'MPEG-TS (.ts)' if selected_ext == 'ts' else 'cancelado'
        if is_proxy_debug_enabled():
            log('[Proxy Live] Perguntar sempre: índice=%s; formato=%s.' % (selected, selected_label), xbmc.LOGDEBUG)
    except Exception:
        pass
    return selected_ext


def prepare_live_url_for_proxy_play(url, title='', stream_id=''):
    """Aplica a escolha do usuário antes de entregar a URL ao proxy nativo."""
    selected_ext = resolve_live_proxy_play_extension(title)
    if selected_ext is None:
        return url, ''
    if not selected_ext:
        return '', ''

    try:
        sid = str(stream_id or '').strip()
        endpoint = 'tsdownloader' if selected_ext == 'ts' else 'hlsretry'
        # Evidência da rota sem expor URL/credenciais: facilita validar HLS x TS
        # diretamente no kodi.log em qualquer plataforma.
        try:
            if is_proxy_debug_enabled():
                log('[Proxy Live] Rota efetiva: formato=.%s; endpoint=/%s; stream_id=%s.' % (selected_ext, endpoint, sid or 'indisponível'), xbmc.LOGDEBUG)
        except Exception:
            pass
        base_url = get_base_url().rstrip('/')
        if sid and base_url and USERNAME and PASSWORD:
            return build_live_stream_url_with_extension(base_url, USERNAME, PASSWORD, sid, selected_ext)
    except Exception:
        pass

    return force_live_url_extension(url, selected_ext), selected_ext


def build_live_proxy_playback_title(title, selected_ext=''):
    """Mostra a modalidade escolhida junto do nome do canal, sem mexer no plot/EPG."""
    base_title = str(title or '').strip()
    if not selected_ext:
        return base_title
    ext = str(selected_ext or '').strip().lower().lstrip('.')
    if ext == 'm3u8':
        suffix = '[Proxy HLS]'
    elif ext == 'ts':
        suffix = '[Proxy TS]'
    else:
        return base_title
    if base_title.endswith(' ' + suffix) or base_title.endswith(suffix):
        return base_title

    # Com EPG ativo, o titulo vem como:
    # Canal - [COLOR aquamarine]Programa atual[/COLOR]
    # O sufixo fica no canal, antes do programa.
    marker = ' - [COLOR'
    if marker in base_title:
        return base_title.replace(marker, ' ' + suffix + marker, 1).strip()

    return (base_title + ' ' + suffix).strip()

def build_vod_proxy_playback_title(title):
    """Identifica VOD/episódio reproduzido via proxy local sem alterar título salvo/listado."""
    base_title = str(title or '').strip()
    suffix = '[Proxy VODs]'
    if not base_title:
        return suffix
    if base_title.endswith(' ' + suffix) or base_title == suffix:
        return base_title
    if base_title.startswith(suffix + ' '):
        base_title = base_title[len(suffix):].strip()
    return (base_title + ' ' + suffix).strip()


def detect_stream_mime_type(url, stream_kind=''):
    manifest_type = detect_stream_manifest_type(url)
    if manifest_type == 'hls':
        return 'application/vnd.apple.mpegurl'
    if manifest_type == 'mpd':
        return 'application/dash+xml'

    ext = detect_stream_extension(url)
    if ext == 'ts':
        return 'video/mp2t'
    if ext == 'mp4':
        return 'video/mp4'
    if ext == 'mkv':
        return 'video/x-matroska'
    if ext == 'avi':
        return 'video/x-msvideo'
    if ext == 'mov':
        return 'video/quicktime'
    if ext == 'wmv':
        return 'video/x-ms-wmv'
    if ext == 'flv':
        return 'video/x-flv'
    if ext == 'webm':
        return 'video/webm'
    if ext == 'm4v':
        return 'video/x-m4v'
    if ext in ('mpg', 'mpeg'):
        return 'video/mpeg'

    if stream_kind == 'live':
        return 'video/mp2t'
    return ''

def is_local_oneplay_proxy_url(url):
    clean = strip_kodi_url_headers(url or '')
    return clean.startswith('http://127.0.0.1:') and (
        ('/hlsretry?' in clean) or
        ('/tsdownloader?' in clean) or
        (clean.startswith('http://127.0.0.1:') and '/?' in clean)
    )


def is_local_live_proxy_url(url):
    clean = strip_kodi_url_headers(url or '')
    return clean.startswith('http://127.0.0.1:') and (('/hlsretry?' in clean) or ('/tsdownloader?' in clean))



def should_force_ffmpegdirect_for_live(url):
    """Heurística conservadora para live.

    Regras:
    - Proxy local (HLS/TS): deixa o VideoPlayer nativo do Kodi assumir.
      Isso reduz loading e evita briga de buffering/continuidade com uma origem já mediada localmente.
    - URLs diretas com headers Kodi (pipe): evita ffmpegdirect e deixa o player nativo do Kodi
      abrir a origem, porque esse caminho é mais compatível com headers como Referer/Origin/Cookie.
    - MPEG-DASH direto continua com ffmpegdirect.
    - MPEG-TS direto sem proxy continua elegível para ffmpegdirect.
    - HLS direto sem proxy: preferir player nativo para reduzir loading em fontes sensíveis.
    """
    raw = url or ''
    if not raw:
        return False
    if is_local_live_proxy_url(raw):
        return False
    if '|' in raw:
        return False
    manifest_type = detect_stream_manifest_type(raw)
    ext = detect_stream_extension(raw)
    if manifest_type == 'mpd':
        return True
    if ext == 'ts':
        return True
    return False


def configure_live_stream_listitem(li, url):
    manifest_type = detect_stream_manifest_type(url)
    mime_type = detect_stream_mime_type(url, 'live')

    try:
        li.setContentLookup(False)
    except Exception:
        pass

    if mime_type:
        try:
            li.setMimeType(mime_type)
        except Exception:
            pass

    # Fluxo conservador:
    # - Proxy local e TS direto continuam com ffmpegdirect.
    # - Live direto com headers Kodi ou HLS direto sem proxy cai no player nativo,
    #   preservando compatibilidade e reduzindo loading em fontes mais sensíveis.
    try:
        if has_addon('inputstream.ffmpegdirect') and should_force_ffmpegdirect_for_live(url):
            li.setProperty('inputstream', 'inputstream.ffmpegdirect')
            li.setProperty('inputstream.ffmpegdirect.is_realtime_stream', 'true')
            # Reforça para o Kodi/FFmpegDirect que canais live não devem ser tratados como VOD.
            # Aplicado somente no fluxo live; VOD/episódios seguem intactos.
            li.setProperty('inputstream.ffmpegdirect.playback_as_live', 'true')
            if manifest_type:
                li.setProperty('inputstream.ffmpegdirect.manifest_type', manifest_type)
    except Exception as e:
        log(f"Falha ao configurar inputstream live: {e}", xbmc.LOGDEBUG)

def configure_vod_stream_listitem(li, url, stream_kind='vod'):
    manifest_type = detect_stream_manifest_type(url)
    mime_type = detect_stream_mime_type(url, stream_kind)
    ext = detect_stream_extension(url)

    # Modelo 0.2.28: VOD/Episódio fica mais próximo do XC Pro.
    # O lookup nativo ajuda o Kodi a manter bookmark/retomada sem o modelo
    # próprio do addon.
    try:
        li.setContentLookup(True)
    except Exception:
        pass

    if mime_type:
        try:
            li.setMimeType(mime_type)
        except Exception:
            pass

    # Arquivos diretos (mp4/mkv/avi...) ficam no player nativo.
    # HLS/DASH/TS ganham inputstream quando disponível.
    try:
        if (manifest_type or ext == 'ts') and has_addon('inputstream.ffmpegdirect'):
            li.setProperty('inputstream', 'inputstream.ffmpegdirect')
            li.setProperty('inputstream.ffmpegdirect.is_realtime_stream', 'false')
            if manifest_type:
                li.setProperty('inputstream.ffmpegdirect.manifest_type', manifest_type)
    except Exception as e:
        log(f"Falha ao configurar inputstream VOD: {e}", xbmc.LOGDEBUG)

def b64_decode(text):
    return external_b64_decode(text, log_fn=lambda message: log(message, xbmc.LOGERROR))

def get_base_url():
    return external_get_base_url(ADDON, dns_list=DNS_LIST_B64, log_fn=lambda message: log(message, xbmc.LOGERROR))

PROFILE_DIR = xbmcvfs.translatePath(ADDON.getAddonInfo('profile'))
LOGIN_SUCCESS_MARKER_PATH = os.path.join(PROFILE_DIR, '.login_success_notification_state.json')
PROXY_DEFAULTS_MARKER = os.path.join(PROFILE_DIR, '.proxy_defaults_v033_proxy_disabled_applied')
LEGACY_EPG_XML_PATH = os.path.join(PROFILE_DIR, 'epg.xml')
LEGACY_EPG_META_PATH = os.path.join(PROFILE_DIR, 'epg_meta.json')
LEGACY_EPG_XMLTV_XML_PATH = os.path.join(PROFILE_DIR, 'epg_xmltv.xml')
LEGACY_EPG_XMLTV_META_PATH = os.path.join(PROFILE_DIR, 'epg_xmltv_meta.json')
# REGRA DE PRODUÇÃO: o XML bruto do EPG deve renovar diariamente.
# Não aumentar este TTL sem pedido explícito do usuário, mesmo que a janela do índice cubra mais dias.
EPG_CACHE_DAYS = 1
EPG_TTL = EPG_CACHE_DAYS * 24 * 3600
EPG_INDEX_VERSION = 'oneplayvip_epg_xcpro_window_v3_service_watchdog'
# A janela do índice pode cobrir mais de 1 dia por performance/continuidade,
# mas o cache bruto e o índice precisam ser revalidados diariamente.
EPG_INDEX_WINDOW_DAYS = 2
# Margens usadas só para solicitar refresh em background; a UI não baixa XMLTV pesado.
EPG_REFRESH_MARGIN_SECS = 2 * 3600
# A grade local continua indexada em Hoje + Amanhã. A validação abaixo exige
# cobertura até o fim da janela diária, e não apenas algumas horas à frente.
EPG_MIN_FUTURE_COVERAGE_SECS = 12 * 3600
EPG_MIN_REFRESH_INTERVAL_SECS = 60 * 60
EPG_REQUIRE_FULL_WINDOW = True


DESC_TTL = 7 * 24 * 3600
DESC_EMPTY_TTL = 6 * 3600
LEGACY_VOD_DESC_CACHE_PATH = os.path.join(PROFILE_DIR, 'vod_desc_cache.json')
LEGACY_SERIES_DESC_CACHE_PATH = os.path.join(PROFILE_DIR, 'series_desc_cache.json')
LEGACY_SEARCH_STATE_PATH = os.path.join(PROFILE_DIR, 'search_state.json')
LEGACY_FAVORITES_PATH = os.path.join(PROFILE_DIR, 'favorites.json')
SCOPED_STORAGE_ROOT = os.path.join(PROFILE_DIR, 'profiles')
VALIDATED_SCOPE_STATE_PATH = os.path.join(PROFILE_DIR, '.validated_scope_state.json')
VOD_DESC_MIGRATION_MARKER = os.path.join(PROFILE_DIR, '.vod_desc_scoped_migrated')
SERIES_DESC_MIGRATION_MARKER = os.path.join(PROFILE_DIR, '.series_desc_scoped_migrated')
SEARCH_MIGRATION_MARKER = os.path.join(PROFILE_DIR, '.search_scoped_migrated')
FAVORITES_MIGRATION_MARKER = os.path.join(PROFILE_DIR, '.favorites_scoped_migrated')
EPG_XML_MIGRATION_MARKER = os.path.join(PROFILE_DIR, '.epg_xml_scoped_migrated')
EPG_META_MIGRATION_MARKER = os.path.join(PROFILE_DIR, '.epg_meta_scoped_migrated')
MAX_DESC_CACHE_ENTRIES = 50000
VOD_DESC_HYDRATE_QUEUE_MAX = 30000
MAX_EPG_INDEX_FILES = 8
CATEGORY_COUNT_CACHE_TTL = 15 * 60
CATEGORY_COUNT_CACHE_VERSION = 'oneplayvip_category_counts_v1'
ADULT_PIN_FILE = os.path.join(PROFILE_DIR, 'adult_pin.txt')


def _atomic_write_text_runtime(path, text, encoding='utf-8'):
    return storage_runtime.atomic_write_text(path, text, profile_dir=PROFILE_DIR, encoding=encoding)


def _normalize_setting_string(value):
    return str(value or '').strip().lower()


def _seed_setting_if_missing(setting_id, default_value, allowed_values=None):
    try:
        current_value = _normalize_setting_string(ADDON.getSetting(setting_id))
    except Exception:
        current_value = ''
    if allowed_values is None:
        allowed_values = {'true', 'false'}
    allowed_values = {str(v).strip().lower() for v in allowed_values if str(v).strip() != ''}
    if current_value in allowed_values:
        return False
    ADDON.setSetting(setting_id, str(default_value))
    return True


def ensure_proxy_native_defaults():
    """Semeia defaults operacionais do proxy sem sobrescrever escolhas válidas.

    VOD e Live permanecem independentes, mas compartilham a mesma política de
    resiliência. Instalações novas iniciam com ambos os proxies desligados;
    perfil Estável e tolerância Xtream seguem prontos para uso quando o usuário
    decidir ativar um ou os dois controles. Instalações já configuradas mantêm
    sua escolha válida sem qualquer sobrescrita.
    """
    try:
        if xbmcvfs.exists(PROXY_DEFAULTS_MARKER) or os.path.exists(PROXY_DEFAULTS_MARKER):
            return
    except Exception:
        pass

    try:
        os.makedirs(PROFILE_DIR, exist_ok=True)
    except Exception:
        try:
            if not xbmcvfs.exists(PROFILE_DIR):
                xbmcvfs.mkdirs(PROFILE_DIR)
        except Exception:
            pass

    try:
        # Semeia somente valores ausentes ou inválidos. Os dois proxies permanecem
        # desligados por padrão; escolhas já salvas (inclusive "true") são preservadas.
        _seed_setting_if_missing('proxy_vod_enabled', 'false')
        _seed_setting_if_missing('proxy_live_enabled', 'false')
        _seed_setting_if_missing('proxy_mode', '2', allowed_values={'0', '1', '2'})
        _seed_setting_if_missing('proxy_live_open_mode', '0', allowed_values={'0', '1', '2'})
        _seed_setting_if_missing('proxy_live_format', '0', allowed_values={'0', '1'})
        _seed_setting_if_missing('proxy_host_memory', 'false')
        _seed_setting_if_missing('proxy_xtream_hardening', 'true')
        _seed_setting_if_missing('proxy_debug', 'false')
        try:
            # Compatibilidade invisível com builds que ainda consultam este ID.
            ADDON.setSetting('proxy_defaults_v331_applied', 'true')
        except Exception:
            pass
        _atomic_write_text_runtime(PROXY_DEFAULTS_MARKER, str(int(time.time())))
    except Exception as e:
        try:
            log(f'Falha ao aplicar defaults operacionais do proxy 0.3.0: {e}', xbmc.LOGDEBUG)
        except Exception:
            pass


ADULT_MARKERS = ('18+', '+18', '18 +', '+ 18', 'xxx', 'porn', 'porno', 'sex', 'adulttime', 'adulto', 'onlyfans', 'only fans', 'privacy', 'novinha', 'novinho', 'interracialsexx', 'mypervyfamily', 'brazzersexxtra', 'blacked raw', 'brad montana', 'mia khalifa', 'brasileirinhas')

def sanitize_adult_pin(value):
    value = ''.join(ch for ch in (value or '') if ch.isdigit())
    return value if len(value) == 4 else ''

def read_adult_pin_file():
    try:
        if not os.path.exists(ADULT_PIN_FILE):
            return ''
        with open(ADULT_PIN_FILE, 'r', encoding='utf-8') as fh:
            return sanitize_adult_pin(fh.read().strip())
    except Exception:
        return ''

def write_adult_pin_file(pin):
    pin = sanitize_adult_pin(pin)
    if not pin:
        return False
    try:
        os.makedirs(PROFILE_DIR, exist_ok=True)
    except Exception:
        try:
            if not xbmcvfs.exists(PROFILE_DIR):
                xbmcvfs.mkdirs(PROFILE_DIR)
        except Exception:
            pass
    try:
        return atomic_write_text(ADULT_PIN_FILE, pin)
    except Exception:
        return False

def save_adult_pin(pin):
    pin = sanitize_adult_pin(pin)
    if not pin:
        return False
    saved = False
    try:
        ADDON.setSetting('adult_pin', pin)
        saved = True
    except Exception:
        pass
    if write_adult_pin_file(pin):
        saved = True
    return saved

def get_adult_pin_value():
    pin_from_file = read_adult_pin_file()
    pin_from_setting = sanitize_adult_pin(ADDON.getSetting('adult_pin') or '')
    current = pin_from_file or pin_from_setting or DEFAULT_ADULT_PIN

    if pin_from_file:
        if pin_from_setting != pin_from_file:
            try:
                ADDON.setSetting('adult_pin', pin_from_file)
            except Exception:
                pass
    elif pin_from_setting:
        write_adult_pin_file(pin_from_setting)
    else:
        save_adult_pin(current)

    return current
_TAG_RE = re.compile(r'<[^>]+>')

# =========================
# Utilitários
# =========================
def clear_desc_caches():
    try:
        vod_path = vod_desc_cache_storage_path()
        series_path = series_desc_cache_storage_path()
        safe_delete_file(vod_path)
        safe_delete_file(series_path)
        log('Caches de sinopses do servidor atual limpos com sucesso.')
        return True
    except Exception as e:
        log(f"Falha ao limpar cache de sinopse: {e}", xbmc.LOGERROR)
        return False

def clear_search_cache():
    try:
        search_path = search_state_storage_path()
        safe_delete_file(search_path)
        log('Estado de busca limpo com sucesso.')
        return True
    except Exception as exc:
        log(f'Falha ao limpar estado de busca: {exc}', xbmc.LOGERROR)
        return False

def clear_favorites_cache():
    try:
        favorites_path = favorites_storage_path()
        safe_delete_file(favorites_path)
        log('Favoritos do servidor atual limpos com sucesso.')
        return True
    except Exception as exc:
        log(f'Falha ao limpar favoritos: {exc}', xbmc.LOGERROR)
        return False

def clear_epg_cache():
    try:
        paths = [
            epg_xml_storage_path(),
            epg_meta_storage_path(),
            server_slot_storage_path('epg_xmltv.xml'),
            server_slot_storage_path('epg_xmltv_meta.json'),
            server_slot_storage_path('epg.xml'),
            server_slot_storage_path('epg_meta.json'),
            LEGACY_EPG_XMLTV_XML_PATH,
            LEGACY_EPG_XMLTV_META_PATH,
            LEGACY_EPG_XML_PATH,
            LEGACY_EPG_META_PATH,
        ]
        seen = set()
        for path in paths:
            if path and path not in seen:
                seen.add(path)
                safe_delete_file(path)
        epg_clear_index_cache()
        epg_runtime_cache_invalidate('clear_epg_cache')
        log('EPG do servidor atual limpo com sucesso.')
        return True
    except Exception as exc:
        log(f'Falha ao limpar EPG: {exc}', xbmc.LOGERROR)
        return False

def clear_all_current_server_state():
    # A retomada é nativa do Kodi; a limpeza mantém somente dados que o addon realmente administra.
    results = [
        clear_desc_caches(),
        clear_search_cache(),
        clear_epg_cache(),
        clear_favorites_cache(),
    ]
    return all(results)

def open_cleanup_dialog():
    options = [
        'Limpar cache de sinopses',
        'Limpar histórico de busca',
        'Limpar EPG',
        'Limpar Favoritos',
        'Limpar tudo do servidor atual',
    ]
    selected = xbmcgui.Dialog().select('Limpeza', options)
    if selected < 0:
        return False

    if selected == 0:
        ok = clear_desc_caches()
        if ok:
            notify('Cache de sinopses limpo.', heading='Limpeza', iconimage='INFO')
        else:
            show_dialog('Limpeza', 'Não foi possível limpar o cache de sinopses.')
        return ok

    if selected == 1:
        ok = clear_search_cache()
        if ok:
            notify('Histórico de busca limpo.', heading='Limpeza', iconimage='INFO')
        else:
            show_dialog('Limpeza', 'Não foi possível limpar o histórico de busca.')
        return ok

    if selected == 2:
        ok = clear_epg_cache()
        if ok:
            notify('Dados do EPG limpos.', heading='Limpeza', iconimage='INFO')
        else:
            show_dialog('Limpeza', 'Não foi possível limpar o EPG.')
        return ok

    if selected == 3:
        if not xbmcgui.Dialog().yesno('Limpeza', 'Deseja remover todos os favoritos salvos para o servidor atual?'):
            return False
        ok = clear_favorites_cache()
        if ok:
            notify('Favoritos do servidor atual limpos.', heading='Limpeza', iconimage='INFO')
        else:
            show_dialog('Limpeza', 'Não foi possível limpar os favoritos.')
        return ok

    if not xbmcgui.Dialog().yesno('Limpeza', 'Deseja limpar caches, busca, EPG e Favoritos do servidor atual?'):
        return False
    ok = clear_all_current_server_state()
    if ok:
        notify('Limpeza do servidor atual concluída.', heading='Limpeza', iconimage='INFO')
    else:
        show_dialog('Limpeza', 'A limpeza foi concluída parcialmente. Alguns dados não puderam ser removidos.')
    return ok

def format_expire_warning_line(days_left, exp_date=None):
    if days_left is None:
        return None, None

    if days_left >= 2:
        return (
            f"[COLOR white]Seu acesso vence em [B][COLOR yellow]{days_left} dias[/COLOR][/B].[/COLOR]",
            "[COLOR white]Para evitar interrupção, renove seu plano antes da data de vencimento.[/COLOR]"
        )

    if days_left == 1:
        return (
            "[COLOR white]Seu acesso vence [B][COLOR yellow]amanhã[/COLOR][/B].[/COLOR]",
            "[COLOR white]Para evitar interrupção, renove seu plano antes da data de vencimento.[/COLOR]"
        )

    if days_left == 0:
        try:
            exp_ts = int(str(exp_date or '').strip())
            if exp_ts > 0:
                expire_time = datetime.fromtimestamp(exp_ts).strftime('%H:%M')
                return (
                    f"[COLOR white]Seu acesso vence [B][COLOR yellow]hoje às {expire_time}[/COLOR][/B].[/COLOR]",
                    "[COLOR white]Para evitar interrupção, renove seu plano antes desse horário.[/COLOR]"
                )
        except Exception:
            pass
        return (
            "[COLOR white]Seu acesso vence [B][COLOR yellow]hoje[/COLOR][/B].[/COLOR]",
            "[COLOR white]Para evitar interrupção, renove seu plano ainda hoje.[/COLOR]"
        )

    return None, None

def show_expire_popup(days_left, exp_date=None):
    if is_silent_action_mode():
        return

    if days_left is None:
        return

    try:
        warn_days = int(ADDON.getSetting('expire_warning_days')) + 1
    except Exception:
        warn_days = 3

    dialog = xbmcgui.Dialog()

    def center_text(text):
        # Adiciona linhas no topo e no final para simular centralização
        return "\n\n\n" + text + "\n\n\n"

    if days_left < 0:
        # Conta vencida
        message = center_text(
            "[COLOR red][B]SEU ACESSO ESTÁ VENCIDO[/B][/COLOR]\n"
            "[COLOR white][B]Renove agora para continuar usando o serviço.[/B][/COLOR]"
        )
        dialog.ok("[COLOR white][B]ACESSO VENCIDO[/B][/COLOR]", message)
        return

    if days_left <= warn_days:
        due_line, help_line = format_expire_warning_line(days_left, exp_date)
        if not due_line:
            return
        message = center_text(due_line + "\n" + help_line)
        dialog.ok("[COLOR white][B]AVISO DE VENCIMENTO[/B][/COLOR]", message)

def format_account_expiry_datetime(exp_date):
    try:
        exp_ts = int(str(exp_date or '').strip())
        if exp_ts > 0:
            return datetime.fromtimestamp(exp_ts).strftime('%d/%m/%Y às %H:%M')
    except Exception:
        pass
    return 'Não informado'

def show_login_success_popup(user_info):
    if is_silent_action_mode():
        return
    if was_login_success_notification_shown(user_info):
        return
    try:
        exp_text = format_account_expiry_datetime((user_info or {}).get('exp_date', 0))
        notify(
            f'Login realizado com sucesso! Acesso válido até: {exp_text}',
            heading='[COLOR aquamarine][B]ONE[COLOR white]PLAY [COLOR aquamarine]VIP[/B][/COLOR]',
            iconimage='INFO',
            time_ms=8000,
            sound=False
        )
        mark_login_success_notification_shown(user_info)
    except Exception as exc:
        log(f'Falha ao exibir notificação de acesso validado: {exc}', xbmc.LOGDEBUG)

def color(text, color_name):
    return f"[COLOR {color_name}]{text}[/COLOR]"

def format_date(text):
    return color(text, 'lightblue')

def get_status_color(status):
    status = (status or '').lower()
    if status in ['active', 'ativo']:
        return 'lime'
    if status in ['expired', 'expirado', 'disabled']:
        return 'red'
    return 'orange'

def sanitize_log_message(msg):
    """Redige autenticação Xtream de mensagens e exceções antes do kodi.log."""
    try:
        text = str(msg or '')
    except Exception:
        text = 'falha sem detalhe legível'
    text = re.sub(r'(?i)\b(username|password)=([^&\s]+)', lambda m: f'{m.group(1)}=***', text)
    text = re.sub(r'(?i)(https?://)([^/@\s:]+):([^/@\s]+)@', r'\1***:***@', text)
    text = re.sub(r'(?i)(/(?:live|movie|series)/)([^/\s?#]+)/([^/\s?#]+)(/)', r'\1***/***\4', text)
    return text[:2000]


def log(msg, level=xbmc.LOGDEBUG):
    xbmc.log(f"[{ADDON_ID}] {sanitize_log_message(msg)}", level)

def is_custom_dns_enabled():
    try:
        return (ADDON.getSetting('enable_custom_dns') or 'false').lower() == 'true'
    except Exception:
        return False


def configure_custom_dns_runtime():
    """Ativa o resolvedor DNS alternativo apenas quando a opção estiver ligada.

    Conservador por padrão: desligado não muda a pilha de rede do addon.
    Quando ligado, afeta chamadas Python/requests e o proxy local; o player direto do Kodi
    continua usando a resolução nativa do sistema.
    """
    try:
        if dns_runtime is None:
            return False
        if is_custom_dns_enabled():
            return bool(dns_runtime.install(cache_ttl=3600, mode_logger=False))
        return bool(dns_runtime.uninstall())
    except Exception as exc:
        try:
            log(f'Falha ao configurar resolvedor DNS alternativo: {exc}', xbmc.LOGDEBUG)
        except Exception:
            pass
        return False

configure_custom_dns_runtime()
refresh_runtime_settings()
ensure_proxy_native_defaults()
refresh_runtime_settings()

def is_silent_action_mode():
    try:
        return mode in SILENT_ACTION_MODES
    except Exception:
        return False

def should_suppress_account_dialog(title=''):
    if not is_silent_action_mode():
        return False
    normalized = (title or '').strip().lower()
    blocked_titles = {
        'configuração necessária',
        'erro de acesso',
        'acesso bloqueado',
        'conta vencida',
        'erro de comunicação',
        'erro de conexão'
    }
    return normalized in blocked_titles

def settle_after_settings_action(delay_ms=350):
    if is_silent_action_mode():
        try:
            xbmc.sleep(int(delay_ms))
        except Exception:
            pass

def show_dialog(title, message):
    if should_suppress_account_dialog(title):
        log(f"Diálogo suprimido no modo {mode}: {title} - {message}", xbmc.LOGDEBUG)
        return False
    return xbmcgui.Dialog().ok(title, message)

def yesno_default_yes(title, message, yeslabel='Sim', nolabel='Não'):
    dialog = xbmcgui.Dialog()
    try:
        return dialog.yesno(
            title,
            message,
            nolabel=nolabel,
            yeslabel=yeslabel,
            defaultbutton=getattr(xbmcgui, 'DLG_YESNO_YES_BTN', 1),
        )
    except TypeError:
        return dialog.yesno(title, message, nolabel=nolabel, yeslabel=yeslabel)

def prompt_text_input(title, default='', hidden=False):
    try:
        keyboard = xbmc.Keyboard(default or '', title, bool(hidden))
        keyboard.doModal()
        if not keyboard.isConfirmed():
            return None
        return (keyboard.getText() or '').strip()
    except Exception as exc:
        log(f'Falha ao abrir teclado para {title}: {exc}', xbmc.LOGERROR)
        return None

def get_platform_name():
    try:
        if xbmc.getCondVisibility('system.platform.android'):
            return 'android'
        if xbmc.getCondVisibility('system.platform.windows'):
            return 'windows'
        if xbmc.getCondVisibility('system.platform.osx'):
            return 'osx'
        if xbmc.getCondVisibility('system.platform.ios') or xbmc.getCondVisibility('system.platform.darwin'):
            return 'ios'
        if xbmc.getCondVisibility('system.platform.linux') or xbmc.getCondVisibility('system.platform.linux.Raspberrypi'):
            return 'linux'
    except Exception:
        pass
    return 'unknown'

def decode_b64_url(data):
    try:
        missing_padding = len(data or '') % 4
        if missing_padding:
            data += '=' * (4 - missing_padding)
        return base64.b64decode((data or '').encode('utf-8')).decode('utf-8')
    except Exception as e:
        log(f'Falha ao decodificar URL de APK: {e}', xbmc.LOGERROR)
        return ''

def get_apk_entries():
    items = []
    for name, b64_url in APK_ENTRIES_B64:
        url = decode_b64_url(b64_url)
        if url:
            items.append((name, url))
    return items

def notify(message, heading=None, iconimage=None, time_ms=3500, sound=False):
    heading = heading or ADDON.getAddonInfo('name')
    if iconimage == 'INFO':
        iconimage = xbmcgui.NOTIFICATION_INFO
    elif iconimage == 'WARNING':
        iconimage = xbmcgui.NOTIFICATION_WARNING
    elif iconimage == 'ERROR':
        iconimage = xbmcgui.NOTIFICATION_ERROR
    elif iconimage is None:
        iconimage = addonIcon
    xbmcgui.Dialog().notification(heading, message, iconimage, time_ms, sound=sound)

def normalize_bool(value):
    return str(value or '').strip().lower() in ('1', 'true', 'yes', 'on')

def is_global_search_enabled():
    return True

def is_favorites_enabled():
    return True

def render_account_connection_menu():
    # A própria rota também protege contra uma lista antiga guardada pelo Kodi:
    # sem credenciais, não pode reconstruir os itens de conta.
    refresh_runtime_settings()
    if not has_valid_credentials():
        render_root_menu()
        return True
    build_menu(build_account_connection_items())
    return True

def render_root_menu():
    build_root_menu()
    return True

def _normalize_external_origin(value):
    return str(value or '').strip().lower()

def set_external_origin(origin=''):
    """Registra somente uma origem externa previamente permitida na sessão Kodi."""
    try:
        win = xbmcgui.Window(10000)
        origin = _normalize_external_origin(origin)
        if origin == EXTERNAL_ORIGIN_ONEPLAY_MATRIX:
            win.setProperty(EXTERNAL_ORIGIN_PROPERTY, origin)
            return True
        win.clearProperty(EXTERNAL_ORIGIN_PROPERTY)
    except Exception:
        pass
    return False

def get_external_origin():
    try:
        return _normalize_external_origin(
            xbmcgui.Window(10000).getProperty(EXTERNAL_ORIGIN_PROPERTY)
        )
    except Exception:
        return ''

def sync_external_origin_context():
    """Aceita a origem Matrix apenas na abertura recebida por URL.

    Aberturas diretas da raiz limpam qualquer marcador remanescente; navegação
    interna não altera a sessão para não perder a origem antes do logout.
    """
    incoming = _normalize_external_origin(get_param('origin', ''))
    if incoming == EXTERNAL_ORIGIN_ONEPLAY_MATRIX:
        set_external_origin(incoming)
        log('Origem de sessão registrada: OnePlay Matrix.', xbmc.LOGINFO)
        return incoming
    if mode == 'main' and not incoming:
        set_external_origin('')
    return ''

def _is_oneplay_matrix_available():
    try:
        xbmcaddon.Addon(ONEPLAY_MATRIX_ADDON_ID)
        return True
    except Exception:
        return False

def request_logout_home_navigation():
    """Substitui a pilha atual pelo destino correto após o logout.

    - Abertura direta: preserva a raiz deslogada do próprio VIP.
    - Abertura marcada pelo OnePlay Matrix: substitui o VIP pela raiz Matrix.
    """
    origin = get_external_origin()
    # A marca é transitória e nunca pode sobreviver a um logout.
    set_external_origin('')
    try:
        win = xbmcgui.Window(10000)
        win.clearProperty(LOGOUT_HOME_REQUEST_PROPERTY)

        if origin == EXTERNAL_ORIGIN_ONEPLAY_MATRIX and _is_oneplay_matrix_available():
            xbmc.executebuiltin(
                f'Container.Update({ONEPLAY_MATRIX_ROOT_URL},replace)'
            )
            log('Logout confirmado: retorno solicitado para a raiz do OnePlay Matrix.', xbmc.LOGINFO)
            return True

        # URL base, sem rota auxiliar: uma única raiz deslogada do VIP.
        win.setProperty(LOGOUT_HOME_REQUEST_PROPERTY, 'true')
        xbmc.executebuiltin(f'Container.Update({sys.argv[0]},replace)')
        log('Logout confirmado: raiz canônica sem credenciais solicitada.', xbmc.LOGINFO)
        return True
    except Exception as exc:
        try:
            xbmcgui.Window(10000).clearProperty(LOGOUT_HOME_REQUEST_PROPERTY)
        except Exception:
            pass
        log(f'Falha ao abrir o destino após logout: {exc}', xbmc.LOGERROR)
        return False

def consume_logout_home_request():
    """Consome o sinal transitório que impede QR/configuração automática."""
    try:
        win = xbmcgui.Window(10000)
        requested = win.getProperty(LOGOUT_HOME_REQUEST_PROPERTY) == 'true'
        if requested:
            win.clearProperty(LOGOUT_HOME_REQUEST_PROPERTY)
        return requested
    except Exception:
        return False

# REGRA DE PRODUÇÃO KODI (NÃO ALTERAR SEM NECESSIDADE REAL):
# Em rotas não-pasta e em retornos após ajustes/toggles, preferir Container.Refresh
# ou rebuild inline do menu visível. Em alguns builds do Kodi, Container.Update(...,replace)
# empilha histórico/menus e duplica navegação. Só usar Update para navegação real quando
# Refresh ou rebuild inline não forem suficientes.
def refresh_container():
    try:
        xbmc.executebuiltin('Container.Refresh')
    except Exception:
        pass


def rebuild_enter_menu_inline(show_message=False, title='', message=''):
    if show_message and message:
        show_dialog(title or 'OnePlayVIP', message)
    try:
        build_menu(get_enter_menu_items())
        return True
    except Exception as exc:
        log(f'Falha ao reconstruir menu Enter inline: {exc}', xbmc.LOGERROR)
        try:
            refresh_container()
        except Exception:
            pass
        return False



def end_directory(cache_to_disc=True):
    try:
        xbmcplugin.endOfDirectory(ADDON_HANDLE, succeeded=True, updateListing=False, cacheToDisc=cache_to_disc)
    except TypeError:
        xbmcplugin.endOfDirectory(ADDON_HANDLE, True, False, cache_to_disc)

def show_install_apk_help(download_path=''):
    settle_after_settings_action(250)
    filename = ntpath.basename(download_path) if download_path else ''
    target_line = (
        f'3. Abra a pasta [COLOR lime]Download[/COLOR] e toque em [COLOR lime]{filename}[/COLOR].'
        if filename else
        '3. Abra a pasta [COLOR lime]Download[/COLOR] e toque no APK baixado.'
    )
    lines = [
        '1. Feche o Kodi.',
        '2. Abra o gerenciador de arquivos do Android.',
        target_line,
        '4. Se o Android bloquear, libere [COLOR yellow]Instalar apps desconhecidos[/COLOR].',
        '5. Faça a instalação e abra o aplicativo.'
    ]
    xbmcgui.Dialog().ok('Como instalar o APK', '\n'.join(lines))


def get_apk_filename_from_url(url):
    try:
        filename = ntpath.basename((url or '').split('?', 1)[0]).strip()
        if filename and filename.lower().endswith('.apk'):
            return filename
    except Exception:
        pass
    return ''

def select_apk_url():
    items = get_apk_entries()
    if not items:
        show_dialog('Download de APK', 'Nenhum APK disponível no momento.')
        return ''

    if len(items) == 1:
        return items[0][1]

    names = [name for name, _url in items]
    urls = [_url for _name, _url in items]
    selected = xbmcgui.Dialog().select('Selecionar APK para download', names)
    if selected < 0:
        return ''
    return urls[selected]

def download_progress_hook(numblocks, blocksize, filesize, dp):
    try:
        downloaded = numblocks * blocksize
        percent = int(min((downloaded * 100) / filesize, 100)) if filesize > 0 else 0
        elapsed = max(time.time() - download_progress_hook.start_time, 0.001)
        speed_kbps = (downloaded / elapsed) / 1024.0
        eta_seconds = ((filesize - downloaded) / (downloaded / elapsed)) if filesize > 0 and downloaded > 0 else 0
        downloaded_mb = float(downloaded) / (1024 * 1024)
        total_mb = float(filesize) / (1024 * 1024) if filesize > 0 else 0
        mins, secs = divmod(max(int(eta_seconds), 0), 60)
        message = (
            f'{downloaded_mb:.2f} MB de {total_mb:.2f} MB\n'
            f'[COLOR yellow]Velocidade:[/COLOR] {speed_kbps:.02f} KB/s  '
            f'[COLOR yellow]Tempo restante:[/COLOR] {mins:02d}:{secs:02d}'
        )
        dp.update(percent, message)
    except Exception:
        try:
            dp.update(0, 'Baixando arquivo...')
        except Exception:
            pass

    if dp.iscanceled():
        dp.close()
        raise Exception('Download cancelado pelo usuário.')

def perform_apk_download(url, destination_path):
    filename = get_apk_filename_from_url(url) or ntpath.basename(destination_path)
    dp = xbmcgui.DialogProgress()
    dp.create(f'Baixando {filename}...', 'Por favor, aguarde...')
    try:
        opener = build_opener()
        opener.addheaders = [('User-Agent', USER_AGENT)]
        install_opener(opener)
        download_progress_hook.start_time = time.time()
        urlretrieve(url, destination_path, lambda nb, bs, fs: download_progress_hook(nb, bs, fs, dp))
        dp.update(100, 'Finalizando download...')
        return True
    except Exception as e:
        try:
            if xbmcvfs.exists(destination_path):
                xbmcvfs.delete(destination_path)
        except Exception:
            pass
        try:
            if os.path.exists(destination_path):
                os.remove(destination_path)
        except Exception:
            pass
        log(f'Falha no download do APK: {e}', xbmc.LOGERROR)
        notify('Não foi possível baixar o APK.', heading='Download de APK', iconimage='ERROR')
        return False
    finally:
        try:
            dp.close()
        except Exception:
            pass

def handle_apk_download():
    settle_after_settings_action(400)

    platform_name = get_platform_name()
    if platform_name != 'android':
        show_dialog(
            'Download de APK',
            '[COLOR white]Este download está disponível apenas em[/COLOR] [COLOR aquamarine][B]dispositivos Android[/B][/COLOR][COLOR white].[/COLOR]\n'
            '[COLOR white]Use celular, tablet ou TV Box com[/COLOR] [COLOR aquamarine][B]Android[/B][/COLOR] [COLOR white]para baixar o APK.[/COLOR]'
        )
        return

    url = select_apk_url()
    if not url:
        return

    filename = get_apk_filename_from_url(url)
    if not filename:
        show_dialog('Download de APK', 'Não foi possível identificar o arquivo APK para download.')
        return

    if not xbmcvfs.exists(APK_DOWNLOAD_FOLDER_ANDROID):
        xbmcvfs.mkdirs(APK_DOWNLOAD_FOLDER_ANDROID)

    destination_path = os.path.join(APK_DOWNLOAD_FOLDER_ANDROID, filename)

    try:
        if xbmcvfs.exists(destination_path):
            xbmcvfs.delete(destination_path)
    except Exception:
        pass

    if perform_apk_download(url, destination_path):
        try:
            xbmc.sleep(250)
        except Exception:
            pass

        xbmcgui.Dialog().ok(
            'Download concluído',
            '[COLOR yellow]APK baixado com sucesso.[/COLOR]\n'
            f'Arquivo salvo em:\n[COLOR lime]{destination_path}[/COLOR]\n\n'
            '[COLOR white]Toque em OK para ver as instruções de instalação.[/COLOR]'
        )

        try:
            xbmc.sleep(300)
        except Exception:
            pass
        show_install_apk_help(destination_path)

class QRCodeDialog(xbmcgui.WindowXMLDialog):
    CONTROL_IMAGE = 1001
    CONTROL_OK = 1002
    ACTION_PREVIOUS_MENU = 10
    ACTION_NAV_BACK = 92
    ACTION_BACKSPACE = 110

    def __init__(self, *args, **kwargs):
        self.image_path = kwargs.pop('image_path', '')
        super().__init__(*args, **kwargs)

    def onInit(self):
        try:
            image_control = self.getControl(self.CONTROL_IMAGE)
            try:
                image_control.setImage(self.image_path, useCache=False)
            except TypeError:
                image_control.setImage(self.image_path)
        except Exception as e:
            log(f"Falha ao carregar imagem do QR no diálogo: {e}", xbmc.LOGERROR)

        try:
            self.setFocusId(self.CONTROL_OK)
        except Exception:
            pass

    def onClick(self, control_id):
        if control_id == self.CONTROL_OK:
            self.close()

    def onAction(self, action):
        try:
            action_id = action.getId()
        except Exception:
            action_id = int(action)

        if action_id in (self.ACTION_PREVIOUS_MENU, self.ACTION_NAV_BACK, self.ACTION_BACKSPACE):
            self.close()
        else:
            super().onAction(action)

def show_qr_code():
    message = (
        "[B]Escaneie o QR Code para falar com o suporte[/B]\n\n"
        "[COLOR white]Se o QR não abrir, use o WhatsApp:[/COLOR]\n"
        f"[COLOR gold]{SUPPORT_WHATSAPP}[/COLOR]"
    )

    if xbmcvfs.exists(QR_IMAGE_PATH):
        try:
            dialog = QRCodeDialog('QRCodeDialog.xml', ADDON.getAddonInfo('path'), 'Default', '1080i', image_path=QR_IMAGE_PATH)
            dialog.doModal()
            del dialog
            return
        except Exception as e:
            log(f"Falha ao abrir QR Code em janela customizada: {e}", xbmc.LOGERROR)

    xbmcgui.Dialog().ok('Suporte / WhatsApp', message)

def is_adult_name(text):
    lowered = (text or '').strip().lower()
    return bool(lowered and any(marker in lowered for marker in ADULT_MARKERS))

def is_adult_enabled():
    return ENABLE_ADULT.lower() == 'true'

def clear_adult_session():
    try:
        xbmcgui.Window(10000).clearProperty(ADULT_SESSION_PROPERTY)
    except Exception:
        pass

def prompt_current_adult_pin(title='Conteúdo Adulto'):
    expected = get_adult_pin_value()
    entered = xbmcgui.Dialog().input(title, type=xbmcgui.INPUT_NUMERIC, option=xbmcgui.ALPHANUM_HIDE_INPUT)
    if entered is None:
        return False
    return sanitize_adult_pin(entered) == expected

def ensure_adult_access(label='Conteúdo Adulto'):
    if not is_adult_enabled():
        show_dialog('Conteúdo Adulto', 'O conteúdo adulto está desativado nas configurações do addon.')
        return False
    if prompt_current_adult_pin(f'PIN Adulto - {label}'):
        return True
    show_dialog('Conteúdo Adulto', 'PIN inválido. Tente novamente.')
    return False

def build_url(**kwargs):
    return sys.argv[0] + '?' + urllib.parse.urlencode(kwargs)


def stream_identity_is_rebuildable(item):
    """Indica quando a URL autenticada pode ser criada somente no play."""
    if not isinstance(item, dict):
        return False
    kind = str(item.get('stream_kind') or '').strip().lower()
    stream_id = str(item.get('stream_id') or '').strip()
    if kind == 'live':
        return bool(stream_id)
    if kind == 'vod':
        return bool(str(item.get('vod_id') or stream_id).strip())
    if kind == 'episode':
        return bool(stream_id)
    return False


def build_stream_url_from_identity(stream_kind='', stream_id='', vod_id='', container_extension='', stream_type=''):
    """Reconstrói a origem em memória, sem transportá-la na URL do plugin."""
    kind = str(stream_kind or '').strip().lower()
    item_id = str(vod_id or stream_id or '').strip()
    if not item_id or not re.match(r'^[A-Za-z0-9_-]+$', item_id):
        return ''
    base_url = str(get_base_url() or '').strip().rstrip('/')
    if not base_url or not USERNAME or not PASSWORD:
        return ''
    ext = str(container_extension or '').strip().lower().lstrip('.')
    if not re.match(r'^[a-z0-9]{1,8}$', ext):
        ext = 'm3u8' if kind == 'live' else 'mp4'
    if kind == 'live':
        if ext not in ('m3u8', 'ts'):
            ext = 'm3u8'
        return build_live_stream_url_with_extension(base_url, USERNAME, PASSWORD, item_id, ext)[0]
    if kind == 'vod':
        route = str(stream_type or 'movie').strip().lower()
        if not re.match(r'^[a-z0-9_-]{1,24}$', route):
            route = 'movie'
        return f'{base_url}/{route}/{USERNAME}/{PASSWORD}/{item_id}.{ext}'
    if kind == 'episode':
        return f'{base_url}/series/{USERNAME}/{PASSWORD}/{item_id}.{ext}'
    return ''


def sanitize_search_results(results):
    """Remove URLs reconstruíveis antes de persistir a Pesquisa Global."""
    if not isinstance(results, list):
        return []
    clean_results = []
    for entry in results:
        if not isinstance(entry, dict):
            continue
        clean_entry = dict(entry)
        if stream_identity_is_rebuildable(clean_entry):
            clean_entry.pop('url', None)
        clean_results.append(clean_entry)
    return clean_results


def dedupe_api_catalog_items(data, endpoint=''):
    """Remove IDs repetidos da resposta usada pela UI e pela pesquisa."""
    if not isinstance(data, list):
        return data, 0
    id_key = 'series_id' if endpoint == 'get_series' else 'stream_id'
    unique = []
    positions = {}
    duplicates = 0

    def native_plot(item):
        info = item.get('info') or {}
        return str(
            item.get('plot')
            or item.get('description')
            or item.get('overview')
            or info.get('plot')
            or info.get('description')
            or info.get('overview')
            or ''
        ).strip()

    for item in data:
        if not isinstance(item, dict):
            continue
        item_id = str(item.get(id_key) or item.get('vod_id') or '').strip()
        if not item_id:
            unique.append(item)
            continue
        if item_id not in positions:
            positions[item_id] = len(unique)
            unique.append(item)
            continue
        duplicates += 1
        old_pos = positions[item_id]
        if not native_plot(unique[old_pos]) and native_plot(item):
            unique[old_pos] = item
    return unique, duplicates

# =========================
# Storage/scoping (extraído de forma conservadora para resources/lib/storage_runtime.py)
# Navegação e rotas permanecem no main.py para preservar o comportamento aprovado no Kodi.
# =========================
def ensure_profile_dir():
    return storage_runtime.ensure_profile_dir(PROFILE_DIR)


def ensure_parent_dir(path):
    return storage_runtime.ensure_parent_dir(path)


def safe_delete_file(path):
    return storage_runtime.safe_delete_file(path)


def safe_move_file(src, dst):
    return storage_runtime.safe_move_file(src, dst)


def atomic_write_text(path, text, encoding='utf-8'):
    return storage_runtime.atomic_write_text(path, text, profile_dir=PROFILE_DIR, encoding=encoding)


def atomic_write_json(path, data, ensure_ascii=False, indent=None):
    return storage_runtime.atomic_write_json(path, data, profile_dir=PROFILE_DIR, ensure_ascii=ensure_ascii, indent=indent, log_fn=log)


def storage_identity_from_values(base_url='', username=''):
    return storage_runtime.storage_identity_from_values(base_url, username)


def scope_name_from_identity(identity, base_url='', username=''):
    return storage_runtime.scope_name_from_identity(identity, base_url, username)


def remember_validated_scope_state(base_url='', username=''):
    try:
        identity = storage_identity_from_values(base_url, username)
        if identity == 'default':
            return False
        payload = {
            'identity': identity,
            'scope_name': scope_name_from_identity(identity, base_url, username),
            'updated_at': int(time.time())
        }
        ensure_profile_dir()
        return atomic_write_json(VALIDATED_SCOPE_STATE_PATH, payload, ensure_ascii=False)
    except Exception as exc:
        log(f'Falha ao salvar estado do escopo validado: {exc}', xbmc.LOGDEBUG)
        return False


def server_slot_scope_name(server_idx=None):
    return storage_runtime.server_slot_scope_name(get_current_server_choice, server_idx)


def current_server_scope_identity():
    return server_slot_scope_name()


def server_slot_storage_path(filename, server_idx=None):
    return storage_runtime.server_slot_storage_path(PROFILE_DIR, filename, get_current_server_choice, server_idx)


def scoped_storage_path_from_values(filename, base_url='', username=''):
    return storage_runtime.scoped_storage_path_from_values(SCOPED_STORAGE_ROOT, filename, base_url, username)


def _storage_ctx():
    return {
        'profile_dir': PROFILE_DIR,
        'username': USERNAME,
        'get_base_url_fn': get_base_url,
        'server_slot_storage_path_fn': server_slot_storage_path,
        'scoped_storage_path_from_values_fn': scoped_storage_path_from_values,
        'load_json_fn': load_json_file,
        'save_json_fn': save_json_file,
        'log_fn': log,
    }


def maybe_migrate_server_slot_json(filename, legacy_path, default_payload, server_idx=None, base_url=None, username=None, log_label='dados'):
    return storage_runtime.maybe_migrate_server_slot_json(_storage_ctx(), filename, legacy_path, default_payload, server_idx=server_idx, base_url=base_url, username=username, log_label=log_label)


def maybe_migrate_server_slot_file(filename, legacy_path, server_idx=None, base_url=None, username=None, log_label='arquivo'):
    return storage_runtime.maybe_migrate_server_slot_file(_storage_ctx(), filename, legacy_path, server_idx=server_idx, base_url=base_url, username=username, log_label=log_label)


def preserve_current_server_slot_state():
    try:
        current_idx = get_current_server_choice()
        current_base_url = get_base_url()
        current_username = USERNAME
        maybe_migrate_server_slot_json('favorites.json', LEGACY_FAVORITES_PATH, {'items': []}, server_idx=current_idx, base_url=current_base_url, username=current_username, log_label='Favoritos')
        maybe_migrate_server_slot_json('vod_desc_cache.json', LEGACY_VOD_DESC_CACHE_PATH, {}, server_idx=current_idx, base_url=current_base_url, username=current_username, log_label='Cache VOD')
        maybe_migrate_server_slot_json('series_desc_cache.json', LEGACY_SERIES_DESC_CACHE_PATH, {}, server_idx=current_idx, base_url=current_base_url, username=current_username, log_label='Cache séries')
        maybe_migrate_server_slot_json('search_state.json', LEGACY_SEARCH_STATE_PATH, {}, server_idx=current_idx, base_url=current_base_url, username=current_username, log_label='Estado de busca')
        maybe_migrate_server_slot_json('epg_xmltv_meta.json', LEGACY_EPG_XMLTV_META_PATH, {}, server_idx=current_idx, base_url=current_base_url, username=current_username, log_label='Meta EPG XMLTV')
        maybe_migrate_server_slot_file('epg_xmltv.xml', LEGACY_EPG_XMLTV_XML_PATH, server_idx=current_idx, base_url=current_base_url, username=current_username, log_label='EPG XMLTV')
        # Migração suave das versões anteriores do próprio OnePlayVIP.
        try:
            old_meta = maybe_migrate_server_slot_json('epg_meta.json', LEGACY_EPG_META_PATH, {}, server_idx=current_idx, base_url=current_base_url, username=current_username, log_label='Meta EPG legado')
            new_meta = server_slot_storage_path('epg_xmltv_meta.json', current_idx)
            if old_meta and not os.path.exists(new_meta) and os.path.exists(old_meta):
                atomic_write_json(new_meta, load_json_file(old_meta, {}) or {}, ensure_ascii=False, indent=2)
        except Exception:
            pass
        try:
            old_xml = maybe_migrate_server_slot_file('epg.xml', LEGACY_EPG_XML_PATH, server_idx=current_idx, base_url=current_base_url, username=current_username, log_label='EPG XML legado')
            new_xml = server_slot_storage_path('epg_xmltv.xml', current_idx)
            if old_xml and not os.path.exists(new_xml) and os.path.exists(old_xml):
                with open(old_xml, 'r', encoding='utf-8', errors='ignore') as fh:
                    atomic_write_text(new_xml, fh.read(), encoding='utf-8')
        except Exception:
            pass
    except Exception as exc:
        log(f'Falha ao preservar estado do servidor atual: {exc}', xbmc.LOGDEBUG)


def preserve_current_server_favorites():
    preserve_current_server_slot_state()


def favorites_storage_path():
    return maybe_migrate_server_slot_json('favorites.json', LEGACY_FAVORITES_PATH, {'items': []}, log_label='Favoritos')



def vod_desc_cache_storage_path():
    return maybe_migrate_server_slot_json('vod_desc_cache.json', LEGACY_VOD_DESC_CACHE_PATH, {}, log_label='Cache VOD')


def series_desc_cache_storage_path():
    return maybe_migrate_server_slot_json('series_desc_cache.json', LEGACY_SERIES_DESC_CACHE_PATH, {}, log_label='Cache séries')


def search_state_storage_path():
    return maybe_migrate_server_slot_json('search_state.json', LEGACY_SEARCH_STATE_PATH, {}, log_label='Estado de busca')


def category_counts_storage_path(content_endpoint):
    safe_name = re.sub(r'[^A-Za-z0-9._-]+', '_', str(content_endpoint or '').strip().lower()).strip('._-') or 'default'
    return server_slot_storage_path(f'category_counts_{safe_name}.json')


def epg_xml_storage_path():
    # Padrão XC Pro: XMLTV bruto por servidor/login, com migração suave do epg.xml antigo.
    path = maybe_migrate_server_slot_file('epg_xmltv.xml', LEGACY_EPG_XMLTV_XML_PATH, log_label='EPG XMLTV')
    try:
        if not os.path.exists(path):
            old_path = maybe_migrate_server_slot_file('epg.xml', LEGACY_EPG_XML_PATH, log_label='EPG XML legado')
            if old_path and os.path.exists(old_path):
                with open(old_path, 'r', encoding='utf-8', errors='ignore') as fh:
                    atomic_write_text(path, fh.read(), encoding='utf-8')
    except Exception:
        pass
    return path


def epg_meta_storage_path():
    # Padrão XC Pro: metadados separados do XMLTV, com fallback do epg_meta.json antigo.
    path = maybe_migrate_server_slot_json('epg_xmltv_meta.json', LEGACY_EPG_XMLTV_META_PATH, {}, log_label='Meta EPG XMLTV')
    try:
        if os.path.exists(path) and load_json_file(path, {}) not in ({}, None):
            return path
        old_path = maybe_migrate_server_slot_json('epg_meta.json', LEGACY_EPG_META_PATH, {}, log_label='Meta EPG legado')
        old_data = load_json_file(old_path, {}) if old_path else {}
        if isinstance(old_data, dict) and old_data:
            atomic_write_json(path, old_data, ensure_ascii=False, indent=2)
    except Exception:
        pass
    return path


def load_json_file(path, default=None):
    return storage_runtime.load_json_file(path, default=default, profile_dir=PROFILE_DIR, log_fn=log)


def save_json_file(path, data):
    return storage_runtime.save_json_file(path, data, profile_dir=PROFILE_DIR, log_fn=log)


def normalize_sort_text(text):
    return storage_runtime.normalize_sort_text(text)

# =========================
# Category counts (extraído de forma conservadora para resources/lib/category_counts_runtime.py)
# =========================
def _category_counts_ctx():
    return {
        'get_base_url_fn': get_base_url,
        'username': USERNAME,
        'current_server_scope_identity_fn': current_server_scope_identity,
        'storage_identity_from_values_fn': storage_identity_from_values,
        'category_counts_storage_path_fn': category_counts_storage_path,
        'load_json_fn': load_json_file,
        'save_json_fn': save_json_file,
        'fetch_endpoint_fn': lambda endpoint: probe_player_api(f'action={endpoint}'),
        'cache_ttl': CATEGORY_COUNT_CACHE_TTL,
        'cache_version': CATEGORY_COUNT_CACHE_VERSION,
        'log_fn': log,
    }


def category_count_scope_identity():
    return category_counts_runtime.category_count_scope_identity(_category_counts_ctx())


def category_count_media_label(content_endpoint, total_count):
    return category_counts_runtime.category_count_media_label(content_endpoint, total_count)


def format_category_title_with_count(title, total_count):
    return category_counts_runtime.format_category_title_with_count(title, total_count)


def build_category_count_map_from_data(content_endpoint, data):
    return category_counts_runtime.build_category_count_map_from_data(content_endpoint, data)


def get_category_counts_for_endpoint(content_endpoint):
    return category_counts_runtime.get_category_counts_for_endpoint(_category_counts_ctx(), content_endpoint)

def favorite_prefix(kind):
    kind = (kind or '').strip().lower()
    if kind == 'live':
        return 'Canal:'
    if kind == 'vod':
        return 'Filme:'
    if kind == 'series':
        return 'Séries:'
    return ''

def favorite_base_title(kind, title):
    title = (title or 'Favorito').strip() or 'Favorito'
    known_prefixes = ['Canal:', 'Filme:', 'Série:', 'Séries:']
    for pref in known_prefixes:
        if title.startswith(pref):
            stripped = title[len(pref):].strip()
            if stripped:
                title = stripped
            break
    return title

def favorite_display_title(kind, title):
    title = favorite_base_title(kind, title)
    prefix = favorite_prefix(kind)
    return f'{prefix} {title}'.strip() if prefix else title

MEDIA_PREFIX_COLORS = {
    'live': 'aquamarine',
    'vod': 'gold',
    'series': 'orchid',
    'episode': 'orchid',
}


def strip_media_type_prefix(title):
    title = (title or '').strip()
    # Remove marcações de cor Kodi antes de analisar o prefixo.
    plain = re.sub(r'\[/?COLOR(?:\s+[^\]]+)?\]', '', title, flags=re.IGNORECASE).strip()
    prefixes = ('Canal:', 'Filme:', 'Série:', 'Séries:', 'Serie:', 'Series:')
    for pref in prefixes:
        if plain.lower().startswith(pref.lower()):
            return plain[len(pref):].strip()
    return plain


def colored_media_title(kind, title):
    kind = (kind or '').strip().lower()
    base = strip_media_type_prefix(title) or (title or '').strip()
    prefix_map = {
        'live': 'Canal:',
        'vod': 'Filme:',
        'series': 'Série:',
        'episode': 'Série:',
    }
    prefix = prefix_map.get(kind, '')
    if not prefix:
        return base
    color = MEDIA_PREFIX_COLORS.get(kind, 'white')
    return f'[COLOR {color}]{prefix}[/COLOR] {base}'.strip()

def return_to_enter_menu_with_message(title, message=''):
    if message:
        show_dialog(title, message)
    # REGRA FIXA DE PRODUÇÃO (KODI): para rotas-pasta vazias/desativadas,
    # não reconstruir inline e não reabrir rota.
    # O comportamento correto é voltar para o container anterior sem empilhar histórico.
    try:
        xbmc.executebuiltin('Action(Back)')
    except Exception as exc:
        log(f'Falha ao voltar com Action(Back): {exc}', xbmc.LOGDEBUG)
        try:
            refresh_container()
        except Exception:
            pass
    raise SystemExit


def favorites_load():
    data = load_json_file(favorites_storage_path(), {'items': []})
    if not isinstance(data, dict):
        data = {'items': []}
    items = data.get('items')
    data['items'] = items if isinstance(items, list) else []
    return data

def favorites_save(data):
    payload = {'items': []}
    if isinstance(data, dict):
        items = data.get('items')
        if isinstance(items, list):
            payload['items'] = items
    return save_json_file(favorites_storage_path(), payload)

def favorite_key(kind, item_id):
    kind = (kind or '').strip().lower()
    item_id = str(item_id or '').strip()
    if not kind or not item_id:
        return ''
    return f'{kind}:{item_id}'

def favorites_key_set():
    return {str(item.get('key', '')) for item in favorites_load().get('items', []) if str(item.get('key', '')).strip()}

def infer_favorite_kind(item):
    kind = (item.get('favorite_kind') or '').strip().lower()
    if kind in ('live', 'vod', 'series'):
        return kind
    if item.get('series_id'):
        return 'series'
    if item.get('stream_kind') == 'live':
        return 'live'
    if item.get('stream_kind') == 'vod' or item.get('vod_id') or item.get('stream_id'):
        return 'vod'
    return ''

def favorite_entry_from_item(item):
    kind = infer_favorite_kind(item)
    title = favorite_base_title(kind, html.unescape(item.get('favorite_title') or item.get('title') or '').strip())
    icon = item.get('icon', '') or ''
    fanart = item.get('fanart', addonFanart) or addonFanart
    plot = item.get('plot', '') or ''
    category_id = str(item.get('category_id', '') or '').strip()
    is_adult = normalize_bool(item.get('is_adult', False))

    if kind == 'live':
        stream_id = str(item.get('stream_id', '') or '').strip()
        if not stream_id:
            return None
        live_ext = (item.get('container_extension') or get_live_proxy_stream_extension() or 'm3u8').lower()
        if live_ext not in ('m3u8', 'ts'):
            live_ext = 'm3u8'
        return {'key': favorite_key('live', stream_id), 'kind': 'live', 'title': title, 'stream_id': stream_id, 'epg_channel_id': str(item.get('epg_channel_id', '') or '').strip(), 'container_extension': live_ext, 'icon': icon, 'fanart': fanart, 'plot': plot, 'category_id': category_id, 'is_adult': is_adult, 'added_at': time.time()}

    if kind == 'vod':
        vod_id = str(item.get('vod_id') or item.get('stream_id') or '').strip()
        if not vod_id:
            return None
        ext = (item.get('container_extension') or 'mp4').lower()
        stream_type = (item.get('stream_type') or 'movie').strip() or 'movie'
        return {'key': favorite_key('vod', vod_id), 'kind': 'vod', 'title': title, 'vod_id': vod_id, 'stream_type': stream_type, 'container_extension': ext, 'icon': icon, 'fanart': fanart, 'plot': plot, 'category_id': category_id, 'is_adult': is_adult, 'added_at': time.time()}

    if kind == 'series':
        series_id = str(item.get('series_id', '') or '').strip()
        if not series_id:
            return None
        return {'key': favorite_key('series', series_id), 'kind': 'series', 'title': title, 'series_id': series_id, 'icon': icon, 'fanart': fanart, 'plot': plot, 'category_id': category_id, 'is_adult': is_adult, 'added_at': time.time()}

    return None

def favorite_entry_from_params(params):
    item = {'favorite_kind': params.get('fav_kind', ''), 'title': params.get('title', ''), 'favorite_title': params.get('favorite_title', ''), 'icon': params.get('icon', ''), 'fanart': params.get('fanart', addonFanart), 'plot': params.get('plot', ''), 'category_id': params.get('category_id', ''), 'is_adult': params.get('is_adult', 'false'), 'stream_id': params.get('stream_id', ''), 'vod_id': params.get('vod_id', ''), 'series_id': params.get('series_id', ''), 'container_extension': params.get('container_extension', ''), 'stream_type': params.get('stream_type', ''), 'stream_kind': params.get('stream_kind', ''), 'epg_channel_id': params.get('epg_channel_id', '')}
    return favorite_entry_from_item(item)

def add_favorite(entry):
    if not isinstance(entry, dict) or not entry.get('key'):
        return False
    data = favorites_load()
    items = [it for it in data.get('items', []) if str(it.get('key', '')) != entry['key']]
    items.insert(0, entry)
    data['items'] = items
    return favorites_save(data)

def remove_favorite(favorite_key_value):
    favorite_key_value = str(favorite_key_value or '').strip()
    if not favorite_key_value:
        return False
    data = favorites_load()
    items = data.get('items', [])
    new_items = [it for it in items if str(it.get('key', '')) != favorite_key_value]
    if len(new_items) == len(items):
        return False
    data['items'] = new_items
    return favorites_save(data)

def favorite_to_menu_item(fav):
    kind = (fav.get('kind') or '').strip().lower()
    title = fav.get('title', 'Favorito') or 'Favorito'
    icon = resolve_item_icon(fav)
    fanart = resolve_item_fanart(fav)
    plot = fav.get('plot', '') or ''
    category_id = str(fav.get('category_id', '') or '').strip()
    is_adult = normalize_bool(fav.get('is_adult', False))
    base_url = get_base_url().rstrip('/')

    if kind == 'live':
        stream_id = str(fav.get('stream_id', '') or '').strip()
        if not stream_id or not base_url or not USERNAME or not PASSWORD:
            return None
        live_url, live_ext = build_live_stream_url(base_url, USERNAME, PASSWORD, stream_id)
        return {'title': title, 'url': live_url, 'icon': icon, 'fanart': fanart, 'plot': plot, 'media_type': 'video', 'normalplayer': 'false', 'stream_kind': 'live', 'stream_id': stream_id, 'epg_channel_id': str(fav.get('epg_channel_id', '') or '').strip(), 'container_extension': live_ext, 'category_id': category_id, 'is_adult': is_adult, 'favorite_kind': 'live', 'favorite_key': fav.get('key', '')}

    if kind == 'vod':
        vod_id = str(fav.get('vod_id', '') or '').strip()
        stream_type = (fav.get('stream_type') or 'movie').strip() or 'movie'
        ext = (fav.get('container_extension') or 'mp4').lower()
        if not vod_id or not base_url or not USERNAME or not PASSWORD:
            return None
        return {'title': title, 'url': f'{base_url}/{stream_type}/{USERNAME}/{PASSWORD}/{vod_id}.{ext}', 'icon': icon, 'fanart': fanart, 'plot': plot, 'media_type': 'movie', 'normalplayer': 'false', 'stream_kind': 'vod', 'stream_id': vod_id, 'vod_id': vod_id, 'container_extension': ext, 'stream_type': stream_type, 'category_id': category_id, 'is_adult': is_adult, 'favorite_kind': 'vod', 'favorite_key': fav.get('key', '')}

    if kind == 'series':
        series_id = str(fav.get('series_id', '') or '').strip()
        if not series_id:
            return None
        return {'title': title, 'mode': 'seasons', 'params': f"series_id={series_id}&category_id={urllib.parse.quote(category_id, safe='')}&is_adult={'true' if is_adult else 'false'}", 'icon': icon, 'fanart': fanart, 'plot': plot, 'media_type': 'tvshow', 'series_id': series_id, 'category_id': category_id, 'is_adult': is_adult, 'favorite_kind': 'series', 'favorite_key': fav.get('key', '')}

    return None

def list_favorites_items():
    items = []
    for fav in favorites_load().get('items', []):
        item = favorite_to_menu_item(fav)
        if not item:
            continue
        if item.get('is_adult') and not is_adult_enabled():
            continue
        item['display_title'] = favorite_display_title(item.get('favorite_kind') or infer_favorite_kind(item), item.get('title', 'Favorito'))
        items.append(item)
    items.sort(key=lambda it: normalize_sort_text(it.get('display_title') or it.get('title') or ''))
    return items

def build_favorites_menu():
    items = list_favorites_items()
    if not items:
        return_to_enter_menu_with_message('Favoritos', 'Nenhum favorito salvo no momento.')
        return

    favorite_keys = favorites_key_set() if is_favorites_enabled() else set()
    for item in items:
        label = item.get('display_title') or item.get('title', '')
        play_title = item.get('title', label)
        li = xbmcgui.ListItem(label=label)
        icon = resolve_item_icon(item)
        fanart = resolve_item_fanart(item)
        li.setArt({'icon': icon, 'thumb': icon, 'fanart': fanart})
        if fanart:
            li.setProperty('Fanart_Image', fanart)
        plot = item.get('plot', '') or ''

        # Resultados da Pesquisa Global podem ter sido salvos antes de o
        # aquecimento concluir. Reconsulta o cache no momento da renderização
        # para exibir a sinopse já hidratada sem exigir reprodução ou nova busca.
        if item.get('vod_id'):
            try:
                vod_cache_now = desc_cache_load(vod_desc_cache_storage_path())
                has_cached_plot, cached_plot = desc_cache_get(vod_cache_now, item.get('vod_id'))
                if has_cached_plot and cached_plot:
                    plot = cached_plot
                    item['plot'] = cached_plot
            except Exception as exc:
                log(f'Falha ao reaplicar sinopse VOD cacheada no menu: {exc}', xbmc.LOGDEBUG)

        info = {'title': label}
        if plot:
            info['plot'] = plot
        apply_video_info(li, info)
        favorite_key_value = item.get('favorite_key', '')
        cm = []
        if favorite_key_value and favorite_key_value in favorite_keys:
            cm_url = build_url(mode='remove_favorite', favorite_key=favorite_key_value, from_favorites='true')
            cm.append(('Remover dos Favoritos', f'RunPlugin({cm_url})'))
        if cm:
            li.addContextMenuItems(cm, replaceItems=False)

        rebuildable = stream_identity_is_rebuildable(item)
        if item.get('url') or rebuildable:
            params = {'mode': 'play', 'title': play_title, 'icon': icon, 'fanart': fanart, 'normalplayer': str(item.get('normalplayer', 'false')).lower(), 'stream_kind': item.get('stream_kind', ''), 'container_extension': item.get('container_extension', ''), 'stream_type': item.get('stream_type', ''), 'category_id': str(item.get('category_id', '') or ''), 'stream_id': str(item.get('stream_id', '') or ''), 'epg_channel_id': str(item.get('epg_channel_id', '') or ''), 'is_adult': str(normalize_bool(item.get('is_adult', False))).lower()}
            if not rebuildable:
                play = ensure_play_url_headers(item.get('url', ''), item.get('stream_kind', ''))
                if not play:
                    log(f'Ignorando favorito reproduzível sem URL: {label}', xbmc.LOGDEBUG)
                    continue
                params['url'] = play
            if item.get('vod_id'):
                params['vod_id'] = str(item.get('vod_id'))
            if item.get('series_id'):
                params['series_id'] = str(item.get('series_id'))
            url = build_url(**params)
            li.setProperty('IsPlayable', 'true')
            xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, False)
        else:
            url_params = {'mode': item.get('mode', 'seasons')}
            if 'params' in item:
                qs = urllib.parse.parse_qs(item['params'], keep_blank_values=True)
                for k, v in qs.items():
                    url_params[k] = v[0] if isinstance(v, list) else v
            url = build_url(**url_params)
            xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, True)
    end_directory(cache_to_disc=True)

def load_search_state():
    try:
        data = load_json_file(search_state_storage_path(), {})
        if not isinstance(data, dict):
            return {}
        old_results = data.get('last_results')
        if isinstance(old_results, list):
            clean_results = sanitize_search_results(old_results)
            if clean_results != old_results:
                data = dict(data)
                data['last_results'] = clean_results
                save_json_file(search_state_storage_path(), data)
        return data
    except Exception as e:
        log(f"Falha ao ler estado da pesquisa: {e}", xbmc.LOGDEBUG)
        return {}

def save_search_state(data):
    try:
        clean = dict(data or {}) if isinstance(data, dict) else {}
        if isinstance(clean.get('last_results'), list):
            clean['last_results'] = sanitize_search_results(clean['last_results'])
        save_json_file(search_state_storage_path(), clean)
    except Exception as e:
        log(f"Falha ao salvar estado da pesquisa: {e}", xbmc.LOGDEBUG)

def remember_search(scope, query, results=None):
    state = load_search_state()
    state['last_scope'] = (scope or 'all').strip()
    state['last_query'] = (query or '').strip()
    if results is not None:
        state['last_results'] = sanitize_search_results(results)
    save_search_state(state)

def mark_resume_search(scope, query):
    state = load_search_state()
    state['resume_once'] = True
    state['last_scope'] = (scope or state.get('last_scope') or 'all').strip()
    state['last_query'] = (query or state.get('last_query') or '').strip()
    save_search_state(state)

def pop_resume_search():
    state = load_search_state()
    query = (state.get('last_query') or '').strip()
    scope = (state.get('last_scope') or 'all').strip()
    if state.get('resume_once') and query:
        state['resume_once'] = False
        save_search_state(state)
        cached_results = state.get('last_results')
        if isinstance(cached_results, list):
            return {'scope': scope, 'query': query, 'results': cached_results}
        return {'scope': scope, 'query': query}
    return None

def has_valid_credentials():
    return bool(USERNAME and PASSWORD and get_base_url())
def prompt_server_selection(title='Selecionar servidor'):
    current_idx = get_current_server_choice()
    labels = []
    for idx, label in enumerate(get_server_labels()):
        if idx == current_idx:
            labels.append(f'[COLOR lime]{label}[/COLOR]')
        else:
            labels.append(label)
    return xbmcgui.Dialog().select(title, labels)

def apply_login_settings(username, password, server_choice=None):
    try:
        preserve_current_server_favorites()
        ADDON.setSetting('username', (username or '').strip())
        ADDON.setSetting('password', (password or '').strip())
        if server_choice is not None:
            ADDON.setSetting('server_choice', str(int(server_choice)))
        reset_login_success_notification()
        refresh_runtime_settings()
        return True
    except Exception as exc:
        log(f'Falha ao salvar login: {exc}', xbmc.LOGERROR)
        return False

def clear_login_settings(keep_server=True):
    try:
        preserve_current_server_favorites()
        saved_server = get_current_server_choice()
        ADDON.setSetting('username', '')
        ADDON.setSetting('password', '')
        if keep_server:
            ADDON.setSetting('server_choice', str(saved_server))
        reset_login_success_notification()
        refresh_runtime_settings()
        return True
    except Exception as exc:
        log(f'Falha ao limpar login: {exc}', xbmc.LOGERROR)
        return False

def probe_player_api(endpoint='', base_url=None, username=None, password=None):
    base_url = (base_url if base_url is not None else get_base_url()) or ''
    username = (username if username is not None else USERNAME) or ''
    password = (password if password is not None else PASSWORD) or ''
    base_url = base_url.strip()
    username = username.strip()
    password = password.strip()

    if not all([base_url, username, password]):
        return None, 'missing_config', 'Configure usuário, senha e servidor para continuar.'

    query = urllib.parse.urlencode({'username': username, 'password': password})
    url = f"{base_url.rstrip('/')}/player_api.php?{query}"
    if endpoint:
        url += f"&{endpoint}"

    r = None
    try:
        r = safe_requests_get(url)
        raw = (r.text or '').strip()
        if not raw:
            return None, 'login', 'O servidor respondeu vazio para esse acesso.'

        try:
            data = r.json()
        except ValueError:
            raw_lower = raw.lower()
            if (
                '<html' in raw_lower or
                'access denied' in raw_lower or
                'unauthorized' in raw_lower or
                'invalid' in raw_lower or
                'username' in raw_lower or
                'password' in raw_lower
            ):
                return None, 'login', 'Não foi possível validar o acesso. Verifique o servidor, usuário e senha.'
            return None, 'invalid_response', 'O servidor respondeu em formato inesperado. Verifique os dados e tente novamente.'

        user_info = data.get('user_info', {}) if isinstance(data, dict) else {}
        auth = str(user_info.get('auth', '1')).strip()
        status = str(user_info.get('status', '')).strip().lower()

        if auth == '0':
            return None, 'login', 'Não foi possível validar o acesso. Verifique o servidor, usuário e senha.'
        if status in ['disabled', 'banned']:
            return None, 'blocked', 'Sua conta está bloqueada ou desativada.'
        if status in ['expired', 'expirado']:
            return None, 'expired', 'Sua conta está vencida.'

        remember_validated_scope_state(base_url, username)
        return data, '', ''
    except requests.RequestException as exc:
        try:
            status_code = exc.response.status_code if getattr(exc, 'response', None) else None
        except Exception:
            status_code = None
        if status_code == 429:
            return None, 'rate_limit', 'Muitas tentativas em pouco tempo. Aguarde alguns instantes e tente novamente.'
        if status_code in (401, 403):
            return None, 'login', 'Não foi possível validar o acesso. Verifique o servidor, usuário e senha.'
        return None, 'connection', 'Não foi possível validar este servidor. Verifique servidor, usuário e senha.'
    finally:
        if r is not None:
            try:
                r.close()
            except Exception:
                pass

def show_probe_result_dialog(error_type='', error_message=''):
    title, message = probe_result_message(error_type, error_message)
    xbmcgui.Dialog().ok(title, message)

def show_server_test_result(server_idx, data, error_type='', error_message=''):
    server_info = get_server_summary(server_idx)
    if data:
        user_info = data.get('user_info', {}) if isinstance(data, dict) else {}
        status = str(user_info.get('status', 'ativo') or 'ativo')
        exp_date = user_info.get('exp_date', 0)
        days_left = days_until_expire(exp_date)
        lines = [
            f'[B]{server_info}[/B]',
            '',
            '[COLOR lime]Conexão OK[/COLOR]',
            f'Status: {status}',
        ]
        if days_left is not None:
            lines.append(f'Dias restantes: {days_left}')
        xbmcgui.Dialog().ok('Teste do servidor', '\n'.join(lines))
        return
    title, message = probe_result_message(error_type, error_message)
    xbmcgui.Dialog().ok(title, f'[B]{server_info}[/B]\n\n{message}')

def run_quick_setup_wizard():
    while True:
        selected_server = prompt_server_selection('Configuração Rápida - Selecionar servidor')
        if selected_server < 0:
            return False

        server_name = get_server_name(selected_server)

        username = prompt_text_input('Usuário', USERNAME or '', hidden=False)
        if username is None:
            return False
        if not username:
            show_dialog('Configuração Rápida', 'Informe o usuário para continuar.')
            continue

        password = prompt_text_input('Senha', PASSWORD or '', hidden=True)
        if password is None:
            return False
        if not password:
            show_dialog('Configuração Rápida', 'Informe a senha para continuar.')
            continue

        base_url = b64_decode(DNS_LIST_B64[selected_server]).strip()
        data, error_type, error_message = probe_player_api('', base_url=base_url, username=username, password=password)
        if data:
            if apply_login_settings(username, password, selected_server):
                return True
            show_dialog('Configuração Rápida', 'Não foi possível salvar os dados de acesso.')
            return False

        retry_title = 'Acesso não validado'
        retry_message = (
            'Não foi possível validar o acesso.\n'
            'Verifique o servidor, usuário e senha.\n\n'
            'Deseja tentar configurar o acesso novamente?'
        )
        if not yesno_default_yes(retry_title, retry_message, yeslabel='Tentar novamente', nolabel='Cancelar'):
            return False

def run_change_credentials_wizard():
    current_server = get_server_name()
    while True:
        username = prompt_text_input('Novo usuário', USERNAME or '', hidden=False)
        if username is None:
            return False
        if not username:
            show_dialog('Alterar Usuário e Senha', 'Informe o usuário para continuar.')
            continue

        password = prompt_text_input('Nova senha', PASSWORD or '', hidden=True)
        if password is None:
            return False
        if not password:
            show_dialog('Alterar Usuário e Senha', 'Informe a senha para continuar.')
            continue

        data, error_type, error_message = probe_player_api('', username=username, password=password)
        if data:
            if apply_login_settings(username, password):
                return True
            show_dialog('Alterar Usuário e Senha', 'Não foi possível salvar o novo usuário e senha.')
            return False

        retry_title = 'Novo acesso não validado'
        retry_message = (
            'Não foi possível validar o novo usuário e senha.\n'
            f'Verifique os dados informados e o servidor atual ({current_server}).\n\n'
            'Deseja tentar informar o acesso novamente?'
        )
        if not yesno_default_yes(retry_title, retry_message, yeslabel='Tentar novamente', nolabel='Cancelar'):
            return False

def run_access_retry_wizard():
    while True:
        selected_server = prompt_server_selection('Verificar Acesso - Selecionar servidor')
        if selected_server < 0:
            return False

        username = prompt_text_input('Usuário', USERNAME or '', hidden=False)
        if username is None:
            return False
        if not username:
            show_dialog('Verificar Acesso', 'Informe o usuário para continuar.')
            continue

        password = prompt_text_input('Senha', PASSWORD or '', hidden=True)
        if password is None:
            return False
        if not password:
            show_dialog('Verificar Acesso', 'Informe a senha para continuar.')
            continue

        base_url = b64_decode(DNS_LIST_B64[selected_server]).strip()
        data, error_type, error_message = probe_player_api('', base_url=base_url, username=username, password=password)
        if data:
            if apply_login_settings(username, password, selected_server):
                notify('Acesso atualizado com sucesso.', heading='Acesso', iconimage='INFO')
                return True
            show_dialog('Verificar Acesso', 'Não foi possível salvar os dados de acesso.')
            return False

        show_probe_result_dialog(error_type, error_message)

def switch_server_interactive():
    previous_choice = get_current_server_choice()
    while True:
        selected_server = prompt_server_selection('Trocar Servidor')
        if selected_server < 0:
            return False
        preserve_current_server_favorites()
        if not set_server_choice(selected_server):
            show_dialog('Servidor', 'Não foi possível salvar o servidor selecionado.')
            return False

        if not (USERNAME and PASSWORD):
            notify(f'Servidor salvo: {get_server_name(selected_server)}.', heading='Servidor', iconimage='INFO')
            return True

        base_url = b64_decode(DNS_LIST_B64[selected_server]).strip()
        data, error_type, error_message = probe_player_api('', base_url=base_url, username=USERNAME, password=PASSWORD)
        if data:
            notify(f'Servidor ativo e acesso validado: {get_server_name(selected_server)}.', heading='Servidor', iconimage='INFO')
            return True

        title, message = probe_result_message(error_type, error_message)
        keep_choice = xbmcgui.Dialog().yesno(title, f'O acesso não foi validado neste servidor.\n\n{message}\n\nDeseja manter {get_server_name(selected_server)} selecionado mesmo assim?')
        if keep_choice:
            return False
        set_server_choice(previous_choice)
        if not xbmcgui.Dialog().yesno('Servidor', 'Deseja selecionar outro servidor agora?'):
            return False

def test_current_server():
    if not has_valid_credentials():
        show_dialog('Teste do servidor', 'Configure servidor, usuário e senha antes de testar a conexão.')
        return False
    current_idx = get_current_server_choice()
    base_url = b64_decode(DNS_LIST_B64[current_idx]).strip()
    data, error_type, error_message = probe_player_api('', base_url=base_url, username=USERNAME, password=PASSWORD)
    show_server_test_result(current_idx, data, error_type, error_message)
    return bool(data)

def test_all_servers():
    if not has_valid_credentials():
        show_dialog('Teste dos servidores', 'Configure servidor, usuário e senha antes de testar.')
        return False

    progress = xbmcgui.DialogProgress()
    results = []
    try:
        progress.create('Teste dos servidores', 'Validando servidores salvos...')
        for idx, _entry in enumerate(DNS_LIST_B64):
            if progress.iscanceled():
                break
            percent = int(((idx + 1) / max(1, len(DNS_LIST_B64))) * 100)
            progress.update(percent, f'Testando {get_server_name(idx)}...')
            base_url = b64_decode(DNS_LIST_B64[idx]).strip()
            data, error_type, error_message = probe_player_api('', base_url=base_url, username=USERNAME, password=PASSWORD)
            results.append((idx, bool(data), error_type or '', error_message or ''))
        progress.close()
    except Exception:
        try:
            progress.close()
        except Exception:
            pass

    if not results:
        return False

    def compact_server_test_status(error_type='', error_message=''):
        # Mantém o resultado curto e honesto para o teste geral.
        # Nesse fluxo a falha normalmente indica que o acesso não pertence
        # ao servidor testado, não necessariamente problema de internet.
        kind = (error_type or '').strip().lower()
        if kind == 'blocked':
            return 'Conta bloqueada'
        if kind == 'expired':
            return 'Conta vencida'
        if kind == 'rate_limit':
            return 'Limite temporário'
        if kind == 'missing_config':
            return 'Configuração ausente'
        return 'Acesso não validado'

    lines = ['[B]Resultado do teste[/B]', '']
    for idx, ok, error_type, error_message in results:
        if ok:
            status_text = '[COLOR lime]OK[/COLOR]'
            extra = ''
        else:
            status_text = '[COLOR red]FALHA[/COLOR]'
            extra = f' - {compact_server_test_status(error_type, error_message)}'
        lines.append(f'{get_server_summary(idx)}: {status_text}{extra}')
    xbmcgui.Dialog().textviewer('Teste dos servidores', '\n'.join(lines))
    return True

def build_account_connection_items():
    current_server = get_server_summary()
    return [
        {
            'title': 'Informações da Conta',
            'mode': 'account_info',
            'plot': 'Mostra status da conta, validade, criação e limite de conexões.',
            'fanart': addonFanart,
            'folder': False,
        },
        {
            'title': f'Servidor Atual: {current_server}',
            'mode': 'test_current_server',
            'plot': f'Testa a conexão com o servidor atual. Atual: {current_server}.',
            'fanart': addonFanart,
            'folder': False,
        },
        {
            'title': 'Trocar Servidor',
            'mode': 'switch_server',
            'plot': 'Troca o servidor mantendo o usuário e a senha já salvos.',
            'fanart': addonFanart,
            'folder': False,
        },
        {
            'title': 'Alterar Usuário e Senha',
            'mode': 'change_credentials',
            'plot': 'Altera somente usuário e senha, mantendo o servidor atual selecionado.',
            'fanart': addonFanart,
            'folder': False,
        },
        {
            'title': 'Testar Todos os Servidores',
            'mode': 'test_all_servers',
            'plot': 'Valida todos os servidores configurados usando o login salvo.',
            'fanart': addonFanart,
            'folder': False,
        },
        {
            'title': 'Limpeza',
            'mode': 'cleanup_tools',
            'plot': 'Limpa caches e estados do servidor atual.',
            'fanart': addonFanart,
            'folder': False,
        },
        {
            'title': 'Sair da Conta',
            'mode': 'logout',
            'plot': 'Remove usuário e senha salvos e volta ao menu inicial do addon.',
            'fanart': addonFanart,
            'folder': False,
        },
    ]

def open_enter_menu_from_data(data, suppress_expire=False):
    user_info = (data or {}).get('user_info', {}) if isinstance(data, dict) else {}
    exp_date = user_info.get('exp_date', 0)
    days_left = days_until_expire(exp_date)
    account_connection_refresh = consume_account_connection_dirty()
    settings_or_account_refresh = account_connection_refresh
    if not suppress_expire:
        show_expire_popup(days_left, exp_date)
    if not settings_or_account_refresh:
        show_login_success_popup(user_info)
    cache_to_disc = not settings_or_account_refresh
    build_menu(get_enter_menu_items(), cache_to_disc=cache_to_disc)
    return True

def try_open_enter_menu(suppress_expire=False):
    refresh_runtime_settings()
    if not has_valid_credentials():
        return False, 'missing_config', 'Configure usuário, senha e servidor para continuar.'
    data, error_type, error_message = probe_player_api('')
    if not data:
        return False, error_type, error_message
    open_enter_menu_from_data(data, suppress_expire=suppress_expire)
    return True, '', ''

def get_server_labels():
    return external_get_server_labels(dns_list=DNS_LIST_B64)

def get_current_server_choice():
    return external_get_current_server_choice(ADDON, dns_list=DNS_LIST_B64)

def get_server_name(idx=None):
    return external_get_server_name(ADDON, idx=idx, dns_list=DNS_LIST_B64)

def get_server_summary(idx=None):
    return external_get_server_summary(ADDON, idx=idx, dns_list=DNS_LIST_B64)

def set_server_choice(choice_idx):
    return external_set_server_choice(
        ADDON,
        choice_idx,
        dns_list=DNS_LIST_B64,
        refresh_fn=refresh_runtime_settings,
        log_fn=lambda message: log(message, xbmc.LOGERROR),
    )

def is_live_proxy_enabled():
    return live_proxy_helper.is_live_proxy_enabled(ADDON)


def is_vod_proxy_enabled():
    return live_proxy_helper.is_vod_proxy_enabled(ADDON)


def is_proxy_debug_enabled():
    try:
        return (ADDON.getSetting('proxy_debug') or 'false').strip().lower() == 'true'
    except Exception:
        return False


def is_any_proxy_enabled():
    return live_proxy_helper.is_any_proxy_enabled(ADDON)


def set_live_proxy_enabled(enabled):
    return live_proxy_helper.set_live_proxy_enabled(ADDON, enabled, log_fn=lambda message, level=xbmc.LOGERROR: log(message, level))

def ensure_live_proxy_started():
    return live_proxy_helper.ensure_live_proxy_started(log_fn=lambda message, level=xbmc.LOGERROR: log(message, level))

def stop_live_proxy_if_running():
    return live_proxy_helper.stop_live_proxy_if_running(log_fn=lambda message, level=xbmc.LOGDEBUG: log(message, level))

def wrap_live_url_with_proxy(url):
    return live_proxy_helper.wrap_live_url_with_proxy(
        url,
        ADDON,
        strip_kodi_url_headers,
        log_fn=lambda message, level=xbmc.LOGERROR: log(message, level),
    )


def is_vod_proxy_candidate(url, stream_kind='', container_extension=''):
    if str(stream_kind or '').strip().lower() not in ('vod', 'episode'):
        return False
    clean = strip_kodi_url_headers(url or '')
    if not clean or is_local_oneplay_proxy_url(clean):
        return False
    if not clean.lower().startswith(('http://', 'https://')):
        return False
    ext = (detect_stream_extension(clean) or str(container_extension or '')).strip().lower().lstrip('.')
    # DASH exige resolver manifesto e segmentos relativos; não encapsular como
    # arquivo simples até haver reescritor MPD dedicado. Isso preserva o fluxo
    # nativo do InputStream Adaptive sem transformar .mpd em VOD inválido.
    if ext == 'mpd':
        return False
    if ext in ('mp4', 'mkv', 'avi', 'mov', 'wmv', 'webm', 'm4v', 'mpg', 'mpeg', 'ts', 'm3u8'):
        return True
    lower = clean.lower()
    if '/movie/' in lower or '/series/' in lower:
        return True
    return False


def wrap_vod_url_with_proxy(url, stream_kind='', container_extension=''):
    if not is_vod_proxy_enabled() or not is_vod_proxy_candidate(url, stream_kind=stream_kind, container_extension=container_extension):
        return url
    return live_proxy_helper.wrap_vod_url_with_proxy(
        url,
        ADDON,
        strip_kodi_url_headers,
        log_fn=lambda message, level=xbmc.LOGERROR: log(message, level),
    )

def probe_result_message(error_type, fallback_message=''):
    fallback_message = (fallback_message or '').strip()
    mapping = {
        'missing_config': ('Configuração necessária', 'Configure servidor, usuário e senha para continuar.'),
        'login': ('Acesso não validado', 'Não foi possível validar o acesso. Verifique o servidor, usuário e senha.'),
        'blocked': ('Acesso bloqueado', 'Sua conta está bloqueada ou desativada.'),
        'expired': ('Conta vencida', 'Sua conta está vencida.'),
        'invalid_response': ('Resposta inválida do servidor', 'O servidor respondeu em formato inesperado. Verifique os dados e tente novamente.'),
        'rate_limit': ('Limite temporário', 'Muitas tentativas em pouco tempo. Aguarde alguns instantes e tente novamente.'),
        'connection': ('Servidor não validado', 'Não foi possível validar este servidor.'),
    }
    title, default_message = mapping.get(error_type or '', ('Erro', 'Não foi possível concluir a operação.'))
    return title, fallback_message or default_message

def get_logged_out_root_items():
    return [
        {
            'title': 'Configuração Rápida',
            'mode': 'quick_setup',
            'plot': 'Configure servidor, usuário e senha em um fluxo guiado.',
            'fanart': addonFanart,
            'folder': False,
        },
        {
            'title': 'Configurações',
            'mode': 'settings',
            'plot': 'Abra as configurações nativas do addon para opções estruturais e técnicas.',
            'fanart': addonFanart,
            'folder': False,
        },
        {
            'title': SUPPORT_CONTACT_LABEL,
            'mode': 'show_qr',
            'plot': 'Abra o QR Code / WhatsApp de suporte.',
            'fanart': addonFanart,
            'folder': False,
        },
    ]

def build_root_menu():
    if has_valid_credentials():
        current_server = get_server_summary()
        items = [{
            'title': 'Acesso e Servidor',
            'mode': 'account_connection_menu',
            'plot': f'Gerencie acesso, servidor e testes de conexão. Atual: {current_server}.',
            'fanart': addonFanart,
        }]
        items.extend([
            {
                'title': 'Configurações',
                'mode': 'settings',
                'plot': 'Abra as configurações nativas do addon para opções estruturais e técnicas.',
                'fanart': addonFanart,
                'folder': False,
            },
            {
                'title': SUPPORT_CONTACT_LABEL,
                'mode': 'show_qr',
                'plot': 'Abra o QR Code / WhatsApp de suporte.',
                'fanart': addonFanart,
                'folder': False,
            },
        ])
    else:
        items = get_logged_out_root_items()
    build_menu(items)

def get_param_map():
    params = {}
    if len(sys.argv) > 2 and sys.argv[2]:
        parsed = urllib.parse.urlparse(sys.argv[2])
        q = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
        for k, v in q.items():
            params[k] = v[0] if isinstance(v, list) else v
    return params

def get_param(name, default=""):
    return PARAMS.get(name, default)

def fingerprint():
    base_url = (get_base_url() or '').strip().rstrip('/')
    username = (USERNAME or '').strip()
    password = (PASSWORD or '').strip()
    raw = f"{base_url}|{username}|{password}"
    return hashlib.sha1(raw.encode('utf-8')).hexdigest()

def safe_requests_get(url, **kw):
    kw.setdefault('headers', HEADERS)
    kw.setdefault('timeout', 30)

    retries = max(0, int(kw.pop('retries', 2)))
    backoff = max(0.2, float(kw.pop('backoff', 1.0)))
    if http_runtime is not None:
        try:
            session = http_runtime.get_thread_session(
                scope='main-api',
                headers=HEADERS,
                pool_connections=8,
                pool_maxsize=8,
            )
        except Exception:
            session = None
        if session is not None:
            return http_runtime.safe_get(
                url,
                session=session,
                retries=retries,
                backoff=backoff,
                retry_statuses=TRANSIENT_HTTP_STATUS,
                **kw,
            )

    # Fallback compatível caso o runtime compartilhado não esteja disponível.
    last_exc = None
    for attempt in range(retries + 1):
        try:
            r = requests.get(url, **kw)
            if r.status_code in TRANSIENT_HTTP_STATUS and attempt < retries:
                try:
                    r.close()
                except Exception:
                    pass
                time.sleep(backoff * (2 ** attempt))
                continue
            r.raise_for_status()
            return r
        except requests.RequestException as e:
            last_exc = e
            status_code = None
            try:
                resp = getattr(e, 'response', None)
                if resp is not None:
                    status_code = int(resp.status_code)
            except Exception:
                status_code = None
            try:
                ssl_error = getattr(requests.exceptions, 'SSLError', ())
                connection_error = isinstance(e, (requests.Timeout, requests.ConnectionError))
                if ssl_error and isinstance(e, ssl_error):
                    connection_error = False
            except Exception:
                connection_error = False
            should_retry = (
                attempt < retries and (
                    status_code in TRANSIENT_HTTP_STATUS or
                    connection_error
                )
            )
            if should_retry:
                time.sleep(backoff * (2 ** attempt))
                continue
            break
    raise last_exc if last_exc else requests.RequestException("Falha na requisição")

def open_settings_after_error(context='erro'):
    if is_silent_action_mode():
        log(f"Abertura de configurações suprimida no modo {mode} após {context}", xbmc.LOGDEBUG)
        return
    try:
        ADDON.openSettings()
        refresh_runtime_settings()
        refresh_container()
        raise SystemExit
    except SystemExit:
        raise
    except Exception as exc:
        log(f"Falha ao abrir configurações após {context}: {exc}", xbmc.LOGERROR)
        try:
            rebuild_enter_menu_inline()
            raise SystemExit
        except SystemExit:
            raise
        except Exception as ret_exc:
            log(f"Falha ao retornar ao menu Enter após {context}: {ret_exc}", xbmc.LOGERROR)

def show_settings_error(title, message, context='erro de configuração'):
    if mode == 'download_apk':
        log(f"Aviso de configuração suprimido no modo {mode}: {title} - {message}", xbmc.LOGDEBUG)
        return
    xbmcgui.Dialog().ok(title, message)
    open_settings_after_error(context)

def show_login_error(open_settings=True):
    if mode == 'download_apk':
        log(f"Aviso de login suprimido no modo {mode}", xbmc.LOGDEBUG)
        return
    msg = (
        "[B][COLOR red]Acesso não validado[/COLOR][/B]\n\n"
        "[COLOR white]Verifique se o [B]usuário[/B], a [B]senha[/B] e o [B]servidor[/B] estão corretos.[/COLOR]\n\n"
        "[COLOR gold]Dicas:[/COLOR]\n"
        "[COLOR white]• Confirme se não há espaço extra no login[/COLOR]\n"
        "[COLOR white]• Verifique se a conta ainda está ativa[/COLOR]\n"
        "[COLOR white]• Confira se o servidor selecionado é o certo[/COLOR]"
    )
    xbmcgui.Dialog().ok('Acesso não validado', msg)
    if open_settings:
        open_settings_after_error('erro de login')

def get_json(endpoint):
    data, error_type, error_message = probe_player_api(endpoint)
    if data is not None:
        return data

    if error_type == 'missing_config':
        show_settings_error('Configuração necessária', 'Configure servidor, usuário e senha antes de continuar.', 'configuração necessária')
    elif error_type == 'login':
        show_login_error()
    elif error_type == 'blocked':
        show_settings_error('Acesso bloqueado', 'Sua conta está bloqueada ou desativada.\nConfira o usuário/servidor ou entre em contato com o suporte.', 'acesso bloqueado')
    elif error_type == 'expired':
        show_settings_error('Conta vencida', 'Sua conta está vencida.\nRenove ou confira o usuário/servidor para continuar.', 'conta vencida')
    elif error_type == 'invalid_response':
        show_settings_error('Resposta inválida do servidor', 'O servidor respondeu em formato inesperado.\nVerifique servidor, usuário e senha e tente novamente.', 'erro de comunicação')
    elif error_type == 'rate_limit':
        show_dialog('Limite temporário', 'Muitas tentativas em pouco tempo.\nAguarde alguns instantes e tente novamente.')
    else:
        show_settings_error('Servidor não validado', 'Não foi possível validar o acesso neste servidor.\nVerifique servidor, usuário e senha e tente novamente.', 'acesso não validado')
    return None

# =========================
# Descrições (helpers + cache)
# =========================
def clean_plot(text):
    text = html.unescape(text or '')
    text = _TAG_RE.sub('', text)
    return text.strip()

def desc_cache_load(path):
    data = load_json_file(path, {})
    return data if isinstance(data, dict) else {}

def trim_desc_cache(cache):
    if not isinstance(cache, dict):
        return {}
    now_ts = time.time()
    items = []
    for key, entry in cache.items():
        if not isinstance(entry, dict):
            continue
        try:
            fetched_at = float(entry.get('fetched_at') or 0)
        except Exception:
            fetched_at = 0
        if fetched_at and (now_ts - fetched_at) > DESC_TTL:
            continue
        items.append((str(key), {'plot': entry.get('plot', '') or '', 'fetched_at': fetched_at}))
    if len(items) > MAX_DESC_CACHE_ENTRIES:
        items = sorted(items, key=lambda item: item[1].get('fetched_at') or 0, reverse=True)[:MAX_DESC_CACHE_ENTRIES]
    return {key: value for key, value in items}

def desc_cache_save(path, cache):
    try:
        atomic_write_json(path, trim_desc_cache(cache), ensure_ascii=False, indent=2)
    except Exception as e:
        log(f"Falha ao salvar cache de descrições: {e}", xbmc.LOGERROR)

def desc_cache_get(cache, key):
    k = str(key)
    e = cache.get(k)
    if not isinstance(e, dict):
        return (False, '')
    fetched_at = e.get('fetched_at', 0)
    plot = e.get('plot', '') or ''
    ttl = DESC_TTL if plot else DESC_EMPTY_TTL
    if not fetched_at or (time.time() - fetched_at) > ttl:
        return (False, '')
    return (True, plot)

def desc_cache_put(cache, key, plot):
    cache[str(key)] = {'plot': plot or '', 'fetched_at': time.time()}



def _normalize_vod_identity_title(value):
    value = html.unescape(str(value or '')).strip().lower()
    value = re.sub(r'\[[^\]]+\]', ' ', value)
    value = re.sub(r'\([^)]*(?:dublado|legendado|dual|4k|hd|fhd|uhd)[^)]*\)', ' ', value, flags=re.I)
    value = re.sub(r'[^a-z0-9\u00c0-\u024f]+', ' ', value, flags=re.I)
    return re.sub(r'\s+', ' ', value).strip()


def _extract_vod_year(candidate):
    for key in ('year', 'release_year', 'releasedate', 'releaseDate'):
        raw = str((candidate or {}).get(key) or '')
        match = re.search(r'(19|20)\d{2}', raw)
        if match:
            return match.group(0)
    title = str((candidate or {}).get('title') or '')
    match = re.search(r'\b((?:19|20)\d{2})\b', title)
    return match.group(1) if match else ''


def vod_usage_storage_path():
    return server_slot_storage_path('vod_usage_stats.json')


def vod_seen_storage_path():
    return server_slot_storage_path('vod_seen_ids.json')


def vod_prefetch_request_storage_path():
    return server_slot_storage_path('vod_catalog_prefetch_request.json')


def vod_metadata_profile_storage_path():
    return server_slot_storage_path('vod_metadata_profile.json')


def series_metadata_profile_storage_path():
    return server_slot_storage_path('series_metadata_profile.json')


def _metadata_profile_is_native(path):
    profile = load_json_file(path, {})
    if not isinstance(profile, dict):
        return False
    if profile.get('mode') == 'native_metadata':
        return True
    try:
        # Compatibilidade imediata com perfis da 0.3.3.6: se a descoberta já
        # encontrou ao menos uma sinopse, a UI não agenda novas consultas.
        return int(profile.get('synopsis_total') or 0) > 0
    except Exception:
        return False


def mark_native_metadata_from_catalog(path, synopsis_total, sample_total):
    """Persiste a evidência nativa antes que a UI possa criar uma fila."""
    try:
        synopsis_total = int(synopsis_total or 0)
        sample_total = int(sample_total or 0)
    except Exception:
        return False
    if synopsis_total <= 0:
        return False

    profile = load_json_file(path, {})
    if not isinstance(profile, dict):
        profile = {}
    if (
        profile.get('mode') == 'native_metadata'
        and profile.get('detection_rule') == 'any_native_synopsis_v1'
        and 'threshold' not in profile
    ):
        # A evidência já é persistente; evita escrita em disco a cada listagem.
        return True
    try:
        previous_synopsis = int(profile.get('synopsis_total') or 0)
        previous_sample = int(profile.get('sample_total') or 0)
    except Exception:
        previous_synopsis = 0
        previous_sample = 0

    profile['mode'] = 'native_metadata'
    profile['detection_rule'] = 'any_native_synopsis_v1'
    profile['synopsis_total'] = max(previous_synopsis, synopsis_total)
    profile['sample_total'] = max(previous_sample, sample_total)
    if profile['sample_total'] > 0:
        profile['coverage'] = min(
            1.0,
            float(profile['synopsis_total']) / float(profile['sample_total'])
        )
    profile['classified_at'] = time.time()
    profile.pop('threshold', None)
    atomic_write_json(path, profile, ensure_ascii=False, indent=2)
    return True


def is_vod_native_metadata_mode():
    return _metadata_profile_is_native(vod_metadata_profile_storage_path())


def is_series_native_metadata_mode():
    return _metadata_profile_is_native(series_metadata_profile_storage_path())


def record_vod_usage(vod_id, title='', year=''):
    vod_id = str(vod_id or '').strip()
    if not vod_id:
        return
    path = vod_usage_storage_path()
    data = load_json_file(path, {})
    if not isinstance(data, dict):
        data = {}
    entry = data.get(vod_id) if isinstance(data.get(vod_id), dict) else {}
    entry['count'] = min(100000, int(entry.get('count') or 0) + 1)
    entry['last_access_at'] = time.time()
    entry['title'] = str(title or entry.get('title') or '')[:160]
    entry['year'] = str(year or entry.get('year') or '')[:4]
    data[vod_id] = entry
    # Mantém índice pequeno e útil.
    ordered = sorted(
        data.items(),
        key=lambda kv: (
            int((kv[1] or {}).get('count') or 0),
            float((kv[1] or {}).get('last_access_at') or 0),
        ),
        reverse=True,
    )[:10000]
    atomic_write_json(path, dict(ordered), ensure_ascii=False, indent=2)


def schedule_vod_catalog_prefetch(categories):
    if is_vod_native_metadata_mode():
        return 0
    selected = []
    for item in categories or []:
        if not isinstance(item, dict) or normalize_bool(item.get('is_adult', False)):
            continue
        cid = str(item.get('category_id') or '').strip()
        if cid:
            selected.append({'category_id': cid, 'title': str(item.get('title') or '')[:120]})
        if len(selected) >= 4:
            break
    if not selected:
        return 0
    payload = {'categories': selected, 'updated_at': time.time(), 'cursor': 0}
    atomic_write_json(vod_prefetch_request_storage_path(), payload, ensure_ascii=False, indent=2)
    return len(selected)


def vod_desc_hydration_queue_storage_path():
    return server_slot_storage_path('vod_desc_hydration_queue.json')

def enqueue_vod_description_hydration(candidates, priority=20, source='category'):
    """Agenda poucos VODs sem sinopse para o service, sem bloquear a UI.

    A listagem continua usando apenas a resposta resumida e o cache local.
    O service residente consulta get_vod_info progressivamente.
    """
    if not candidates or is_vod_native_metadata_mode():
        return 0
    path = vod_desc_hydration_queue_storage_path()
    data = load_json_file(path, {})
    usage_data = load_json_file(vod_usage_storage_path(), {})
    if not isinstance(usage_data, dict):
        usage_data = {}
    seen_data = load_json_file(vod_seen_storage_path(), {})
    if not isinstance(seen_data, dict):
        seen_data = {}
    if not isinstance(data, dict):
        data = {}
    pending = data.get('pending') or []
    if not isinstance(pending, list):
        pending = []
    existing_entries = {
        str(entry.get('vod_id') or ''): entry
        for entry in pending
        if isinstance(entry, dict) and str(entry.get('vod_id') or '').strip()
    }
    existing = set(existing_entries)
    added = 0
    promoted = 0
    now_ts = time.time()
    for candidate in candidates:
        if not isinstance(candidate, dict):
            continue
        vod_id = str(candidate.get('vod_id') or '').strip()
        if not vod_id:
            continue
        year = _extract_vod_year(candidate)
        usage_entry = usage_data.get(vod_id) if isinstance(usage_data.get(vod_id), dict) else {}
        usage_count = int(usage_entry.get('count') or 0)
        popularity_boost = min(30, usage_count * 3)
        is_new = vod_id not in seen_data
        effective_priority = int(priority or 0) + popularity_boost + (40 if is_new else 0)
        if vod_id in existing:
            current = existing_entries.get(vod_id)
            if isinstance(current, dict) and effective_priority > int(current.get('priority') or 0):
                current['priority'] = effective_priority
                current['source'] = str(source or 'category')[:32]
                promoted += 1
            continue
        pending.append({
            'vod_id': vod_id,
            'title': str(candidate.get('title') or '')[:160],
            'queued_at': now_ts,
            'attempts': 0,
            'priority': effective_priority,
            'source': str(source or 'category')[:32],
            'year': year,
            'identity_title': _normalize_vod_identity_title(candidate.get('title') or ''),
        })
        existing.add(vod_id)
        added += 1
        if len(pending) >= VOD_DESC_HYDRATE_QUEUE_MAX:
            break
    if candidates:
        for candidate in candidates:
            if isinstance(candidate, dict):
                candidate_id = str(candidate.get('vod_id') or '').strip()
                if candidate_id:
                    seen_data[candidate_id] = time.time()
        if len(seen_data) > 100000:
            seen_data = dict(sorted(seen_data.items(), key=lambda kv: float(kv[1] or 0), reverse=True)[:100000])
        atomic_write_json(vod_seen_storage_path(), seen_data, ensure_ascii=False, indent=2)
    if added or promoted:
        pending.sort(key=lambda e: (-int(e.get('priority') or 0), float(e.get('queued_at') or 0)) if isinstance(e, dict) else (0, 0))
        data['pending'] = pending[:VOD_DESC_HYDRATE_QUEUE_MAX]
        data['updated_at'] = now_ts
        try:
            atomic_write_json(path, data, ensure_ascii=False, indent=2)
            log(
                f'Hidratação VOD agendada em background: {added} novo(s), {promoted} promovido(s).',
                xbmc.LOGDEBUG
            )
        except Exception as exc:
            log(f'Falha ao agendar hidratação VOD: {exc}', xbmc.LOGDEBUG)
            return 0
    return added + promoted

def extract_vod_plot(vod_info_json):
    if not isinstance(vod_info_json, dict):
        return ''
    info = vod_info_json.get('info') or {}
    movie_data = vod_info_json.get('movie_data') or {}
    plot = (
        info.get('plot')
        or info.get('description')
        or movie_data.get('plot')
        or movie_data.get('description')
        or ''
    )
    return clean_plot(plot)

def extract_series_plot(series_info_json):
    if not isinstance(series_info_json, dict):
        return ''
    info = series_info_json.get('info') or {}
    plot = (
        info.get('plot')
        or info.get('description')
        or info.get('overview')
        or ''
    )
    return clean_plot(plot)

def get_vod_plot_cached(vod_id):
    cache = desc_cache_load(vod_desc_cache_storage_path())
    has, plot = desc_cache_get(cache, vod_id)
    if has:
        return plot

    if is_vod_native_metadata_mode():
        # No modo nativo, ausência no resumo/cache é tratada como vazio real.
        # Registra TTL curto para o play/contexto não insistir na API de detalhe.
        desc_cache_put(cache, vod_id, '')
        desc_cache_save(vod_desc_cache_storage_path(), cache)
        return ''

    vod_info = get_json(f"action=get_vod_info&vod_id={vod_id}")
    plot = extract_vod_plot(vod_info)
    desc_cache_put(cache, vod_id, plot)
    desc_cache_save(vod_desc_cache_storage_path(), cache)
    return plot

def get_series_plot_cached(series_id):
    cache = desc_cache_load(series_desc_cache_storage_path())
    has, plot = desc_cache_get(cache, series_id)
    if has:
        return plot

    if is_series_native_metadata_mode():
        desc_cache_put(cache, series_id, '')
        desc_cache_save(series_desc_cache_storage_path(), cache)
        return ''

    series_info = get_json(f"action=get_series_info&series_id={series_id}")
    plot = extract_series_plot(series_info)
    desc_cache_put(cache, series_id, plot)
    desc_cache_save(series_desc_cache_storage_path(), cache)
    return plot

# =========================
# EPG (smart cache + parsing indexado)
# Extraído de forma conservadora para resources/lib/epg_runtime.py.
# O fluxo visual/navegação continua no main.py para evitar regressões no Kodi.
# =========================
XMLTV_DEFAULT_OFFSET_SECS = -(3 * 3600)  # America/Sao_Paulo quando o feed vier sem timezone
EPG_BOUNDARY_TOLERANCE_SECS = 300  # tolera pequenos buracos/atrasos de grade e virada de bloco


def _epg_ctx():
    return {
        'profile_dir': PROFILE_DIR,
        'username': USERNAME,
        'password': PASSWORD,
        'epg_cache_days': EPG_CACHE_DAYS,
        'epg_ttl': EPG_TTL,
        'epg_index_version': EPG_INDEX_VERSION,
        'epg_index_window_days': EPG_INDEX_WINDOW_DAYS,
        'max_epg_index_files': MAX_EPG_INDEX_FILES,
        'xmltv_default_offset_secs': XMLTV_DEFAULT_OFFSET_SECS,
        'epg_boundary_tolerance_secs': EPG_BOUNDARY_TOLERANCE_SECS,
        'epg_refresh_margin_secs': EPG_REFRESH_MARGIN_SECS,
        'epg_min_future_coverage_secs': EPG_MIN_FUTURE_COVERAGE_SECS,
        'epg_min_refresh_interval_secs': EPG_MIN_REFRESH_INTERVAL_SECS,
        'epg_require_full_window': EPG_REQUIRE_FULL_WINDOW,
        'request_background_refresh_on_ui': True,
        'log_fn': log,
        'load_json_fn': load_json_file,
        'atomic_write_json_fn': atomic_write_json,
        'atomic_write_text_fn': atomic_write_text,
        'epg_meta_storage_path_fn': epg_meta_storage_path,
        'epg_xml_storage_path_fn': epg_xml_storage_path,
        'server_slot_storage_path_fn': server_slot_storage_path,
        'safe_delete_file_fn': safe_delete_file,
        'ensure_profile_dir_fn': ensure_profile_dir,
        'ensure_parent_dir_fn': ensure_parent_dir,
        'safe_requests_get_fn': safe_requests_get,
        'get_base_url_fn': get_base_url,
        'fingerprint_fn': fingerprint,
        'clear_desc_caches_fn': clear_desc_caches,
        'current_server_scope_identity_fn': current_server_scope_identity,
        # No plugin/UI, não baixar XMLTV pesado sob demanda. O service.py cuida disso em background.
        'download_on_demand': False,
    }


def epg_meta_load():
    return epg_runtime.epg_meta_load(_epg_ctx())


def epg_meta_save(meta):
    return epg_runtime.epg_meta_save(_epg_ctx(), meta)


def epg_local_day_window(now_ts=None, window_days=None):
    return epg_runtime.epg_local_day_window(_epg_ctx(), now_ts=now_ts, window_days=window_days)


def epg_clear_index_cache():
    return epg_runtime.epg_clear_index_cache(_epg_ctx())


def epg_runtime_cache_invalidate(reason=None, clear_scope=True):
    return epg_runtime.epg_runtime_cache_invalidate(_epg_ctx(), reason=reason, clear_scope=clear_scope)

def request_epg_background_refresh(reason='ui_settings_changed'):
    """Sinaliza o service residente para preparar XMLTV fora da navegação.

    O Kodi 21 executa RunScript(path) sem contexto de addon em alguns cenários.
    Por isso a UI não inicia mais service.py avulso: grava somente o pedido por
    servidor, que o service já residente consome em poucos segundos.
    """
    try:
        if str(ENABLE_EPG or '').lower() != 'true':
            return False
        if not (USERNAME and PASSWORD and get_base_url()):
            return False
        ensure_profile_dir()
        return bool(epg_runtime.epg_mark_refresh_requested(_epg_ctx(), reason=reason))
    except Exception as exc:
        log(f'Falha ao solicitar refresh EPG em background: {exc}', xbmc.LOGDEBUG)
        return False




def probe_epg_background_refresh(reason='ui_live_categories_probe'):
    """Inspeção local e leve antes de abrir categorias Live.

    Nunca baixa XMLTV na navegação: apenas verifica metadados/arquivo e grava
    um pedido único para o service.py quando a grade está ausente, vazia,
    vencida ou sem cobertura de Hoje + Amanhã.
    """
    try:
        if str(ENABLE_EPG or '').lower() != 'true':
            return False
        if not (USERNAME and PASSWORD and get_base_url()):
            return False
        ctx = _epg_ctx()
        if epg_runtime.epg_should_refresh(ctx):
            return bool(epg_runtime.epg_mark_refresh_requested(ctx, reason=reason))
    except Exception as exc:
        log(f'Verificação leve do EPG falhou sem bloquear categorias: {exc}', xbmc.LOGDEBUG)
    return False

def epg_xml_identity(xml_path):
    return epg_runtime.epg_xml_identity(xml_path)


def normalize_epg_channel_id(cid):
    return epg_runtime.normalize_epg_channel_id(cid)


def normalize_epoch_seconds(value):
    return epg_runtime.normalize_epoch_seconds(value)


def epg_load_parsed(force=False):
    return epg_runtime.epg_load_parsed(_epg_ctx(), force=force)

def epg_lookup_current_next(epg_channel_id, epg):
    cid = normalize_epg_channel_id(epg_channel_id)
    now = int(time.time())
    tolerance = max(0, int(EPG_BOUNDARY_TOLERANCE_SECS or 0))

    plist = epg['progs'].get(cid, [])
    current, nextp = None, None

    for i, pr in enumerate(plist):
        start = normalize_epoch_seconds(pr.get('start')) or now
        end = normalize_epoch_seconds(pr.get('end')) or (start + 3600)
        if end <= start:
            end = start + 3600
        pr['start'] = start
        pr['end'] = end

        if (start - tolerance) <= now < (end + tolerance):
            current = pr
            if i + 1 < len(plist):
                nextp = plist[i + 1]
            break
        if start > now:
            nextp = pr
            if i - 1 >= 0 and normalize_epoch_seconds(plist[i - 1].get('end')) >= (now - tolerance):
                current = plist[i - 1]
            break

    return current, nextp

def epg_lookup_day_schedule(epg_channel_id, epg, limit=12, include_current=False):
    """Retorna uma grade móvel a partir do horário atual.

    Regra de produção: a grade não deve morrer no fim do dia civil (23:59).
    O índice XMLTV já carrega hoje + próximos dias; aqui apenas pegamos os
    próximos blocos disponíveis, atravessando a meia-noite quando necessário.
    """
    cid = normalize_epg_channel_id(epg_channel_id)
    now = int(time.time())
    tolerance = max(0, int(EPG_BOUNDARY_TOLERANCE_SECS or 0))
    max_items = max(0, int(limit or 0))

    plist = epg['progs'].get(cid, [])
    out = []

    for pr in plist:
        start = normalize_epoch_seconds(pr.get('start')) or now
        end = normalize_epoch_seconds(pr.get('end')) or (start + 3600)
        if end <= start:
            end = start + 3600
        pr['start'] = start
        pr['end'] = end

        if end < (now - tolerance):
            continue
        if not include_current and (start - tolerance) <= now < (end + tolerance):
            continue

        out.append(pr)
        if max_items and len(out) >= max_items:
            break

    return out


# =========================
# UI (menus)
# =========================
def build_menu(items, mode=None, is_playable=False, cache_to_disc=True):
    if not items:
        end_directory(cache_to_disc=cache_to_disc)
        return

    favorite_keys = favorites_key_set() if is_favorites_enabled() else set()

    for item in items:
        label = item.get('title', '')
        li = xbmcgui.ListItem(label=label)

        icon = resolve_item_icon(item)
        fanart = resolve_item_fanart(item)
        li.setArt({'icon': icon, 'thumb': icon, 'fanart': fanart})
        if fanart:
            li.setProperty('Fanart_Image', fanart)

        plot = item.get('plot', '') or ''
        info = {'title': label}
        if plot:
            info['plot'] = plot
        apply_video_info(li, info)

        media_type = item.get('media_type') or ''
        if media_type:
            try:
                li.setProperty('mediatype', media_type)
            except Exception:
                pass

        item_is_adult = normalize_bool(item.get('is_adult', False))
        cm = []
        if item.get('vod_id'):
            cm_url = build_url(mode='show_vod_plot', vod_id=str(item['vod_id']), title=label, category_id=str(item.get('category_id', '') or ''), is_adult=str(item_is_adult).lower())
            cm.append(("Sinopse", f'RunPlugin({cm_url})'))
        if item.get('series_id'):
            cm_url = build_url(mode='show_series_plot', series_id=str(item['series_id']), title=label, category_id=str(item.get('category_id', '') or ''), is_adult=str(item_is_adult).lower())
            cm.append(("Sinopse", f'RunPlugin({cm_url})'))

        if str(item.get('stream_kind') or '').strip().lower() == 'live' and str(ENABLE_EPG or '').lower() == 'true' and item.get('epg_channel_id'):
            full_epg_url = build_url(
                mode='show_full_epg',
                stream_id=str(item.get('stream_id', '') or ''),
                stream_name=str(item.get('stream_name') or favorite_base_title('live', label) or 'Canal'),
                epg_channel_id=str(item.get('epg_channel_id') or '')
            )
            cm.append(('Ver Programação Completa', f'Container.Update({full_epg_url})'))

        favorite_entry = favorite_entry_from_item(item) if is_favorites_enabled() else None
        favorite_key_value = favorite_entry.get('key', '') if favorite_entry else ''
        favorite_kind = infer_favorite_kind(item) if is_favorites_enabled() else ''
        if is_favorites_enabled() and favorite_kind and favorite_key_value:
            base_params = {
                'fav_kind': favorite_kind,
                'title': label,
                'favorite_title': str(item.get('favorite_title') or favorite_base_title(favorite_kind, label)),
                'icon': icon,
                'fanart': fanart,
                'plot': plot,
                'category_id': str(item.get('category_id', '') or ''),
                'is_adult': str(item_is_adult).lower(),
                'stream_id': str(item.get('stream_id', '') or ''),
                'vod_id': str(item.get('vod_id', '') or ''),
                'series_id': str(item.get('series_id', '') or ''),
                'container_extension': str(item.get('container_extension', '') or ''),
                'stream_type': str(item.get('stream_type', '') or ''),
                'stream_kind': str(item.get('stream_kind', '') or ''),
                'epg_channel_id': str(item.get('epg_channel_id', '') or '')
            }
            if item.get('from_search'):
                base_params['from_search'] = str(item.get('from_search')).lower()
                if item.get('search_scope'):
                    base_params['search_scope'] = item.get('search_scope', '')
                if item.get('search_query'):
                    base_params['search_query'] = item.get('search_query', '')
            if favorite_key_value in favorite_keys:
                remove_params = {'mode': 'remove_favorite', 'favorite_key': favorite_key_value}
                if item.get('from_search'):
                    remove_params['from_search'] = str(item.get('from_search')).lower()
                    if item.get('search_scope'):
                        remove_params['search_scope'] = item.get('search_scope', '')
                    if item.get('search_query'):
                        remove_params['search_query'] = item.get('search_query', '')
                cm_url = build_url(**remove_params)
                cm.append(('Remover dos Favoritos', f'RunPlugin({cm_url})'))
            else:
                cm_url = build_url(mode='add_favorite', **base_params)
                cm.append(('Adicionar aos Favoritos', f'RunPlugin({cm_url})'))

        if cm:
            li.addContextMenuItems(cm, replaceItems=False)

        rebuildable = stream_identity_is_rebuildable(item)
        if is_playable and (item.get('url') or rebuildable):
            play = ''
            if not rebuildable:
                play = ensure_play_url_headers(item.get('url', ''), item.get('stream_kind', ''))
                if not play:
                    log(f"Ignorando item reproduzível sem URL: {label}", xbmc.LOGDEBUG)
                    continue

            li.setProperty('IsPlayable', 'true')

            params = {
                'mode': 'play',
                'title': label,
                'icon': icon,
                'fanart': fanart,
                'normalplayer': str(item.get('normalplayer', 'false')).lower(),
                'stream_kind': item.get('stream_kind', ''),
                'container_extension': item.get('container_extension', ''),
                'stream_type': item.get('stream_type', ''),
                'category_id': str(item.get('category_id', '') or ''),
                'stream_id': str(item.get('stream_id', '') or ''),
                'epg_channel_id': str(item.get('epg_channel_id', '') or ''),
                'is_adult': str(item_is_adult).lower()
            }
            if not rebuildable:
                params['url'] = play
            # Em filmes/episódios, evita o bookmark/resume nativo do Kodi
            # preso na URL do plugin (que pode disparar antes do fluxo interno do addon).
            if item.get('vod_id'):
                params['vod_id'] = str(item['vod_id'])
            if item.get('series_id'):
                params['series_id'] = str(item['series_id'])
            if item.get('from_search'):
                params['from_search'] = str(item.get('from_search')).lower()
            if item.get('search_scope'):
                params['search_scope'] = item.get('search_scope', '')
            if item.get('search_query'):
                params['search_query'] = item.get('search_query', '')

            url = build_url(**params)
            xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, False)
        else:
            url_params = {'mode': mode} if mode else {'mode': item.get('mode')}
            if 'params' in item:
                qs = urllib.parse.parse_qs(item['params'], keep_blank_values=True)
                for k, v in qs.items():
                    url_params[k] = v[0] if isinstance(v, list) else v
            if item.get('category_id'):
                url_params['category_id'] = str(item.get('category_id', '') or '')
            if item.get('is_adult'):
                url_params['is_adult'] = str(item_is_adult).lower()
            if item.get('from_search'):
                url_params['from_search'] = str(item.get('from_search')).lower()
            if item.get('search_scope'):
                url_params['search_scope'] = item.get('search_scope', '')
            if item.get('search_query'):
                url_params['search_query'] = item.get('search_query', '')
            url = build_url(**url_params)
            xbmcplugin.addDirectoryItem(ADDON_HANDLE, url, li, item.get('folder', True))

    end_directory(cache_to_disc=cache_to_disc)

# =========================
# Funções de dados
# =========================
# =========================
# Funções de dados
# =========================
def get_categories(endpoint, content_endpoint=None):
    data = get_json(endpoint)
    if not data:
        return []

    if isinstance(data, list):
        cats = data
    elif isinstance(data, dict) and 'categories' in data:
        cats = data['categories']
    else:
        cats = list(data.values()) if isinstance(data, dict) else []

    counts_map = get_category_counts_for_endpoint(content_endpoint) if content_endpoint else {}

    items = []
    for cat in cats:
        name = html.unescape(cat.get('category_name', 'Sem nome'))
        category_id = str(cat.get('category_id', '') or '').strip()
        item_is_adult = is_adult_name(name)
        if item_is_adult and not is_adult_enabled():
            continue

        display_title = name
        plot = ''
        if counts_map:
            try:
                total_count = int(counts_map.get(category_id, 0) or 0)
            except Exception:
                total_count = 0
            display_title = format_category_title_with_count(name, total_count)
            media_label = category_count_media_label(content_endpoint, total_count)
            plot = f'Esta categoria possui {total_count} {media_label}.'

        item = {'title': display_title, 'params': f"category_id={category_id}", 'category_id': category_id, 'is_adult': item_is_adult}
        if plot:
            item['plot'] = plot
        items.append(item)
    return items

def get_adult_category_ids(endpoint):
    data = get_json(endpoint)
    if not data:
        return set()

    if isinstance(data, list):
        cats = data
    elif isinstance(data, dict) and 'categories' in data:
        cats = data['categories']
    else:
        cats = list(data.values()) if isinstance(data, dict) else []

    adult_ids = set()
    for cat in cats:
        name = html.unescape((cat.get('category_name', '') or '').strip())
        category_id = str(cat.get('category_id', '') or '').strip()
        if category_id and is_adult_name(name):
            adult_ids.add(category_id)
    return adult_ids

def get_adult_content_ids(endpoint, adult_category_ids=None):
    adult_category_ids = {str(x or '').strip() for x in (adult_category_ids or set()) if str(x or '').strip()}
    if not adult_category_ids:
        return set()

    key_name = 'stream_id'
    if endpoint == 'get_series':
        key_name = 'series_id'

    ids = set()
    for cid in adult_category_ids:
        try:
            items = get_items(endpoint, cid, True, adult_category_ids)
        except Exception as exc:
            log(f'Falha ao mapear conteúdo adulto em {endpoint}/{cid}: {exc}', xbmc.LOGERROR)
            continue
        for item in items:
            item_id = str(item.get(key_name, '') or '').strip()
            if item_id:
                ids.add(item_id)
    return ids

def ensure_epg_loaded():
    return epg_load_parsed()

def epg_format_range(pr):
    if not pr:
        return ''
    try:
        start = int(pr.get('start') or 0)
        end = int(pr.get('end') or 0)
    except Exception:
        start, end = 0, 0
    if start <= 0:
        return ''
    if end <= start:
        end = start + 3600
    return f"{datetime.fromtimestamp(start).strftime('%H:%M')} - {datetime.fromtimestamp(end).strftime('%H:%M')}"

def build_live_epg_plot(current=None, nextp=None, include_desc=True, day_schedule=None, compact=False, show_now_next=True, now_next_color=None, current_color=None, next_color=None, schedule_color=None, schedule_header_color=None, schedule_current_color=None, next_day_preview=None, next_day_header='Madrugada / Próximo dia', next_day_header_color=None, next_day_color=None):
    parts = []

    if current_color is None:
        current_color = now_next_color
    if next_color is None:
        next_color = now_next_color

    if show_now_next and current:
        current_title = str(current.get('title') or '').strip()
        current_desc = str(current.get('desc') or '').strip()
        current_range = epg_format_range(current)
        if current_range and current_title:
            current_line = f"Agora: {current_range} | {current_title}"
        elif current_title:
            current_line = f"Agora: {current_title}"
        else:
            current_line = ''
        if current_line:
            if current_color:
                parts.append(color(current_line, current_color))
            elif compact:
                parts.append(color(current_line, 'aquamarine'))
            else:
                parts.append(current_line)
        if include_desc and current_desc:
            parts.append(current_desc)

    if show_now_next and nextp:
        next_title = str(nextp.get('title') or '').strip()
        next_desc = str(nextp.get('desc') or '').strip()
        next_range = epg_format_range(nextp)
        if current and (next_title or (include_desc and next_desc)) and not compact:
            parts.append('')
        if next_range and next_title:
            next_line = f"Próximo: {next_range} | {next_title}"
        elif next_title:
            next_line = f"Próximo: {next_title}"
        else:
            next_line = ''
        if next_line:
            if next_color:
                parts.append(color(next_line, next_color))
            elif compact:
                parts.append(color(next_line, 'aquamarine'))
            else:
                parts.append(next_line)
        if include_desc and next_desc:
            parts.append(next_desc)

    schedule_lines = []
    now_ts = int(time.time())

    # Define uma ÚNICA programação atual na Grade do dia.
    # Conservador: mantém o visual anterior à revisão de cores, mas remove a
    # tolerância da marcação visual para não pintar dois horários ao mesmo tempo.
    # Se o XMLTV vier com sobreposição, vence o programa com início mais recente.
    schedule_items = list(day_schedule or [])
    current_schedule_idx = None
    current_schedule_start = None
    for idx, candidate in enumerate(schedule_items):
        start = normalize_epoch_seconds(candidate.get('start')) or 0
        end = normalize_epoch_seconds(candidate.get('end')) or 0
        if start <= 0:
            continue
        if end <= start:
            end = start + 3600
        if start <= now_ts < end:
            if current_schedule_start is None or start >= current_schedule_start:
                current_schedule_idx = idx
                current_schedule_start = start

    for idx, pr in enumerate(schedule_items):
        title = str(pr.get('title') or '').strip()
        when = epg_format_range(pr)
        if when and title:
            line = f"{when} | {title}"
        elif title:
            line = title
        else:
            line = ''
        if not line:
            continue

        is_current = (current_schedule_idx is not None and idx == current_schedule_idx)

        if is_current and schedule_current_color:
            schedule_lines.append(color(line, schedule_current_color))
        elif schedule_color:
            schedule_lines.append(color(line, schedule_color))
        else:
            schedule_lines.append(line)

    if schedule_lines:
        if parts and not compact:
            parts.append('')
        header = 'Grade do dia'
        if schedule_header_color:
            header = color(header, schedule_header_color)
        elif compact:
            header = color(header, 'gold')
        parts.append(header)
        parts.extend(schedule_lines)

    preview_lines = []
    for pr in (next_day_preview or []):
        title = str(pr.get('title') or '').strip()
        when = epg_format_range(pr)
        if when and title:
            line = f"{when} | {title}"
        elif title:
            line = title
        else:
            line = ''
        if not line:
            continue

        if next_day_color:
            preview_lines.append(color(line, next_day_color))
        else:
            preview_lines.append(line)

    if preview_lines:
        if parts:
            parts.append('')
        header = str(next_day_header or 'Madrugada / Próximo dia').strip() or 'Madrugada / Próximo dia'
        if next_day_header_color:
            header = color(header, next_day_header_color)
        elif schedule_header_color:
            header = color(header, schedule_header_color)
        elif compact:
            header = color(header, 'gold')
        parts.append(header)
        parts.extend(preview_lines)

    return "\n".join(parts).strip()

def annotate_live_with_epg(items_from_api):
    epg = ensure_epg_loaded()
    out = []
    for s in items_from_api:
        name = s.get('title') or s.get('name') or 'Sem nome'
        epg_id = s.get('epg_channel_id')
        current, nextp = epg_lookup_current_next(epg_id, epg) if epg_id else (None, None)
        day_schedule = epg_lookup_day_schedule(epg_id, epg, limit=12, include_current=True) if epg_id else []
        # A grade agora é móvel e atravessa a meia-noite; prévia separada evitaria duplicar itens.
        next_day_preview = []

        label = name
        if current:
            current_title = str(current.get('title') or '').strip()
            label = f"{name} - {color(current_title, 'aquamarine')}" if current_title else name

        # Grade compacta móvel: próximos horários a partir de agora, sem travar em 23:59.
        plot = build_live_epg_plot(
            current=current,
            nextp=nextp,
            include_desc=False,
            day_schedule=day_schedule,
            compact=True,
            show_now_next=False,
            schedule_color=None,
            schedule_current_color='aquamarine',
            schedule_header_color='gold',
            next_day_preview=next_day_preview,
            next_day_header='Madrugada / Próximo dia',
            next_day_header_color='gold',
            next_day_color=None
        )

        s2 = dict(s)
        s2['stream_name'] = name
        s2['title'] = label
        if plot:
            s2['plot'] = plot
        out.append(s2)
    return out

def get_items(endpoint, category_id=None, category_is_adult=False, adult_category_ids=None):
    BASE_URL = get_base_url()
    params = f"&category_id={category_id}" if category_id else ""
    data = get_json(f"action={endpoint}{params}")
    if not data:
        return []
    data, duplicate_total = dedupe_api_catalog_items(data, endpoint)
    if duplicate_total:
        log(
            f'Catálogo {endpoint}: {duplicate_total} linha(s) duplicada(s) ignorada(s) na interface.',
            xbmc.LOGDEBUG,
        )

    if adult_category_ids is None:
        adult_category_ids = set()
    else:
        adult_category_ids = {str(x or '').strip() for x in adult_category_ids if str(x or '').strip()}

    items = []

    if endpoint in ['get_live_streams', 'get_vod_streams']:
        vod_cache = None
        vod_cache_changed = False
        vod_hydration_candidates = []
        vod_native_mode = False
        if endpoint == 'get_vod_streams':
            native_synopsis_total = sum(
                1 for entry in data
                if isinstance(entry, dict)
                and clean_plot(
                    entry.get('plot')
                    or entry.get('description')
                    or entry.get('overview')
                    or (entry.get('info', {}) or {}).get('plot')
                    or (entry.get('info', {}) or {}).get('description')
                    or (entry.get('info', {}) or {}).get('overview')
                    or ''
                )
            )
            if native_synopsis_total:
                mark_native_metadata_from_catalog(
                    vod_metadata_profile_storage_path(), native_synopsis_total, len(data)
                )
            vod_cache = desc_cache_load(vod_desc_cache_storage_path())
            vod_native_mode = is_vod_native_metadata_mode()

        for s in data:
            url = s.get('stream_url', '')
            sid = s.get('stream_id')
            stype = s.get('stream_type', '')
            name = html.unescape(s.get('name', 'Sem nome'))
            icon = s.get('stream_icon', '')
            epg_channel_id = s.get('epg_channel_id') or None
            container_extension = (s.get('container_extension') or 'mp4').lower()
            item_category_id = str(s.get('category_id') or category_id or '').strip()
            item_is_adult = bool(category_is_adult) or item_category_id in adult_category_ids or is_adult_name(name)

            if sid:
                if endpoint == 'get_live_streams':
                    url, container_extension = build_live_stream_url(BASE_URL, USERNAME, PASSWORD, sid)
                else:
                    url = f'{BASE_URL.rstrip("/")}/{stype}/{USERNAME}/{PASSWORD}/{sid}.{container_extension}'

            if not url:
                log(f"Ignorando item sem URL retornado pela API: {name}", xbmc.LOGDEBUG)
                continue

            item = {'title': name, 'stream_name': name, 'url': url, 'icon': icon, 'fanart': addonFanart, 'container_extension': container_extension, 'stream_id': str(sid or ''), 'stream_type': stype, 'category_id': item_category_id, 'is_adult': item_is_adult}

            if epg_channel_id:
                item['epg_channel_id'] = epg_channel_id

            if endpoint == 'get_live_streams':
                item['media_type'] = 'video'
                item['normalplayer'] = 'false'
                item['stream_kind'] = 'live'
                item['favorite_kind'] = 'live'
            else:
                item['media_type'] = 'movie'
                item['normalplayer'] = 'false'
                item['stream_kind'] = 'vod'
                item['favorite_kind'] = 'vod'

            if endpoint == 'get_vod_streams' and sid:
                item['vod_id'] = sid
                plot = clean_plot(
                    s.get('plot')
                    or s.get('description')
                    or s.get('overview')
                    or (s.get('info', {}) or {}).get('plot')
                    or (s.get('info', {}) or {}).get('description')
                    or (s.get('info', {}) or {}).get('overview')
                    or ''
                )
                has_cached_plot = False
                cached_plot = ''
                if vod_cache is not None:
                    has_cached_plot, cached_plot = desc_cache_get(vod_cache, sid)
                if not plot and vod_cache is not None:
                    if has_cached_plot:
                        plot = cached_plot
                if plot:
                    item['plot'] = plot
                    if (not has_cached_plot) or cached_plot != plot:
                        desc_cache_put(vod_cache, sid, plot)
                        vod_cache_changed = True
                elif not has_cached_plot and not vod_native_mode:
                    vod_hydration_candidates.append({
                        'vod_id': str(sid),
                        'title': name,
                        'year': _extract_vod_year(s),
                    })

            items.append(item)

        if endpoint == 'get_vod_streams' and vod_cache_changed:
            desc_cache_save(vod_desc_cache_storage_path(), vod_cache)
        if endpoint == 'get_vod_streams' and vod_hydration_candidates:
            enqueue_vod_description_hydration(vod_hydration_candidates, priority=40, source='category_recent')

        if endpoint == 'get_live_streams' and ENABLE_EPG.lower() == 'true':
            items = annotate_live_with_epg(items)

    elif endpoint == 'get_series':
        native_synopsis_total = sum(
            1 for entry in data
            if isinstance(entry, dict)
            and clean_plot(
                entry.get('plot')
                or entry.get('description')
                or (entry.get('info', {}) or {}).get('plot')
                or (entry.get('info', {}) or {}).get('description')
                or (entry.get('info', {}) or {}).get('overview')
                or ''
            )
        )
        if native_synopsis_total:
            mark_native_metadata_from_catalog(
                series_metadata_profile_storage_path(), native_synopsis_total, len(data)
            )
        series_cache = desc_cache_load(series_desc_cache_storage_path())
        series_cache_changed = False

        for s in data:
            icon = (s.get('info', {}) or {}).get('cover_big') or (s.get('info', {}) or {}).get('movie_image', '')
            if not icon:
                backdrops = s.get('backdrop_path') or []
                if isinstance(backdrops, str):
                    backdrops = [backdrops]
                icon = s.get('cover') if s.get('cover') else (backdrops[0] if backdrops else '')

            series_id = s.get('series_id', '')
            title = html.unescape(s.get('name', 'Sem nome'))
            item_category_id = str(s.get('category_id') or category_id or '').strip()
            item_is_adult = bool(category_is_adult) or item_category_id in adult_category_ids or is_adult_name(title)

            if not series_id:
                log(f"Ignorando série sem series_id: {title}", xbmc.LOGDEBUG)
                continue

            item = {'title': title, 'params': f"series_id={series_id}&category_id={urllib.parse.quote(item_category_id, safe='')}&is_adult={'true' if item_is_adult else 'false'}", 'icon': icon, 'series_id': series_id, 'fanart': addonFanart, 'media_type': 'tvshow', 'category_id': item_category_id, 'is_adult': item_is_adult, 'favorite_kind': 'series'}

            plot = clean_plot(
                s.get('plot')
                or s.get('description')
                or s.get('overview')
                or (s.get('info', {}) or {}).get('plot')
                or (s.get('info', {}) or {}).get('description')
                or (s.get('info', {}) or {}).get('overview')
                or ''
            )
            has_cached_plot = False
            cached_plot = ''
            if not plot and series_id:
                has_cached_plot, cached_plot = desc_cache_get(series_cache, series_id)
                if has_cached_plot:
                    plot = cached_plot
            if plot:
                item['plot'] = plot
                if not has_cached_plot:
                    has_cached_plot, cached_plot = desc_cache_get(series_cache, series_id)
                if (not has_cached_plot) or cached_plot != plot:
                    desc_cache_put(series_cache, series_id, plot)
                    series_cache_changed = True

            items.append(item)

        if series_cache_changed:
            desc_cache_save(series_desc_cache_storage_path(), series_cache)

    return items

def get_seasons(series_id, category_id='', is_adult=False):
    data = get_json(f"action=get_series_info&series_id={series_id}")
    if not data:
        return []

    try:
        plot = extract_series_plot(data)
        if series_id:
            # get_series_info já foi obrigatório para obter episódios. Cacheia
            # também o vazio por TTL curto e evita repetir a mesma chamada no play.
            cache = desc_cache_load(series_desc_cache_storage_path())
            desc_cache_put(cache, series_id, plot)
            desc_cache_save(series_desc_cache_storage_path(), cache)
    except Exception as e:
        log(f"Falha ao cachear sinopse da série: {e}", xbmc.LOGDEBUG)

    seasons = data.get('episodes', {})
    items = []

    def _season_sort_key(raw_name):
        m = re.search(r'\d+', str(raw_name or ''))
        if m:
            try:
                return (0, int(m.group(0)))
            except Exception:
                pass
        return (1, str(raw_name or '').lower())

    for season_name in sorted(seasons.keys(), key=_season_sort_key):
        episodes = seasons.get(season_name) or []
        ep_list = []
        for index, ep in enumerate(episodes):
            index += 1
            title = html.unescape(ep.get('title', 'Sem título'))
            eid = ep.get('id') or ep.get('episode_id') or ep.get('stream_id')
            ext = (ep.get('info', {}) or {}).get('container_extension') or 'mp4'
            icon = (ep.get('info', {}) or {}).get('cover_big') or (ep.get('info', {}) or {}).get('movie_image', '')
            direct_url = '' if eid else ep.get('direct_source', '')
            if not eid and not direct_url:
                log(f"Ignorando episódio sem URL em {season_name}: {title}", xbmc.LOGDEBUG)
                continue

            episode_item = {'title': str(index) + ' - ' + title, 'icon': icon, 'fanart': addonFanart, 'media_type': 'episode', 'normalplayer': 'false', 'stream_kind': 'episode', 'stream_id': str(eid or ''), 'series_id': str(series_id or ''), 'container_extension': ext.lower(), 'category_id': str(category_id or ''), 'is_adult': bool(is_adult)}
            if direct_url:
                episode_item['url'] = direct_url
            ep_list.append(episode_item)

        if not ep_list:
            continue

        payload = urllib.parse.quote(json.dumps(ep_list, ensure_ascii=False), safe='')
        items.append({'title': f"Temporada {season_name}", 'params': f"episodes={payload}&series_id={series_id}&category_id={urllib.parse.quote(str(category_id or ''), safe='')}&is_adult={'true' if is_adult else 'false'}"})
    return items

def search_global(query, scope='all', max_results=120):
    query = (query or '').strip().lower()
    if not query:
        show_dialog('Pesquisa Global', 'Digite um termo para pesquisar.')
        return []

    if len(query) < 3:
        show_dialog('Pesquisa Global', 'Digite pelo menos 3 caracteres para pesquisar.')
        return []

    scope_map = {
        'all': [('get_live_streams', colored_media_title('live', ''), True), ('get_vod_streams', colored_media_title('vod', ''), True), ('get_series', colored_media_title('series', ''), False)],
        'live': [('get_live_streams', colored_media_title('live', ''), True)],
        'vod': [('get_vod_streams', colored_media_title('vod', ''), True)],
        'series': [('get_series', colored_media_title('series', ''), False)]
    }
    plan = scope_map.get(scope, scope_map['all'])

    adult_category_map = {
        'get_live_streams': get_adult_category_ids('get_live_categories'),
        'get_vod_streams': get_adult_category_ids('get_vod_categories'),
        'get_series': get_adult_category_ids('get_series_categories')
    }
    adult_content_map = {
        'get_live_streams': get_adult_content_ids('get_live_streams', adult_category_map.get('get_live_streams', set())),
        'get_vod_streams': get_adult_content_ids('get_vod_streams', adult_category_map.get('get_vod_streams', set())),
        'get_series': get_adult_content_ids('get_series', adult_category_map.get('get_series', set()))
    }

    results = []
    seen = set()
    progress = xbmcgui.DialogProgress()
    progress.create('Pesquisa Global', 'Consultando catálogo...')

    try:
        total = len(plan)
        for idx, (endpoint, prefix, playable) in enumerate(plan, start=1):
            if progress.iscanceled():
                break
            label = {'get_live_streams': 'Buscando canais...', 'get_vod_streams': 'Buscando filmes...', 'get_series': 'Buscando séries...'}.get(endpoint, 'Pesquisando...')
            progress.update(int(((idx - 1) / float(total)) * 100), label)
            items = get_items(endpoint, adult_category_ids=adult_category_map.get(endpoint, set()))
            adult_content_ids = adult_content_map.get(endpoint, set())
            for i in items:
                title = (i.get('title', '') or '').strip()
                if not title or query not in title.lower():
                    continue
                item_is_adult = normalize_bool(i.get('is_adult', False))
                if not item_is_adult:
                    if endpoint == 'get_series':
                        lookup_id = str(i.get('series_id', '') or '').strip()
                    else:
                        lookup_id = str(i.get('stream_id', '') or i.get('vod_id', '') or '').strip()
                    if lookup_id and lookup_id in adult_content_ids:
                        item_is_adult = True
                if item_is_adult:
                    continue
                key = f"{endpoint}|{title.lower()}|{i.get('url', '')}|{i.get('params', '')}"
                if key in seen:
                    continue
                seen.add(key)
                entry = {'title': (prefix + ' ' + title).strip(), 'favorite_title': title, 'icon': i.get('icon', ''), 'fanart': i.get('fanart', addonFanart), 'plot': i.get('plot', ''), 'media_type': i.get('media_type', ''), 'category_id': str(i.get('category_id', '') or ''), 'is_adult': item_is_adult}
                entry['from_search'] = 'true'
                entry['search_scope'] = scope
                entry['search_query'] = query
                if playable:
                    entry['url'] = i.get('url', '')
                    entry['normalplayer'] = i.get('normalplayer', 'false')
                    entry['stream_kind'] = i.get('stream_kind', '')
                    entry['container_extension'] = i.get('container_extension', '')
                    entry['stream_id'] = i.get('stream_id', '')
                    entry['stream_type'] = i.get('stream_type', '')
                    entry['epg_channel_id'] = i.get('epg_channel_id', '')
                    if i.get('vod_id'):
                        entry['vod_id'] = i['vod_id']
                else:
                    entry['mode'] = 'seasons'
                    entry['params'] = i.get('params', '')
                    if i.get('series_id'):
                        entry['series_id'] = i['series_id']
                results.append(entry)
                if len(results) >= max_results:
                    progress.update(100, 'Limite de resultados atingido.')
                    return results
            progress.update(int((idx / float(total)) * 100), label)
    finally:
        progress.close()

    search_vods = [
        {'vod_id': str(item.get('vod_id') or ''), 'title': str(item.get('favorite_title') or item.get('title') or '')}
        for item in results
        if item.get('vod_id') and not item.get('plot')
    ]
    if search_vods:
        enqueue_vod_description_hydration(search_vods, priority=100, source='search')
    return results

def get_account_info():
    data = get_json("")
    if not data:
        return None
    user_info = data.get('user_info', {})
    if not user_info:
        log("Nenhuma informação de usuário retornada pela API.", xbmc.LOGERROR)
        show_dialog('Informações da Conta', 'Não foi possível obter informações da conta.')
        return None
    username = user_info.get('username', 'Não informado')
    status = user_info.get('status', 'Não informado')
    max_connections = user_info.get('max_connections', 'Não informado')
    active_connections = user_info.get('active_cons', 'Não informado')
    created_at = user_info.get('created_at', 0)
    exp_date = user_info.get('exp_date', 0)
    try:
        created_at_str = datetime.fromtimestamp(int(created_at)).strftime('%d/%m/%Y') if created_at and str(created_at).isdigit() else 'Não informado'
    except Exception:
        created_at_str = 'Não informado'
    try:
        exp_date_str = datetime.fromtimestamp(int(exp_date)).strftime('%d/%m/%Y') if exp_date and str(exp_date).isdigit() else 'Não informado'
    except Exception:
        exp_date_str = 'Não informado'
    status_color = get_status_color(status)
    info_text = (
        f"[B]Usuário:[/B] {color(username, 'white')}\n"
        f"[B]Status:[/B] {color(status.upper(), status_color)}\n\n"
        f"[B]Criado em:[/B] {format_date(created_at_str)}\n"
        f"[B]Vencimento:[/B] {format_date(exp_date_str)}\n\n"
        f"[B]Conexões Máximas:[/B] {color(max_connections, 'white')}\n"
        f"[B]Conexões Ativas:[/B] {color(active_connections, 'orange')}\n\n"
        f"[B]{SUPPORT_CONTACT_LABEL}:[/B] {color(SUPPORT_WHATSAPP, 'gold')}\n"
        f"[B]QR Code:[/B] Feche esta tela para abrir o suporte via WhatsApp / QR Code."
    )
    return info_text


def show_full_epg(stream_id='', stream_name='', epg_channel_id=''):
    """Lista a programação completa como uma pasta real do Kodi.

    Modelo alinhado ao XC Pro:
    - abre via Container.Update a partir do menu de contexto;
    - usa o índice/cache XMLTV já salvo;
    - não reproduz itens da grade;
    - mantém fallback conservador para solicitar refresh em background se o índice não estiver pronto.
    """
    stream_name = str(stream_name or 'Canal').strip() or 'Canal'
    epg_channel_id = str(epg_channel_id or '').strip()
    stream_id = str(stream_id or '').strip()
    programs = []

    xbmc.executebuiltin('ActivateWindow(busydialognocancel)')
    try:
        if str(ENABLE_EPG or '').lower() != 'true':
            xbmc.executebuiltin('Dialog.Close(busydialognocancel)')
            notify('EPG', 'O EPG está desativado nas configurações.', iconimage='WARNING')
            end_directory(cache_to_disc=False)
            return

        epg = ensure_epg_loaded()
        if epg_channel_id:
            programs = epg_lookup_day_schedule(epg_channel_id, epg, limit=48, include_current=True)

        # Se o cache ainda não estiver pronto, não trava a tela nem baixa XMLTV aqui.
        # Pede ao service.py para preparar em background, igual ao fluxo conservador aprovado.
        if not programs and epg_channel_id:
            request_epg_background_refresh('show_full_epg_missing_index')
    except Exception as exc:
        log(f'Erro ao carregar Programação Completa de {stream_name}: {exc}', xbmc.LOGWARNING)
        programs = []
    finally:
        xbmc.executebuiltin('Dialog.Close(busydialognocancel)')

    if not programs:
        notify('EPG', f'Sem informação de programação para {stream_name}. Aguarde a primeira carga do EPG.', iconimage='WARNING')
        end_directory(cache_to_disc=False)
        return

    try:
        xbmcplugin.setContent(ADDON_HANDLE, 'videos')
    except Exception:
        pass

    now_ts = int(time.time())
    count = 0
    current_idx = None
    current_start = None
    for idx, program in enumerate(programs):
        if not isinstance(program, dict):
            continue
        try:
            start_int = int(program.get('start') or 0)
            end_int = int(program.get('end') or 0)
        except Exception:
            start_int, end_int = 0, 0
        if start_int <= 0:
            continue
        if end_int <= start_int:
            end_int = start_int + 3600
        if start_int <= now_ts < end_int:
            if current_start is None or start_int >= current_start:
                current_idx = idx
                current_start = start_int

    for idx, program in enumerate(programs):
        if not isinstance(program, dict):
            continue

        title = str(program.get('title') or 'Sem título').strip() or 'Sem título'
        description = str(program.get('desc') or '').strip()
        formatted_time = epg_format_range(program)
        label = f'{formatted_time} | {title}' if formatted_time else title

        if current_idx is not None and idx == current_idx:
            label = color(label, 'aquamarine')

        li = xbmcgui.ListItem(label=label)
        icon = resolve_item_icon({'title': stream_name, 'stream_kind': 'live', 'media_type': 'video', 'icon': ''})
        fanart = addonFanart
        li.setArt({'icon': icon, 'thumb': icon, 'fanart': fanart})
        if fanart:
            li.setProperty('Fanart_Image', fanart)

        info = {'title': title, 'tvshowtitle': stream_name, 'mediatype': 'video'}
        if description:
            info['plot'] = description
        try:
            start_int = int(program.get('start') or 0)
        except Exception:
            start_int = 0
        if start_int:
            try:
                info['aired'] = datetime.fromtimestamp(start_int).strftime('%Y-%m-%d')
            except Exception:
                pass
        apply_video_info(li, info)

        li.setProperty('IsPlayable', 'false')
        xbmcplugin.addDirectoryItem(
            ADDON_HANDLE,
            build_url(mode='noop'),
            li,
            False
        )
        count += 1

    if not count:
        notify('EPG', f'Nenhum programa encontrado para {stream_name}.', iconimage='WARNING')
    end_directory(cache_to_disc=True)


def play_item(url, title, icon, normalplayer, fanart=None, vod_id=None, series_id=None, stream_kind='', container_extension='', stream_id='', epg_channel_id='', is_adult=False):
    # Busca sinopse/informações de forma leve (apenas no item que vai tocar)
    plot = ''
    try:
        if stream_kind == 'live' and ENABLE_EPG.lower() == 'true' and epg_channel_id:
            epg = ensure_epg_loaded()
            current, nextp = epg_lookup_current_next(epg_channel_id, epg)
            plot = build_live_epg_plot(current=current, nextp=nextp, include_desc=True, current_color='aquamarine', next_color='aquamarine')
        elif vod_id:
            plot = get_vod_plot_cached(vod_id) or ''
        elif series_id:
            plot = get_series_plot_cached(series_id) or ''
    except Exception as e:
        log(f"Falha ao obter sinopse/informações no play: {e}", xbmc.LOGDEBUG)
        plot = ''

    # Na reprodução, exibir somente o título limpo. Os prefixos coloridos
    # Canal/Filme/Série pertencem exclusivamente à Pesquisa Global.
    playback_title = strip_media_type_prefix(title) or (title or '').strip()
    # Modelo limpo: a retomada de VOD/Episódio é 100% nativa do Kodi.
    # O Proxy VOD permanece ativo também no resume, usando o endpoint raiz
    # compatível com o modelo funcional do XC Pro.

    if stream_kind == 'live':
        url, selected_live_ext = prepare_live_url_for_proxy_play(url, title=title, stream_id=stream_id)
        if not url:
            return
        if selected_live_ext:
            container_extension = selected_live_ext
            playback_title = build_live_proxy_playback_title(title, selected_live_ext)
        url = wrap_live_url_with_proxy(url)
    elif stream_kind in ('vod', 'episode'):
        url = wrap_vod_url_with_proxy(url, stream_kind=stream_kind, container_extension=container_extension)
        if url and is_local_oneplay_proxy_url(strip_kodi_url_headers(url)):
            playback_title = build_vod_proxy_playback_title(title)

    # Mesmo quando o proxy acrescenta informação técnica, não transportar
    # prefixos de tipo para o OSD/Agora reproduzindo.
    playback_title = strip_media_type_prefix(playback_title) or (title or '').strip()

    clean_play_url = strip_kodi_url_headers(url) if url else ''
    using_local_proxy = is_local_oneplay_proxy_url(clean_play_url) if clean_play_url else False

    if url and not using_local_proxy:
        url = ensure_play_url_headers(url, stream_kind)

    fanart = fanart or addonFanart
    use_resolved = normalplayer == 'false' or stream_kind in ('live', 'vod', 'episode')

    if use_resolved:
        li = xbmcgui.ListItem(path=url)
        try:
            li.setLabel(playback_title)
        except Exception:
            pass
        li.setArt({'icon': icon, 'thumb': icon, 'fanart': fanart})
        if fanart:
            li.setProperty('Fanart_Image', fanart)
        info = {'title': playback_title}
        if plot:
            info['plot'] = plot
        if stream_kind == 'vod':
            info['mediatype'] = 'movie'
        elif stream_kind == 'episode':
            info['mediatype'] = 'episode'
        apply_video_info(li, info)
        if stream_kind == 'live':
            try:
                li.setProperty('IsPlayable', 'true')
            except Exception:
                pass
            configure_live_stream_listitem(li, url)
        elif stream_kind in ('vod', 'episode'):
            if container_extension and not detect_stream_extension(url):
                try:
                    li.setProperty('oneplay.container_extension', container_extension)
                except Exception:
                    pass
            configure_vod_stream_listitem(li, url, stream_kind)
            try:
                li.setProperty('IsPlayable', 'true')
            except Exception:
                pass
        xbmcplugin.setResolvedUrl(ADDON_HANDLE, True, li)
    else:
        li = xbmcgui.ListItem(label=playback_title, path=url)
        li.setArt({'icon': icon, 'thumb': icon, 'fanart': fanart})
        if fanart:
            li.setProperty('Fanart_Image', fanart)
        info = {'title': playback_title}
        if plot:
            info['plot'] = plot
        apply_video_info(li, info)
        if stream_kind in ('vod', 'episode'):
            configure_vod_stream_listitem(li, url, stream_kind)
        player = xbmc.Player()
        player.play(item=url, listitem=li)

def days_until_expire(exp_date):
    try:
        exp_ts = int(str(exp_date or '').strip())
        if exp_ts <= 0:
            return None
        today = datetime.now().date()
        expire_day = datetime.fromtimestamp(exp_ts).date()
        return (expire_day - today).days
    except Exception:
        return None

def get_enter_menu_items():
    settings_item = {'title': 'Configurações', 'mode': 'settings', 'plot': 'Abra as configurações do addon.', 'fanart': addonFanart, 'folder': False}
    account_connection_item = {'title': 'Acesso e Servidor', 'mode': 'account_connection_menu', 'plot': f'Gerencie acesso, servidor e testes de conexão. Atual: {get_server_summary()}.', 'fanart': addonFanart}
    items = [
        account_connection_item,
        {'title': 'Pesquisa Global', 'mode': 'search', 'plot': 'Pesquise canais, filmes e séries em todo o catálogo.', 'fanart': addonFanart},
        {'title': 'Favoritos', 'mode': 'favorites', 'plot': 'Atalhos rápidos para canais, filmes e séries favoritados.', 'fanart': addonFanart},
        {'title': 'TV ao Vivo', 'mode': 'tv', 'plot': 'Acesse as categorias de TV ao vivo disponíveis.', 'fanart': addonFanart},
        {'title': 'Filmes', 'mode': 'movies', 'plot': 'Acesse as categorias de filmes disponíveis no servidor.', 'fanart': addonFanart},
        {'title': 'Séries', 'mode': 'series', 'plot': 'Acesse as categorias, temporadas e episódios das séries.', 'fanart': addonFanart},
        settings_item,
    ]
    return items

# =========================
# Rotas
# =========================
def guard_adult_flag_from_params(title='Conteúdo Adulto', fallback_flag='false'):
    if not normalize_bool(fallback_flag):
        return True
    if not is_adult_enabled():
        show_dialog('Conteúdo Adulto', 'O conteúdo adulto está desativado nas configurações do addon.')
        return False
    return True

# =========================
# Rotas
# =========================
PARAMS = get_param_map()
mode = get_param('mode', 'main')
suppress_expire_popup = normalize_bool(get_param('suppress_expire', 'false'))
log(f"Modo: {mode}")

def route_dispatch():
    # Captura origem somente quando ela vem da URL de abertura; o valor é
    # restrito à lista interna permitida e não aceita destino arbitrário.
    sync_external_origin_context()

    if mode == 'download_apk':
        handle_apk_download()
        raise SystemExit
    if mode == 'main':
        refresh_runtime_settings()
        if has_valid_credentials():
            success, error_type, error_message = try_open_enter_menu(suppress_expire=suppress_expire_popup)
            if not success:
                show_probe_result_dialog(error_type, error_message)
                if error_type == 'login' and run_access_retry_wizard():
                    success_retry, retry_type, retry_message = try_open_enter_menu(suppress_expire=True)
                    if not success_retry:
                        show_probe_result_dialog(retry_type, retry_message)
                        render_account_connection_menu()
                else:
                    render_account_connection_menu()
                raise SystemExit
        else:
            # No retorno do logout, a raiz abre limpa sem criar nova pasta nem
            # disparar QR/assistente. A abertura normal sem acesso não muda.
            if consume_logout_home_request():
                render_root_menu()
            else:
                show_qr_code()
                if run_quick_setup_wizard():
                    success_retry, retry_type, retry_message = try_open_enter_menu(suppress_expire=True)
                    if not success_retry:
                        show_probe_result_dialog(retry_type, retry_message)
                        render_account_connection_menu()
                    raise SystemExit
                render_root_menu()

    elif mode == 'enter':
        refresh_runtime_settings()
        success, error_type, error_message = try_open_enter_menu(suppress_expire=suppress_expire_popup)
        if not success:
            show_probe_result_dialog(error_type, error_message)
            if error_type == 'login' and run_access_retry_wizard():
                success_retry, retry_type, retry_message = try_open_enter_menu(suppress_expire=True)
                if not success_retry:
                    show_probe_result_dialog(retry_type, retry_message)
                    render_account_connection_menu()
            else:
                render_account_connection_menu()
            raise SystemExit

    elif mode == 'account_info':
        info_text = get_account_info()
        if info_text:
            xbmcgui.Dialog().textviewer("Informações da Conta", info_text)
            try:
                if xbmcgui.Dialog().yesno('Suporte / WhatsApp', 'Deseja abrir o QR Code do suporte agora?'):
                    show_qr_code()
            except Exception as e:
                log(f"Falha ao oferecer QR Code após informações da conta: {e}", xbmc.LOGDEBUG)
        else:
            show_dialog('Informações da Conta', 'Não foi possível obter informações da conta.')

    elif mode == 'show_qr':
        show_qr_code()

    elif mode == 'account_connection_menu':
        # Marcar atualização apenas quando existe uma sessão válida. Caso esta
        # rota tenha sido reaberta do cache após logout, ela só reconstrói a raiz.
        set_account_connection_dirty(has_valid_credentials())
        render_account_connection_menu()

    elif mode == 'quick_setup':
        if run_quick_setup_wizard():
            try:
                refresh_container()
            except Exception:
                success_retry, retry_type, retry_message = try_open_enter_menu(suppress_expire=True)
                if not success_retry:
                    show_probe_result_dialog(retry_type, retry_message)
                    render_account_connection_menu()
        raise SystemExit

    elif mode == 'switch_server':
        switch_server_interactive()
        render_account_connection_menu()
        raise SystemExit

    elif mode == 'change_credentials':
        if run_change_credentials_wizard():
            notify('Usuário e senha atualizados com sucesso.', heading='Acesso e Servidor', iconimage='INFO')
        render_account_connection_menu()
        raise SystemExit

    elif mode == 'test_current_server':
        test_current_server()
        render_account_connection_menu()
        raise SystemExit

    elif mode == 'test_all_servers':
        test_all_servers()
        render_account_connection_menu()
        raise SystemExit

    elif mode == 'cleanup_tools':
        open_cleanup_dialog()
        render_account_connection_menu()
        raise SystemExit

    elif mode == 'logout':
        if xbmcgui.Dialog().yesno('Sair da Conta', 'Deseja remover o usuário e a senha salvos neste addon?'):
            if clear_login_settings(keep_server=True):
                set_account_connection_dirty(False)
                notify('Usuário e senha removidos. O servidor foi mantido salvo.', heading='Acesso e Servidor', iconimage='INFO')
                if not request_logout_home_navigation():
                    show_dialog('Sair da Conta', 'As credenciais foram removidas. Não foi possível abrir o menu inicial automaticamente.')
            else:
                show_dialog('Sair da Conta', 'Não foi possível limpar os dados salvos.')
        raise SystemExit

    elif mode == 'settings':
        tracked_settings = [
            'username', 'password', 'server_choice', 'enable_epg', 'enable_adult',
            'proxy_vod_enabled', 'proxy_live_enabled', 'proxy_mode', 'proxy_live_open_mode', 'proxy_host_memory',
            'proxy_xtream_hardening', 'proxy_debug',
        ]
        before_settings = {}
        for setting_id in tracked_settings:
            try:
                before_settings[setting_id] = str(ADDON.getSetting(setting_id) or '')
            except Exception:
                before_settings[setting_id] = ''
        previous_proxy_state = is_any_proxy_enabled()
        ADDON.openSettings()
        refresh_runtime_settings()
        current_proxy_state = is_any_proxy_enabled()
        if previous_proxy_state and not current_proxy_state:
            stop_live_proxy_if_running()
        after_settings = {}
        for setting_id in tracked_settings:
            try:
                after_settings[setting_id] = str(ADDON.getSetting(setting_id) or '')
            except Exception:
                after_settings[setting_id] = ''
        if before_settings != after_settings:
            access_changed = any(before_settings.get(k) != after_settings.get(k) for k in ('username', 'password', 'server_choice'))
            epg_was_enabled = str(before_settings.get('enable_epg') or '').lower() == 'true'
            epg_is_enabled = str(after_settings.get('enable_epg') or '').lower() == 'true'
            if access_changed:
                reset_login_success_notification()
            if epg_is_enabled and ((not epg_was_enabled) or access_changed):
                if request_epg_background_refresh('settings_epg_enabled' if not epg_was_enabled else 'settings_access_changed'):
                    notify('EPG ativado. Primeira verificação iniciada em segundo plano.', heading='EPG', iconimage='INFO')
            # REGRA FIXA DE PRODUÇÃO: ao sair de Configurações gerais, não forçar
            # refresh do container. As alterações devem valer de forma preguiçosa
            # nos próximos fluxos/telas, sem recarregar a lista visível nem empilhar
            # histórico. Refresh automático aqui é ruído e não deve ser restaurado
            # sem necessidade real e comprovada.


    elif mode == 'change_adult_pin':
        current_pin = get_adult_pin_value()
        prompt_title = 'PIN atual do Conteúdo Adulto'
        if current_pin == DEFAULT_ADULT_PIN:
            prompt_title += ' (padrão: 0000)'
        if current_pin and not prompt_current_adult_pin(prompt_title):
            show_dialog('PIN Adulto', 'PIN atual inválido. Tente novamente.')
            raise SystemExit
        entered = xbmcgui.Dialog().input('Novo PIN Adulto (4 dígitos)', type=xbmcgui.INPUT_NUMERIC, option=xbmcgui.ALPHANUM_HIDE_INPUT)
        if entered is None:
            raise SystemExit
        new_pin = sanitize_adult_pin(entered)
        if not new_pin:
            show_dialog('PIN Adulto', 'Informe exatamente 4 dígitos numéricos.')
            raise SystemExit
        confirm = xbmcgui.Dialog().input('Confirme o novo PIN Adulto', type=xbmcgui.INPUT_NUMERIC, option=xbmcgui.ALPHANUM_HIDE_INPUT)
        if confirm is None:
            raise SystemExit
        if sanitize_adult_pin(confirm) != new_pin:
            show_dialog('PIN Adulto', 'A confirmação do PIN não confere.')
            raise SystemExit
        if not save_adult_pin(new_pin):
            show_dialog('PIN Adulto', 'Não foi possível salvar o novo PIN.')
            raise SystemExit
        refresh_runtime_settings()
        clear_adult_session()
        notify('PIN Adulto alterado com sucesso.', heading='Conteúdo Adulto', iconimage='INFO')

    elif mode == 'favorites':
        if not is_favorites_enabled():
            return_to_enter_menu_with_message('Favoritos', 'Os Favoritos estão desativados nas configurações do addon.')
        build_favorites_menu()


    elif mode == 'add_favorite':
        if not is_favorites_enabled():
            return_to_enter_menu_with_message('Favoritos', 'Os Favoritos estão desativados nas configurações do addon.')
        entry = favorite_entry_from_params(PARAMS)
        if not entry:
            show_dialog('Favoritos', 'Não foi possível adicionar este item aos favoritos.')
        elif add_favorite(entry):
            notify('Item adicionado aos favoritos.', heading='Favoritos', iconimage='INFO')
            # REGRA FIXA DE PRODUÇÃO (KODI): adicionar aos Favoritos deve ser
            # silencioso. Não forçar refresh da pasta atual, não reabrir rota
            # e não marcar "resume_once" da busca só por causa da adição.
            # O conteúdo salvo será carregado quando o usuário abrir Favoritos.
            raise SystemExit
        else:
            show_dialog('Favoritos', 'Não foi possível salvar o favorito.')

    elif mode == 'remove_favorite':
        favorite_key_value = get_param('favorite_key', '')
        if not favorite_key_value:
            entry = favorite_entry_from_params(PARAMS)
            favorite_key_value = entry.get('key', '') if entry else ''
        if not favorite_key_value:
            show_dialog('Favoritos', 'Não foi possível identificar o favorito para remoção.')
        elif remove_favorite(favorite_key_value):
            notify('Item removido dos favoritos.', heading='Favoritos', iconimage='INFO')
            if get_param('from_search', 'false') == 'true':
                mark_resume_search(get_param('search_scope', 'all'), get_param('search_query', ''))
                refresh_container()
            elif get_param('from_favorites', 'false') == 'true':
                items = list_favorites_items()
                if items:
                    refresh_container()
                else:
                    # REGRA FIXA DE PRODUÇÃO (KODI): ao remover o último item de
                    # Favoritos, voltar limpo para o menu anterior.
                    # Não usar refresh na lista vazia e não reabrir rota.
                    return_to_enter_menu_with_message('', '')
                raise SystemExit
            else:
                refresh_container()
        else:
            show_dialog('Favoritos', 'Não foi possível remover o favorito.')

    elif mode == 'tv':
        probe_epg_background_refresh('ui_opened_live_categories')
        build_menu(get_categories('action=get_live_categories', 'get_live_streams'), 'live_items')

    elif mode == 'live_items':
        cid = get_param('category_id')
        adult_flag = get_param('is_adult', 'false')
        if guard_adult_flag_from_params(get_param('title', 'Categoria adulta'), adult_flag):
            build_menu(get_items('get_live_streams', cid, normalize_bool(adult_flag)), is_playable=True)

    elif mode == 'movies':
        movie_categories = get_categories('action=get_vod_categories', 'get_vod_streams')
        try:
            schedule_vod_catalog_prefetch(movie_categories)
        except Exception as exc:
            log(f'Pré-agendamento VOD ignorado com segurança: {exc}', xbmc.LOGDEBUG)
        build_menu(movie_categories, 'movie_items')

    elif mode == 'movie_items':
        cid = get_param('category_id')
        adult_flag = get_param('is_adult', 'false')
        if guard_adult_flag_from_params(get_param('title', 'Categoria adulta'), adult_flag):
            build_menu(get_items('get_vod_streams', cid, normalize_bool(adult_flag)), is_playable=True)

    elif mode == 'series':
        build_menu(get_categories('action=get_series_categories', 'get_series'), 'series_items')

    elif mode == 'series_items':
        cid = get_param('category_id')
        adult_flag = get_param('is_adult', 'false')
        if guard_adult_flag_from_params(get_param('title', 'Categoria adulta'), adult_flag):
            build_menu(get_items('get_series', cid, normalize_bool(adult_flag)), 'seasons')

    elif mode == 'seasons':
        sid = get_param('series_id')
        adult_flag = get_param('is_adult', 'false')
        if guard_adult_flag_from_params(get_param('title', 'Série adulta'), adult_flag):
            build_menu(get_seasons(sid, get_param('category_id', ''), normalize_bool(adult_flag)), 'episodes')

    elif mode == 'episodes':
        if not guard_adult_flag_from_params(get_param('title', 'Temporada adulta'), get_param('is_adult', 'false')):
            raise SystemExit
        eps_json = urllib.parse.unquote(get_param('episodes', '[]'))
        try:
            eps = json.loads(eps_json)
            if not isinstance(eps, list):
                raise ValueError('episodes inválido')
        except Exception as e:
            log(f"Falha ao carregar episódios: {e}", xbmc.LOGERROR)
            eps = []
        build_menu(eps, is_playable=True)

    elif mode == 'show_full_epg':
        show_full_epg(get_param('stream_id', ''), get_param('stream_name', 'Canal'), get_param('epg_channel_id', ''))

    elif mode == 'noop':
        end_directory(cache_to_disc=False)

    elif mode == 'search':
        if not is_global_search_enabled():
            return_to_enter_menu_with_message('Pesquisa Global', 'A Pesquisa Global está desativada nas configurações do addon.')
        resumed = pop_resume_search()
        if resumed:
            cached_results = resumed.get('results') if isinstance(resumed, dict) else None
            if isinstance(cached_results, list):
                if cached_results:
                    build_menu(cached_results, is_playable=True)
                else:
                    return_to_enter_menu_with_message('Pesquisa Global', 'Nenhum resultado encontrado no momento.')
            else:
                results = search_global(resumed.get('query', ''), scope=resumed.get('scope', 'all'))
                if results:
                    remember_search(resumed.get('scope', 'all'), resumed.get('query', ''), results)
                    build_menu(results, is_playable=True)
                else:
                    return_to_enter_menu_with_message('Pesquisa Global', 'Nenhum resultado encontrado no momento.')
        else:
            # Pesquisa Global única no padrão do XC Pro: abre direto o teclado
            # e busca em TV, Filmes e Séries sem mostrar seleção de escopo.
            kb = xbmc.Keyboard('', 'Digite um termo para pesquisar')
            kb.doModal()
            if kb.isConfirmed():
                query = (kb.getText() or '').strip()
                if query:
                    if len(query) < 3:
                        # Termo inválido não é uma pesquisa sem resultados.
                        # Volta para a tela anterior sem tocar no histórico/cache
                        # da última pesquisa válida e sem montar diretório vazio.
                        show_dialog('Pesquisa Global', 'Digite pelo menos 3 caracteres para pesquisar.')
                        # Encerra o fluxo inválido sem tratá-lo como busca vazia/falha.
                        # Container.Refresh preserva a tela anterior e evita falso "sem resultados".
                        try:
                            xbmc.executebuiltin('Action(Back)')
                        except Exception:
                            pass
                        return
                    scope = 'all'
                    results = search_global(query, scope=scope)
                    if results:
                        remember_search(scope, query, results)
                        build_menu(results, is_playable=True)
                    else:
                        return_to_enter_menu_with_message('Pesquisa Global', 'Nenhum resultado encontrado no momento.')
                else:
                    previous = load_search_state()
                    previous_query = (previous.get('last_query') or '').strip()
                    previous_scope = (previous.get('last_scope') or 'all').strip()
                    previous_results = previous.get('last_results')
                    if isinstance(previous_results, list) and previous_results:
                        build_menu(previous_results, is_playable=True)
                    elif previous_query:
                        results = search_global(previous_query, scope=previous_scope)
                        if results:
                            remember_search(previous_scope, previous_query, results)
                            build_menu(results, is_playable=True)
                        else:
                            return_to_enter_menu_with_message('Pesquisa Global', 'Nenhum resultado encontrado na pesquisa anterior.')
                    else:
                        return_to_enter_menu_with_message('Pesquisa Global', 'Nenhuma pesquisa anterior foi encontrada.')
            else:
                return_to_enter_menu_with_message('Pesquisa Global')

    elif mode == 'show_vod_plot':
        vod_id = get_param('vod_id')
        title = get_param('title', 'Sinopse')
        if vod_id:
            plot = get_vod_plot_cached(vod_id) or ''
            if plot:
                xbmcgui.Dialog().textviewer(title, plot)
            else:
                show_dialog('Sinopse', 'Nenhuma descrição disponível.')
        else:
            show_dialog('Sinopse', 'Não foi possível abrir a sinopse deste filme.')

    elif mode == 'show_series_plot':
        series_id = get_param('series_id')
        title = get_param('title', 'Sinopse')
        if series_id:
            plot = get_series_plot_cached(series_id) or ''
            if plot:
                xbmcgui.Dialog().textviewer(title, plot)
            else:
                show_dialog('Sinopse', 'Nenhuma descrição disponível.')
        else:
            show_dialog('Sinopse', 'Não foi possível abrir a sinopse desta série.')

    elif mode == 'play':
        url = get_param('url')
        normalplayer = get_param('normalplayer', 'false')
        title = get_param('title', 'Reproduzindo')
        stream_kind = get_param('stream_kind', '')
        icon = resolve_item_icon({'title': title, 'stream_kind': stream_kind, 'media_type': 'movie' if stream_kind == 'vod' else ('episode' if stream_kind == 'episode' else ''), 'icon': get_param('icon', addonIcon)})
        fanart = get_param('fanart', addonFanart)
        vod_id = get_param('vod_id', '') or None
        series_id = get_param('series_id', '') or None
        container_extension = get_param('container_extension', '')
        stream_id = get_param('stream_id', '')
        stream_type = get_param('stream_type', '')
        if not url:
            url = build_stream_url_from_identity(
                stream_kind=stream_kind,
                stream_id=stream_id,
                vod_id=vod_id or '',
                container_extension=container_extension,
                stream_type=stream_type,
            )
        if not url:
            show_dialog('Reprodução indisponível', 'Não foi possível reconstruir o endereço deste item com segurança.')
            raise SystemExit
        if normalize_bool(get_param('is_adult', 'false')) and not ensure_adult_access(title):
            raise SystemExit
        if get_param('from_search', 'false') == 'true' and stream_kind in ('live', 'vod', 'episode'):
            mark_resume_search(get_param('search_scope', 'all'), get_param('search_query', ''))
        if stream_kind == 'vod' and vod_id:
            try:
                record_vod_usage(vod_id, title, get_param('year', ''))
            except Exception as exc:
                log(f'Falha não crítica ao registrar popularidade VOD: {exc}', xbmc.LOGDEBUG)
        # Mesmo em resume:true, manter Proxy VOD ativo e deixar o Range nativo
        # do Kodi passar pelo endpoint raiz do proxy, como no modelo XC Pro.
        play_item(url, title, icon, normalplayer, fanart=fanart, vod_id=vod_id, series_id=series_id, stream_kind=stream_kind, container_extension=container_extension, stream_id=stream_id, epg_channel_id=get_param('epg_channel_id', ''), is_adult=normalize_bool(get_param('is_adult', 'false')))

    else:
        show_dialog('Erro interno', f'Este caminho do addon não foi reconhecido: {mode}')

try:
    route_dispatch()
except SystemExit:
    raise
except Exception as exc:
    log(f"Falha não tratada em mode={mode}: {exc}\n{traceback.format_exc()}", xbmc.LOGERROR)
    try:
        show_dialog('Erro interno', 'O addon encontrou um erro inesperado e retornou ao menu com segurança.')
    except Exception:
        pass
    try:
        refresh_runtime_settings()
        if has_valid_credentials():
            build_menu(get_enter_menu_items())
        else:
            render_root_menu()
    except Exception as fallback_exc:
        log(f"Falha no retorno seguro: {fallback_exc}\n{traceback.format_exc()}", xbmc.LOGERROR)
        try:
            build_root_menu()
        except Exception:
            pass
