# -*- coding: utf-8 -*-
"""Runtime pesado do EPG extraído do main.py de forma conservadora.

A navegação/plot/listagem continuam no main.py. Aqui ficam apenas cache, parse,
janela diária e download do XMLTV.
"""
import calendar
import os
import re
import time
import urllib.parse
import xml.etree.ElementTree as ET
from datetime import datetime

import xbmc

_RUNTIME_PARSED = None
_RUNTIME_SCOPE = None
_RUNTIME_META = None


def _log(ctx, message, level=xbmc.LOGDEBUG):
    fn = (ctx or {}).get('log_fn')
    if not fn:
        return
    try:
        fn(message, level)
    except TypeError:
        try:
            fn(message)
        except Exception:
            pass
    except Exception:
        pass


def _window_days(ctx):
    try:
        return max(1, int((ctx or {}).get('epg_index_window_days') or 1))
    except Exception:
        return 1


def epg_meta_load(ctx):
    data = ctx['load_json_fn'](ctx['epg_meta_storage_path_fn'](), {})
    return data if isinstance(data, dict) else {}


def epg_meta_save(ctx, meta):
    try:
        ctx['atomic_write_json_fn'](ctx['epg_meta_storage_path_fn'](), meta or {}, ensure_ascii=False, indent=2)
    except Exception as exc:
        _log(ctx, f'Falha ao salvar meta EPG: {exc}', xbmc.LOGERROR)


def epg_local_day_window(ctx, now_ts=None, window_days=None):
    try:
        now_ts = int(time.time() if now_ts is None else now_ts)
    except Exception:
        now_ts = int(time.time())
    try:
        window_days = max(1, int(window_days or _window_days(ctx) or 1))
    except Exception:
        window_days = 1
    start_dt = datetime.fromtimestamp(now_ts).replace(hour=0, minute=0, second=0, microsecond=0)
    start_ts = int(start_dt.timestamp())
    end_ts = start_ts + (window_days * 24 * 3600) - 1
    day_key = start_dt.strftime('%Y%m%d')
    return day_key, start_ts, end_ts


def epg_index_storage_path(ctx, day_key=None):
    # Padrão XC Pro: um índice pronto por escopo atual, não um arquivo por categoria/canal.
    try:
        return ctx['server_slot_storage_path_fn']('epg_xmltv_index.json')
    except Exception:
        return os.path.join(ctx['profile_dir'], 'epg_xmltv_index.json')


def epg_compat_cache_storage_path(ctx):
    try:
        return ctx['server_slot_storage_path_fn']('epg_cache.json')
    except Exception:
        return os.path.join(ctx['profile_dir'], 'epg_cache.json')


def epg_refresh_request_storage_path(ctx):
    try:
        return ctx['server_slot_storage_path_fn']('epg_refresh_request.json')
    except Exception:
        return os.path.join(ctx['profile_dir'], 'epg_refresh_request.json')


def epg_load_refresh_request(ctx):
    try:
        data = ctx['load_json_fn'](epg_refresh_request_storage_path(ctx), {})
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def epg_mark_refresh_requested(ctx, reason='ui_needs_refresh'):
    """Registra um único pedido pendente por servidor/fingerprint.

    Abrir categorias enquanto o primeiro XMLTV ainda baixa não deve regravar o
    arquivo nem inundar o kodi.log com a mesma solicitação.
    """
    try:
        fingerprint = ctx['fingerprint_fn']()
        existing = epg_load_refresh_request(ctx)
        if existing and str(existing.get('fingerprint') or '') == str(fingerprint or ''):
            return True
        payload = {
            'requested_at': int(time.time()),
            'reason': str(reason or 'ui_needs_refresh'),
            'fingerprint': fingerprint,
        }
        ctx['atomic_write_json_fn'](epg_refresh_request_storage_path(ctx), payload, ensure_ascii=False, indent=2)
        _log(ctx, f'EPG refresh em background solicitado: {payload.get("reason")}', xbmc.LOGDEBUG)
        return True
    except Exception as exc:
        _log(ctx, f'Falha ao marcar refresh EPG em background: {exc}', xbmc.LOGDEBUG)
        return False


def epg_clear_refresh_request(ctx):
    try:
        return bool(ctx['safe_delete_file_fn'](epg_refresh_request_storage_path(ctx)))
    except Exception:
        return False


def epg_has_refresh_request(ctx):
    try:
        req = epg_load_refresh_request(ctx)
        if not req:
            return False
        fp = ctx['fingerprint_fn']()
        req_fp = str(req.get('fingerprint') or '')
        if req_fp and req_fp != fp:
            # Pedido antigo de outro login/servidor não deve forçar refresh no escopo atual.
            epg_clear_refresh_request(ctx)
            return False

        # Fecha corrida entre Settings/UI e o service: se o XMLTV foi salvo
        # depois de o pedido existir, o pedido já foi satisfeito e não deve
        # provocar um segundo download.
        try:
            meta = epg_meta_load(ctx)
            fetched_at = int(meta.get('fetched_at') or 0)
            requested_at = int(req.get('requested_at') or 0)
            if str(meta.get('fingerprint') or '') == str(fp or '') and fetched_at and requested_at and fetched_at >= requested_at:
                epg_clear_refresh_request(ctx)
                return False
        except Exception:
            pass
        return True
    except Exception:
        return False


def epg_index_cache_paths(ctx):
    paths = []
    try:
        current_index = epg_index_storage_path(ctx)
        if current_index:
            paths.append(current_index)
        compat_cache = epg_compat_cache_storage_path(ctx)
        if compat_cache:
            paths.append(compat_cache)
        base_dir = os.path.dirname(current_index or compat_cache or ctx.get('profile_dir') or '')
        if base_dir and os.path.isdir(base_dir):
            for name in os.listdir(base_dir):
                # Limpa também os índices antigos epg_index_YYYYMMDD.json, caso existam.
                if name.startswith('epg_index_') and name.endswith('.json'):
                    paths.append(os.path.join(base_dir, name))
    except Exception:
        pass
    seen = []
    for path in paths:
        if path and path not in seen:
            seen.append(path)
    return seen


def epg_prune_old_index_cache(ctx, max_keep=None):
    try:
        max_keep = int(max_keep or ctx.get('max_epg_index_files') or 8)
    except Exception:
        max_keep = 8
    try:
        paths = sorted(epg_index_cache_paths(ctx))
        if len(paths) <= max_keep:
            return True
        keep = set(paths[-max_keep:])
        ok = True
        for path in paths:
            if path in keep:
                continue
            if not ctx['safe_delete_file_fn'](path):
                ok = False
        return ok
    except Exception as exc:
        _log(ctx, f'Falha ao podar índices EPG antigos: {exc}', xbmc.LOGDEBUG)
        return False


def epg_clear_index_cache(ctx):
    ok = True
    for path in epg_index_cache_paths(ctx):
        try:
            ctx['safe_delete_file_fn'](path)
        except Exception as exc:
            ok = False
            _log(ctx, f'Falha ao remover índice EPG {path}: {exc}', xbmc.LOGDEBUG)
    return ok


def epg_xml_identity(xml_path):
    try:
        return {
            'raw_mtime': int(os.path.getmtime(xml_path)),
            'raw_size': int(os.path.getsize(xml_path)),
        }
    except Exception:
        return {'raw_mtime': 0, 'raw_size': 0}


def xml_local_name(tag):
    try:
        tag = str(tag or '')
        return tag.rsplit('}', 1)[-1] if '}' in tag else tag
    except Exception:
        return tag or ''


def find_child_text(element, tag_name):
    try:
        for child in list(element):
            if xml_local_name(child.tag) == tag_name and child.text:
                return str(child.text or '').strip()
    except Exception:
        pass
    return ''


def normalize_epoch_seconds(value):
    try:
        if value is None:
            return 0
        if isinstance(value, str):
            value = value.strip()
            if not value:
                return 0
        epoch = int(float(value))
    except Exception:
        return 0
    if epoch > 99999999999:
        epoch = epoch // 1000
    return epoch if epoch > 0 else 0


def parse_xmltv_time(ctx, ts):
    if not ts:
        return int(time.time())
    ts = ts.strip()
    if len(ts) < 14 or not ts[:14].isdigit():
        _log(ctx, f'Timestamp XMLTV inválido: {ts}')
        return int(time.time())

    base = ts[:14]
    try:
        dt = datetime.strptime(base, '%Y%m%d%H%M%S')
    except Exception:
        return int(time.time())

    offset_secs = int(ctx.get('xmltv_default_offset_secs') or -(3 * 3600))
    rest = ts[14:].strip()
    if rest:
        rest = rest.replace(' ', '')
        if rest.startswith(('+', '-')) and len(rest) >= 5:
            try:
                sign = 1 if rest[0] == '+' else -1
                hh = int(rest[1:3])
                mm = int(rest[3:5])
                offset_secs = sign * (hh * 3600 + mm * 60)
            except Exception:
                offset_secs = int(ctx.get('xmltv_default_offset_secs') or -(3 * 3600))
        else:
            _log(ctx, f'XMLTV sem timezone reconhecível, assumindo America/Sao_Paulo: {ts}')
    else:
        _log(ctx, f'XMLTV sem timezone, assumindo America/Sao_Paulo: {ts}')

    epoch = calendar.timegm(dt.timetuple()) - offset_secs
    return epoch if epoch > 0 else int(time.time())


def normalize_epg_channel_id(cid):
    if not cid:
        return ''
    return str(cid).strip().lower().replace('&amp;', '&')


def epg_xml_meta_from_file(ctx, xml_path):
    meta = {
        'programme_count': 0,
        'channel_count': 0,
        'min_start': 0,
        'max_stop': 0,
    }
    if not xml_path or not os.path.exists(xml_path):
        return meta
    try:
        for event, elem in ET.iterparse(xml_path, events=('end',)):
            tag = xml_local_name(elem.tag)
            if tag == 'channel':
                meta['channel_count'] = int(meta.get('channel_count') or 0) + 1
                elem.clear()
                continue
            if tag != 'programme':
                continue
            start = normalize_epoch_seconds(elem.get('start_timestamp'))
            if start <= 0:
                start = parse_xmltv_time(ctx, elem.get('start'))
            stop = normalize_epoch_seconds(elem.get('stop_timestamp') or elem.get('end_timestamp'))
            if stop <= 0:
                stop = parse_xmltv_time(ctx, elem.get('stop') or elem.get('end'))
            if stop <= start:
                stop = start + 3600
            meta['programme_count'] = int(meta.get('programme_count') or 0) + 1
            if start > 0 and (not meta.get('min_start') or start < int(meta.get('min_start') or 0)):
                meta['min_start'] = int(start)
            if stop > int(meta.get('max_stop') or 0):
                meta['max_stop'] = int(stop)
            elem.clear()
    except Exception as exc:
        _log(ctx, f'Falha ao gerar metadados do XMLTV: {exc}', xbmc.LOGDEBUG)
    try:
        meta.update(epg_xml_identity(xml_path))
    except Exception:
        pass
    return meta


def epg_xml_covers_time(meta, target_ts=None):
    try:
        target_ts = int(time.time() if target_ts is None else target_ts)
        min_start = int((meta or {}).get('min_start') or 0)
        max_stop = int((meta or {}).get('max_stop') or 0)
        return bool(min_start and max_stop and min_start <= target_ts <= max_stop)
    except Exception:
        return False


def epg_should_refresh(ctx):
    meta = epg_meta_load(ctx)
    fp = ctx['fingerprint_fn']()
    meta_fp = meta.get('fingerprint')
    try:
        now_ts = int(time.time())
    except Exception:
        now_ts = 0
    fetched_at = int(meta.get('fetched_at') or 0)
    try:
        age = max(0, int(time.time() - fetched_at)) if fetched_at else int(ctx['epg_ttl']) + 1
    except Exception:
        age = int(ctx.get('epg_ttl') or 0) + 1

    if meta_fp != fp:
        # O EPG é isolado dos metadados VOD/séries. Troca de acesso invalida
        # somente índices/estado EPG; sinopses possuem escopo próprio e só são
        # removidas pela ação explícita de limpeza do addon.
        _log(ctx, 'Fingerprint do EPG mudou (usuário/senha/servidor). Renovando somente o EPG.')
        epg_clear_index_cache(ctx)
        return True

    # Falha recente do provedor entra em cooldown por escopo. A UI pode manter
    # sinalizando que falta EPG, mas o service não martela xmltv.php/epg.php.
    retry_after = int(meta.get('retry_after') or 0)
    if retry_after and now_ts < retry_after:
        if bool(ctx.get('honor_refresh_request')) and epg_has_refresh_request(ctx):
            epg_clear_refresh_request(ctx)
        _log(ctx, 'EPG indisponível; nova tentativa automática após o cooldown.', xbmc.LOGDEBUG)
        return False

    epg_xml_path = ctx['epg_xml_storage_path_fn']()
    if not os.path.exists(epg_xml_path):
        _log(ctx, 'EPG não existe. Baixando.')
        return True

    if bool(ctx.get('honor_refresh_request')) and epg_has_refresh_request(ctx):
        _log(ctx, 'Pedido pendente de refresh EPG encontrado. Renovando em background.')
        return True

    ttl = int(ctx.get('epg_ttl') or 0)
    if ttl > 0 and age >= ttl:
        _log(ctx, f"EPG expirado ({ctx['epg_cache_days']} dia(s)). Renovando.")
        return True

    refresh_margin = max(0, int(ctx.get('epg_refresh_margin_secs') or 0))
    if ttl > 0 and refresh_margin > 0 and age >= max(0, ttl - refresh_margin):
        _log(ctx, 'EPG perto de expirar. Renovando preventivamente em background.')
        return True

    try:
        programme_count = int(meta.get('programme_count') or 0)
        coverage_known = bool(meta.get('min_start') and meta.get('max_stop'))

        # Metadados antigos/incompletos são reconstruídos uma única vez pelo XML
        # local. Isso continua sendo apenas disco local; nunca há download na UI.
        if programme_count <= 0 or not coverage_known or not epg_xml_covers_time(meta, now_ts):
            rebuilt = epg_xml_meta_from_file(ctx, epg_xml_path)
            merged = dict(meta or {})
            merged.update(rebuilt)
            epg_meta_save(ctx, merged)
            meta = merged
            programme_count = int(meta.get('programme_count') or 0)

        min_interval = max(0, int(ctx.get('epg_min_refresh_interval_secs') or 0))

        # Correção principal: XML existente, porém sem nenhum programa, é EPG
        # inválido. O cooldown impede loop se o provedor continuar entregando vazio.
        if programme_count <= 0:
            if age >= min_interval:
                _log(ctx, 'EPG salvo está vazio (0 programas). Renovando em background.')
                return True
            _log(ctx, 'EPG vazio, mas recém-atualizado; aguardando cooldown para evitar downloads em loop.', xbmc.LOGDEBUG)
            return False

        if not epg_xml_covers_time(meta, now_ts):
            if age >= min_interval:
                _log(ctx, 'EPG salvo não cobre o horário atual. Renovando.')
                return True
            _log(ctx, 'EPG não cobre o horário atual, mas foi atualizado há pouco; aguardando cooldown.', xbmc.LOGDEBUG)
            return False

        # Cobertura responsiva: por padrão exige a janela completa Hoje + Amanhã.
        # Mantém fallback para a margem horária antiga em contextos legados.
        if bool(ctx.get('epg_require_full_window')):
            _day_key, _window_start, window_end = epg_local_day_window(ctx, now_ts=now_ts)
            target_ts = int(window_end)
        else:
            future_coverage = max(0, int(ctx.get('epg_min_future_coverage_secs') or 0))
            target_ts = now_ts + future_coverage if future_coverage > 0 else 0

        if target_ts > 0 and not epg_xml_covers_time(meta, target_ts):
            if age >= min_interval:
                _log(ctx, 'EPG sem cobertura completa de Hoje + Amanhã. Renovando em background.')
                return True
            _log(ctx, 'EPG ainda sem cobertura completa de Hoje + Amanhã, mas recém-atualizado; aguardando cooldown.', xbmc.LOGDEBUG)
    except Exception as exc:
        _log(ctx, f'Falha na verificação de cobertura EPG: {exc}', xbmc.LOGDEBUG)

    return False


def epg_build_urls(ctx):
    base_url = ctx['get_base_url_fn']().rstrip('/')
    username = ctx.get('username') or ''
    password = ctx.get('password') or ''
    return [
        (f"{base_url}/xmltv.php?username={username}&password={password}", 'principal'),
        (f"{base_url}/epg.php?username={username}&password={password}", 'fallback')
    ]


def epg_validate_xml_text(ctx, xml_text):
    if not xml_text:
        return False, 'resposta vazia'
    text = xml_text.strip()
    if len(text) < 32:
        return False, 'resposta muito curta'
    if not text.startswith('<'):
        return False, 'resposta não parece XML'
    try:
        root = ET.fromstring(text)
    except Exception as exc:
        return False, f'xml inválido: {exc}'
    root_tag = xml_local_name(root.tag)
    if root_tag not in ('tv', 'xmltv', 'epg'):
        has_epg_nodes = False
        try:
            for elem in list(root):
                tag = xml_local_name(elem.tag)
                if tag in ('channel', 'programme'):
                    has_epg_nodes = True
                    break
        except Exception:
            pass
        if not has_epg_nodes:
            return False, f'raiz XML inesperada: {root_tag}'
    has_programmes = bool(root.findall('.//programme'))
    has_channels = bool(root.findall('.//channel'))
    if not has_programmes and not has_channels:
        try:
            for elem in root.iter():
                tag = xml_local_name(elem.tag)
                if tag == 'programme':
                    has_programmes = True
                elif tag == 'channel':
                    has_channels = True
                if has_programmes or has_channels:
                    break
        except Exception:
            pass
    if not has_programmes and not has_channels:
        return False, 'XML sem canais nem programas'
    return True, 'ok'


def epg_runtime_cache_invalidate(ctx, reason=None, clear_scope=True):
    global _RUNTIME_PARSED, _RUNTIME_SCOPE, _RUNTIME_META
    _RUNTIME_PARSED = None
    _RUNTIME_META = None
    if clear_scope:
        _RUNTIME_SCOPE = None
    if reason:
        _log(ctx, f'EPG runtime invalidado: {reason}', xbmc.LOGDEBUG)


_SENSITIVE_QUERY_RE = re.compile(r'(?i)\b(username|password)=([^&\s]+)')
_URL_AUTH_RE = re.compile(r'(?i)(https?://)([^/@\s:]+):([^/@\s]+)@')


def epg_sanitize_error(ctx, value):
    """Remove autenticação de erros antes do log e do JSON de diagnóstico."""
    try:
        text = str(value or '')
    except Exception:
        text = 'falha sem detalhe legível'
    text = _SENSITIVE_QUERY_RE.sub(lambda match: '{}=***'.format(match.group(1)), text)
    text = _URL_AUTH_RE.sub(r'\1***:***@', text)
    for secret in (ctx.get('username'), ctx.get('password')):
        secret = str(secret or '').strip()
        if len(secret) < 4:
            continue
        for variant in (secret, urllib.parse.quote(secret, safe='')):
            if variant:
                text = text.replace(variant, '***')
    return text[:1600]


def epg_download(ctx):
    ctx['ensure_profile_dir_fn']()
    last_error = None
    epg_xml_path = ctx['epg_xml_storage_path_fn']()
    ctx['ensure_parent_dir_fn'](epg_xml_path)

    for url, label in epg_build_urls(ctx):
        try:
            # A fonte é suficiente para suporte; a URL contém o login Xtream.
            _log(ctx, f'Baixando EPG ({label}).')
            r = ctx['safe_requests_get_fn'](url)
            xml_text = r.text
            valid, reason = epg_validate_xml_text(ctx, xml_text)
            if not valid:
                _log(ctx, f'EPG {label} rejeitado: {reason}', xbmc.LOGWARNING)
                last_error = reason
                continue
            ctx['atomic_write_text_fn'](epg_xml_path, xml_text, encoding='utf-8')
            meta = {'fingerprint': ctx['fingerprint_fn'](), 'fetched_at': time.time(), 'source': label, 'last_attempt_status': 'ok', 'last_attempt_at': int(time.time()), 'retry_after': 0, 'last_error': ''}
            meta.update(epg_xml_meta_from_file(ctx, epg_xml_path))
            epg_meta_save(ctx, meta)
            epg_clear_index_cache(ctx)
            epg_clear_refresh_request(ctx)
            epg_runtime_cache_invalidate(ctx, f'download:{label}')
            _log(ctx, f"EPG salvo com sucesso pela fonte {label}: canais={meta.get('channel_count', 0)}, programas={meta.get('programme_count', 0)}")
            return
        except Exception as exc:
            last_error = epg_sanitize_error(ctx, exc)
            _log(ctx, f'Falha ao baixar EPG ({label}): {last_error}', xbmc.LOGWARNING)

    raise Exception(last_error or 'falha desconhecida ao baixar EPG')


def epg_empty_payload(ctx, day_key=None, window_start=0, window_end=0, xml_identity=None):
    payload = {
        'version': ctx['epg_index_version'],
        'day_key': str(day_key or ''),
        'generated_at': int(time.time()),
        'window_start': int(window_start or 0),
        'window_end': int(window_end or 0),
        'window_days': int(_window_days(ctx)),
        'channels': {},
        'progs': {},
    }
    if isinstance(xml_identity, dict):
        payload.update({
            'raw_mtime': int(xml_identity.get('raw_mtime') or 0),
            'raw_size': int(xml_identity.get('raw_size') or 0),
        })
    else:
        payload.update({'raw_mtime': 0, 'raw_size': 0})
    return payload


def epg_payload_runtime_meta(payload):
    if not isinstance(payload, dict):
        return {}
    try:
        return {
            'version': str(payload.get('version') or ''),
            'day_key': str(payload.get('day_key') or ''),
            'generated_at': int(payload.get('generated_at') or 0),
            'window_start': int(payload.get('window_start') or 0),
            'window_end': int(payload.get('window_end') or 0),
            'window_days': int(payload.get('window_days') or 0),
            'raw_mtime': int(payload.get('raw_mtime') or 0),
            'raw_size': int(payload.get('raw_size') or 0),
        }
    except Exception:
        return {}


def epg_payload_has_usable_data(payload):
    if not isinstance(payload, dict):
        return False
    channels = payload.get('channels') if isinstance(payload.get('channels'), dict) else {}
    progs = payload.get('progs') if isinstance(payload.get('progs'), dict) else {}
    if channels:
        return True
    for arr in progs.values():
        if isinstance(arr, list) and arr:
            return True
    return False


def epg_runtime_cache_is_valid(ctx, payload, scope, xml_path, now_ts=None):
    try:
        now_ts = int(time.time() if now_ts is None else now_ts)
    except Exception:
        now_ts = int(time.time())

    if payload is None or _RUNTIME_SCOPE != scope:
        return False

    meta = epg_payload_runtime_meta(payload)
    if meta.get('version') != ctx['epg_index_version']:
        return False

    generated_at = int(meta.get('generated_at') or 0)
    if generated_at <= 0 or (time.time() - generated_at) >= int(ctx['epg_ttl']):
        return False

    window_start = int(meta.get('window_start') or 0)
    window_end = int(meta.get('window_end') or 0)
    if window_start > 0 and now_ts < window_start:
        return False
    if window_end > 0 and now_ts > window_end:
        return False

    if xml_path and os.path.exists(xml_path):
        xml_id = epg_xml_identity(xml_path)
        if int(meta.get('raw_mtime') or 0) != int(xml_id.get('raw_mtime') or 0):
            return False
        if int(meta.get('raw_size') or 0) != int(xml_id.get('raw_size') or 0):
            return False
    elif int(meta.get('raw_mtime') or 0) or int(meta.get('raw_size') or 0):
        return False

    channels = payload.get('channels')
    progs = payload.get('progs')
    return isinstance(channels, dict) and isinstance(progs, dict)


def epg_read_cached_index(ctx, index_path, xml_path, day_key):
    if not index_path or not os.path.exists(index_path):
        return None
    data = ctx['load_json_fn'](index_path, None)
    if not isinstance(data, dict):
        return None
    try:
        xml_id = epg_xml_identity(xml_path)
        if data.get('version') != ctx['epg_index_version']:
            return None
        if data.get('day_key') != day_key:
            return None
        if int(data.get('raw_mtime') or 0) != int(xml_id.get('raw_mtime') or 0):
            return None
        if int(data.get('raw_size') or 0) != int(xml_id.get('raw_size') or 0):
            return None
        if (time.time() - int(data.get('generated_at') or 0)) >= int(ctx['epg_ttl']):
            return None
        channels = data.get('channels') if isinstance(data, dict) else None
        progs = data.get('progs') if isinstance(data, dict) else None
        if not isinstance(channels, dict) or not isinstance(progs, dict):
            return None
        data.setdefault('window_days', int(_window_days(ctx)))
        data.setdefault('window_start', 0)
        data.setdefault('window_end', 0)
        data.setdefault('generated_at', int(time.time()))
        return data
    except Exception:
        return None


def epg_write_cached_index(ctx, index_path, payload):
    try:
        ctx['atomic_write_json_fn'](index_path, payload, ensure_ascii=False)
        # Compatibilidade XC Pro/legado: mantém epg_cache.json apontando para o mesmo índice pronto.
        try:
            ctx['atomic_write_json_fn'](epg_compat_cache_storage_path(ctx), payload, ensure_ascii=False)
        except Exception:
            pass
        epg_prune_old_index_cache(ctx)
    except Exception as exc:
        _log(ctx, f'Falha ao gravar índice EPG: {exc}', xbmc.LOGDEBUG)


def epg_build_window_index(ctx, xml_path, day_key, window_start, window_end):
    channels = {}
    progs = {}
    tolerance = max(0, int(ctx.get('epg_boundary_tolerance_secs') or 0))
    try:
        for event, elem in ET.iterparse(xml_path, events=('end',)):
            tag = xml_local_name(elem.tag)
            if tag == 'channel':
                cid = normalize_epg_channel_id(elem.get('id'))
                dn = find_child_text(elem, 'display-name')
                if cid:
                    channels[cid] = dn
                elem.clear()
                continue
            if tag != 'programme':
                continue

            cid = normalize_epg_channel_id(elem.get('channel'))
            if not cid:
                elem.clear()
                continue

            start = normalize_epoch_seconds(elem.get('start_timestamp'))
            if start <= 0:
                start = parse_xmltv_time(ctx, elem.get('start'))

            stop = normalize_epoch_seconds(elem.get('stop_timestamp') or elem.get('end_timestamp'))
            if stop <= 0:
                stop = parse_xmltv_time(ctx, elem.get('stop') or elem.get('end'))

            if stop <= start:
                stop = start + 3600

            if stop < (window_start - tolerance) or start > (window_end + tolerance):
                elem.clear()
                continue

            title = find_child_text(elem, 'title')
            desc = find_child_text(elem, 'desc')
            progs.setdefault(cid, []).append({'start': int(start), 'end': int(stop), 'title': title, 'desc': desc})
            elem.clear()

        for cid, arr in progs.items():
            arr.sort(key=lambda x: x.get('start') or 0)

        _log(ctx, f'EPG índice diário criado: canais={len(channels)}, programas={sum(len(v) for v in progs.values())}, janela={day_key}')
    except Exception as exc:
        _log(ctx, f'Erro criando índice EPG: {exc}', xbmc.LOGERROR)
        raise
    return {'channels': channels, 'progs': progs}


def epg_get_window_index(ctx, xml_path):
    day_key, window_start, window_end = epg_local_day_window(ctx)
    index_path = epg_index_storage_path(ctx, day_key)
    cached = epg_read_cached_index(ctx, index_path, xml_path, day_key)
    if cached is not None:
        return cached
    built = epg_build_window_index(ctx, xml_path, day_key, window_start, window_end)
    payload = {
        'version': ctx['epg_index_version'],
        'day_key': day_key,
        'generated_at': int(time.time()),
        'window_start': int(window_start),
        'window_end': int(window_end),
        'window_days': int(_window_days(ctx)),
        'channels': built.get('channels', {}),
        'progs': built.get('progs', {}),
    }
    payload.update(epg_xml_identity(xml_path))
    epg_write_cached_index(ctx, index_path, payload)
    return payload


def epg_load_parsed(ctx, force=False):
    global _RUNTIME_PARSED, _RUNTIME_SCOPE, _RUNTIME_META

    current_scope = ctx['current_server_scope_identity_fn']()
    epg_xml_path = ctx['epg_xml_storage_path_fn']()
    now_ts = int(time.time())
    previous_payload = _RUNTIME_PARSED if isinstance(_RUNTIME_PARSED, dict) else None
    previous_scope = _RUNTIME_SCOPE

    if not force and epg_runtime_cache_is_valid(ctx, _RUNTIME_PARSED, current_scope, epg_xml_path, now_ts=now_ts):
        return _RUNTIME_PARSED

    if force:
        epg_runtime_cache_invalidate(ctx, 'force_reload')
    elif previous_payload is not None:
        reason_bits = []
        if previous_scope != current_scope:
            reason_bits.append('scope_changed')
        meta = epg_payload_runtime_meta(previous_payload)
        if meta:
            if now_ts < int(meta.get('window_start') or 0):
                reason_bits.append('window_not_started')
            if now_ts > int(meta.get('window_end') or 0):
                reason_bits.append('window_expired')
            if (time.time() - int(meta.get('generated_at') or 0)) >= int(ctx['epg_ttl']):
                reason_bits.append('runtime_ttl')
            xml_id = epg_xml_identity(epg_xml_path) if os.path.exists(epg_xml_path) else {'raw_mtime': 0, 'raw_size': 0}
            if int(meta.get('raw_mtime') or 0) != int(xml_id.get('raw_mtime') or 0) or int(meta.get('raw_size') or 0) != int(xml_id.get('raw_size') or 0):
                reason_bits.append('xml_changed')
        epg_runtime_cache_invalidate(ctx, ','.join(reason_bits) if reason_bits else 'runtime_invalid')

    has_xml = os.path.exists(epg_xml_path)
    needs_refresh = epg_should_refresh(ctx)
    download_on_demand = bool(ctx.get('download_on_demand', True))
    if needs_refresh and download_on_demand:
        try:
            epg_download(ctx)
            has_xml = os.path.exists(epg_xml_path)
        except Exception as exc:
            safe_error = epg_sanitize_error(ctx, exc)
            _log(ctx, f'Falha ao baixar EPG: {safe_error}', xbmc.LOGERROR)
            meta = epg_meta_load(ctx)
            # Consome o pedido mesmo em falha e registra nova tentativa. Isso
            # preserva recuperação periódica sem loop de 2 segundos.
            retry_secs = max(60, int(ctx.get('epg_failure_retry_secs') or ctx.get('epg_min_refresh_interval_secs') or 3600))
            failure_meta = dict(meta or {})
            failure_meta.update({
                'fingerprint': ctx['fingerprint_fn'](),
                'last_attempt_at': int(now_ts),
                'last_attempt_status': 'failed',
                'last_error': safe_error,
                'retry_after': int(now_ts + retry_secs),
            })
            epg_meta_save(ctx, failure_meta)
            epg_clear_refresh_request(ctx)
            if has_xml and epg_xml_covers_time(meta, now_ts):
                _log(ctx, 'EPG refresh falhou; reutilizando XML salvo ainda válido.', xbmc.LOGWARNING)
            elif has_xml:
                rebuilt = epg_xml_meta_from_file(ctx, epg_xml_path)
                if epg_xml_covers_time(rebuilt, now_ts):
                    merged = dict(meta or {})
                    merged.update(rebuilt)
                    epg_meta_save(ctx, merged)
                    _log(ctx, 'EPG refresh falhou; reutilizando XML salvo após verificar cobertura.', xbmc.LOGWARNING)
                else:
                    _log(ctx, 'EPG refresh falhou e o XML salvo não cobre o horário atual.', xbmc.LOGWARNING)
            else:
                _log(ctx, 'EPG refresh falhou e não há XML salvo para fallback.', xbmc.LOGWARNING)
    elif needs_refresh:
        # Chamadas vindas da navegação/UI não devem baixar XMLTV pesado. O service.py gera o cache em background.
        _log(ctx, 'EPG precisa renovar, mas download sob demanda está desativado na UI; usando índice/XML salvo se existir.', xbmc.LOGDEBUG)
        if bool(ctx.get('request_background_refresh_on_ui')):
            epg_mark_refresh_requested(ctx, 'ui_opened_grade_needs_refresh')
        try:
            meta = epg_meta_load(ctx)
            if meta.get('fingerprint') != ctx['fingerprint_fn']():
                day_key, window_start, window_end = epg_local_day_window(ctx, now_ts=now_ts)
                _RUNTIME_PARSED = epg_empty_payload(ctx, day_key, window_start, window_end)
                _RUNTIME_SCOPE = current_scope
                _RUNTIME_META = epg_payload_runtime_meta(_RUNTIME_PARSED)
                _log(ctx, 'EPG antigo ignorado: login/senha/servidor mudaram e o service ainda não gerou novo XMLTV.', xbmc.LOGDEBUG)
                return _RUNTIME_PARSED
        except Exception:
            pass

    if not has_xml:
        day_key, window_start, window_end = epg_local_day_window(ctx, now_ts=now_ts)
        _RUNTIME_PARSED = epg_empty_payload(ctx, day_key, window_start, window_end)
        _RUNTIME_SCOPE = current_scope
        _RUNTIME_META = epg_payload_runtime_meta(_RUNTIME_PARSED)
        return _RUNTIME_PARSED

    try:
        payload = epg_get_window_index(ctx, epg_xml_path)
        if not epg_payload_has_usable_data(payload):
            rebuilt = epg_xml_meta_from_file(ctx, epg_xml_path)
            if rebuilt.get('programme_count'):
                merged = dict(epg_meta_load(ctx) or {})
                merged.update(rebuilt)
                epg_meta_save(ctx, merged)
                payload = epg_get_window_index(ctx, epg_xml_path)
        if not epg_payload_has_usable_data(payload) and epg_payload_has_usable_data(previous_payload) and epg_runtime_cache_is_valid(ctx, previous_payload, current_scope, epg_xml_path, now_ts=now_ts):
            _log(ctx, 'EPG rebuild voltou vazio; preservando cache em memória ainda válido.', xbmc.LOGWARNING)
            payload = previous_payload
        _RUNTIME_PARSED = payload if isinstance(payload, dict) else epg_empty_payload(ctx, *epg_local_day_window(ctx, now_ts=now_ts), xml_identity=epg_xml_identity(epg_xml_path))
        _RUNTIME_SCOPE = current_scope
        _RUNTIME_META = epg_payload_runtime_meta(_RUNTIME_PARSED)
        _log(ctx, f"EPG carregado: canais={len(_RUNTIME_PARSED.get('channels', {}))}, programas={sum(len(v) for v in _RUNTIME_PARSED.get('progs', {}).values())}, janela={_RUNTIME_META.get('window_days') or _window_days(ctx)}d, cache={ctx['epg_cache_days']}d")
    except Exception as exc:
        _log(ctx, f'Erro parseando EPG: {exc}', xbmc.LOGERROR)
        if epg_payload_has_usable_data(previous_payload) and epg_runtime_cache_is_valid(ctx, previous_payload, current_scope, epg_xml_path, now_ts=now_ts):
            _RUNTIME_PARSED = previous_payload
            _RUNTIME_SCOPE = current_scope
            _RUNTIME_META = epg_payload_runtime_meta(previous_payload)
            _log(ctx, 'EPG parse falhou; mantendo cache em memória ainda válido.', xbmc.LOGWARNING)
        else:
            day_key, window_start, window_end = epg_local_day_window(ctx, now_ts=now_ts)
            _RUNTIME_PARSED = epg_empty_payload(ctx, day_key, window_start, window_end, xml_identity=epg_xml_identity(epg_xml_path))
            _RUNTIME_SCOPE = current_scope
            _RUNTIME_META = epg_payload_runtime_meta(_RUNTIME_PARSED)

    return _RUNTIME_PARSED
