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

import base64
import calendar
import gzip
import hashlib
import io
import json
import os
import re
import shutil
import time
import unicodedata

try:
    from xml.etree import cElementTree as ET
except Exception:
    from xml.etree import ElementTree as ET

try:
    from kodi_six import xbmcaddon
except ImportError:
    import xbmcaddon

from resources.lib.dns import customdns
from resources.lib.common import make_session, log, DEFAULT_TIMEOUT, PROFILE_DIR, ensure_dir

customdns()

MASTER_URL = base64.b64decode('aHR0cHM6Ly9vbmVwbGF5aGQuY29tL2xpc3Rhc19vbmVwbGF5L21hc3Rlci50eHQ=').decode('utf-8')
SESSION = make_session()
M3U_META_CACHE = {}
M3U_SESSION_CACHE_SECONDS = 120
EPG_CACHE_DIR = ensure_dir(os.path.join(PROFILE_DIR, 'epg_cache'))
EPG_XMLTV_DIR = ensure_dir(EPG_CACHE_DIR)
EPG_INDEX_DIR = ensure_dir(EPG_CACHE_DIR)
EPG_CACHE_VERSION = 'xmltv_day_v3_smart_coverage_brt'
EPG_XMLTV_META_VERSION = 'xmltv_meta_v1_coverage_brt'
BRAZIL_UTC_OFFSET_SECONDS = -3 * 3600

EPG_ALIAS_REPLACEMENTS = (
    ('+', ' plus '),
    ('&', ' and '),
)

EPG_NOISE_WORDS = set([
    'hd', 'sd', 'fhd', 'uhd', '4k', 'fullhd', 'full', 'hevc', 'h264', 'h265',
    'mpeg', 'aac', 'ac3', 'latino', 'dublado', 'dual', 'audio', 'backup',
    'opcao', 'option', 'canal', 'channel', 'ao', 'vivo', 'live', 'br', 'brazil',
    'brasil', 'la', 'latam', 'latin', 'america', 'east', 'west', 'feed'
])

EPG_PREFIX_WORDS = set(['tv'])


def _get_epg_ttl():
    try:
        addon = xbmcaddon.Addon()
        raw = _to_text(addon.getSetting('tv_epg_cache_days')).strip()
        days = int(raw) + 1 if raw != '' else 1
    except Exception:
        days = 1
    if days < 1:
        days = 1
    return days * 86400


def _to_text(value):
    if value is None:
        return ''
    try:
        if isinstance(value, bytes):
            return value.decode('utf-8', 'replace')
    except Exception:
        pass
    try:
        return str(value)
    except Exception:
        return ''


def _normalize_key(value):
    text = _to_text(value).strip().lower()
    if not text:
        return ''
    try:
        text = unicodedata.normalize('NFKD', text)
        text = ''.join(ch for ch in text if not unicodedata.combining(ch))
    except Exception:
        pass
    text = re.sub(r'[^a-z0-9]+', ' ', text)
    return ' '.join(text.split())


def _tokenize_key(value):
    normalized = _normalize_key(value)
    return [token for token in normalized.split() if token]


def _compress_tokens(tokens):
    if not tokens:
        return ''
    return ' '.join([token for token in tokens if token]).strip()


def _channel_alias_forms(value):
    normalized = _normalize_key(value)
    if not normalized:
        return []

    aliases = []

    def add_alias(candidate):
        candidate = _normalize_key(candidate)
        if candidate and candidate not in aliases:
            aliases.append(candidate)

    add_alias(normalized)

    raw = normalized
    for before, after in EPG_ALIAS_REPLACEMENTS:
        raw = raw.replace(before, after)
    add_alias(raw)

    raw = re.sub(r'(tv)\s+', '', raw).strip()
    add_alias(raw)

    tokens = _tokenize_key(normalized)
    if tokens:
        cleaned = [token for token in tokens if token not in EPG_NOISE_WORDS]
        add_alias(_compress_tokens(cleaned))

        trimmed = list(cleaned)
        while trimmed and trimmed[0] in EPG_PREFIX_WORDS:
            trimmed = trimmed[1:]
        add_alias(_compress_tokens(trimmed))

        if len(cleaned) >= 2:
            add_alias(_compress_tokens([cleaned[0], cleaned[-1]]))

        if cleaned:
            digitless = [re.sub(r'\d+$', '', token) or token for token in cleaned]
            add_alias(_compress_tokens(digitless))

    return aliases


def _extract_attr(line, attr_name):
    pattern = r'%s="([^"]*)"' % re.escape(attr_name)
    match = re.search(pattern, line, re.IGNORECASE)
    return match.group(1).strip() if match else ''


def _get_text(url, timeout=DEFAULT_TIMEOUT):
    last_exc = None
    candidates = [url]
    if 'proxy.liyao.space' not in url:
        candidates.append('https://proxy.liyao.space/------{}'.format(url))
    for candidate in candidates:
        try:
            res = SESSION.get(candidate, timeout=timeout)
            if res.status_code == 200 and res.text:
                res.encoding = 'utf-8'
                return res.text
            log('M3U HTTP {} para {}'.format(res.status_code, candidate), level=2)
        except Exception as exc:
            last_exc = exc
            log('M3U falhou em {}: {}'.format(candidate, exc), level=2)
    raise Exception('Erro ao baixar recurso M3U: {}'.format(last_exc or url))


def _parse_m3u_text(text):
    channels = []
    groups = []
    groups_seen = set()
    group_counts = {}
    channels_by_group = {}
    current = None
    epg_url = ''

    for raw_line in text.splitlines():
        line = raw_line.strip()
        if not line:
            continue

        if line.startswith('#EXTM3U'):
            for attr in ('x-tvg-url', 'url-tvg', 'tvg-url'):
                value = _extract_attr(line, attr)
                if value:
                    epg_url = value
                    break
            continue

        if line.startswith('#EXTINF'):
            group = _extract_attr(line, 'group-title') or 'Outros'
            logo = _extract_attr(line, 'tvg-logo')
            tvg_id = _extract_attr(line, 'tvg-id')
            tvg_name = _extract_attr(line, 'tvg-name')
            name = line.split(',')[-1].strip() or tvg_name or 'Canal'
            current = {
                'group': group or 'Outros',
                'name': name,
                'logo': logo,
                'tvg_id': tvg_id,
                'tvg_name': tvg_name or name,
                'id': base64.urlsafe_b64encode(name.encode('utf-8')).decode('ascii')
            }
            if current['group'] not in groups_seen:
                groups_seen.add(current['group'])
                groups.append(current['group'])
                channels_by_group[current['group']] = []
                group_counts[current['group']] = 0
        elif current and (line.startswith('http://') or line.startswith('https://')):
            current['url'] = line
            entry = current.copy()
            channels.append(entry)
            group_name = entry.get('group') or 'Outros'
            channels_by_group.setdefault(group_name, []).append(entry)
            group_counts[group_name] = group_counts.get(group_name, 0) + 1
            current = None

    try:
        text_hash = hashlib.md5(_to_text(text).encode('utf-8')).hexdigest()
    except Exception:
        text_hash = ''

    return {
        'channels': channels,
        'groups': groups,
        'group_counts': group_counts,
        'channels_by_group': channels_by_group,
        'epg_url': epg_url,
        'text_hash': text_hash,
        'fetched_at': int(time.time()),
    }


def _get_meta(m3u_url):
    cached = M3U_META_CACHE.get(m3u_url)
    now_ts = int(time.time())
    if cached and now_ts - int(cached.get('fetched_at', 0) or 0) <= M3U_SESSION_CACHE_SECONDS:
        return cached

    text = _get_text(m3u_url)
    try:
        text_hash = hashlib.md5(_to_text(text).encode('utf-8')).hexdigest()
    except Exception:
        text_hash = ''

    if cached and text_hash and text_hash == cached.get('text_hash', ''):
        cached['fetched_at'] = now_ts
        return cached

    meta = _parse_m3u_text(text)
    M3U_META_CACHE[m3u_url] = meta
    return meta


def parse_m3u(m3u_url):
    meta = _get_meta(m3u_url)
    return meta.get('channels', []), meta.get('groups', [])


def get_group_index(m3u_url):
    meta = _get_meta(m3u_url)
    return meta.get('groups', []), meta.get('group_counts', {})


def get_group_channels(m3u_url, group_name):
    meta = _get_meta(m3u_url)
    return list(meta.get('channels_by_group', {}).get(group_name, []))


def _derive_epg_urls_from_m3u(m3u_url):
    derived = []
    source = _to_text(m3u_url).strip()
    if not source:
        return derived
    try:
        from six.moves.urllib.parse import urlsplit, urlunsplit
    except Exception:
        return derived
    try:
        parts = urlsplit(source)
        path = parts.path or ''
        if '/' not in path:
            return derived
        base_path = path.rsplit('/', 1)[0] + '/'
        for filename in ('xmltv.php', 'epg.php'):
            candidate = urlunsplit((parts.scheme, parts.netloc, base_path + filename, '', ''))
            if candidate not in derived:
                derived.append(candidate)
    except Exception:
        return derived
    return derived


def get_m3u_epg_url(m3u_url):
    epg_url = _get_meta(m3u_url).get('epg_url', '')
    if epg_url:
        return epg_url
    derived = _derive_epg_urls_from_m3u(m3u_url)
    return derived[0] if derived else ''


def get_lists():
    text = _get_text(MASTER_URL)
    return [line.strip() for line in text.splitlines() if line.strip().startswith(('http://', 'https://'))]


def basename(path):
    idx = path.rfind('/') + 1
    return path[idx:]


def _source_key(m3u_url, epg_url):
    seed = '{}|{}|{}'.format(_to_text(m3u_url).strip(), _to_text(epg_url).strip(), EPG_CACHE_VERSION)
    return hashlib.md5(seed.encode('utf-8')).hexdigest()


def _source_label(m3u_url, epg_url=''):
    target = _to_text(m3u_url).strip()
    try:
        options = get_lists()
        for index, option in enumerate(options, 1):
            if _to_text(option).strip() == target:
                return 'lista{}'.format(index)
    except Exception:
        pass

    for candidate in (_to_text(epg_url).strip(), target):
        if not candidate:
            continue
        base = os.path.splitext(basename(candidate.split('?', 1)[0].split('#', 1)[0]))[0]
        base = re.sub(r'[^a-zA-Z0-9]+', '', _normalize_key(base).replace(' ', ''))
        if base:
            return base.lower()

    return 'lista_' + _source_key(m3u_url, epg_url)[:8]


def _xmltv_file_for_source(m3u_url, epg_url):
    return os.path.join(EPG_XMLTV_DIR, '{}.xml'.format(_source_label(m3u_url, epg_url)))


def _xmltv_meta_file_for_source(m3u_url, epg_url):
    return os.path.join(EPG_XMLTV_DIR, '{}.meta.json'.format(_source_label(m3u_url, epg_url)))


def _index_file_for_source(m3u_url, epg_url, day_key):
    return os.path.join(EPG_INDEX_DIR, '{}_{}.json'.format(_source_label(m3u_url, epg_url), day_key))


def _file_is_fresh(path):
    try:
        return os.path.exists(path) and (time.time() - os.path.getmtime(path) <= _get_epg_ttl())
    except Exception:
        return False


def _parse_xmltv_datetime(value):
    if not value:
        return None
    raw = _to_text(value).strip()
    if not raw:
        return None
    base = raw.split(' ', 1)[0]
    tz_part = raw[len(base):].strip()
    try:
        tm = time.strptime(base[:14], '%Y%m%d%H%M%S')
        ts = calendar.timegm(tm)
        if tz_part and len(tz_part) >= 5 and tz_part[0] in '+-' and tz_part[1:5].isdigit():
            sign = -1 if tz_part.startswith('-') else 1
            try:
                digits = tz_part[1:5]
                offset = (int(digits[:2]) * 60 + int(digits[2:4])) * 60
                ts -= sign * offset
            except Exception:
                pass
        else:
            ts -= BRAZIL_UTC_OFFSET_SECONDS
        return int(ts)
    except Exception:
        return None


def _format_brazil_time(timestamp):
    try:
        ts = int(timestamp) + BRAZIL_UTC_OFFSET_SECONDS
        return time.strftime('%H:%M', time.gmtime(ts))
    except Exception:
        return ''


def _local_day_window(now_ts=None):
    if now_ts is None:
        now_ts = int(time.time())
    local_now = int(now_ts) + BRAZIL_UTC_OFFSET_SECONDS
    local_tm = time.gmtime(local_now)
    day_key = time.strftime('%Y%m%d', local_tm)
    day_start_local_epoch = calendar.timegm((local_tm.tm_year, local_tm.tm_mon, local_tm.tm_mday, 0, 0, 0, 0, 0, 0))
    day_start_utc = day_start_local_epoch - BRAZIL_UTC_OFFSET_SECONDS
    day_end_utc = day_start_utc + 86400
    return day_key, int(day_start_utc), int(day_end_utc)


def _build_epg_candidates(epg_url, m3u_url=''):
    seen = set()
    base_candidates = []

    def add_candidate(url):
        url = _to_text(url).strip()
        if not url or url in seen:
            return
        seen.add(url)
        base_candidates.append(url)

    add_candidate(epg_url)
    for derived in _derive_epg_urls_from_m3u(m3u_url):
        add_candidate(derived)

    expanded = []
    for url in list(base_candidates):
        lowered = _to_text(url).lower()
        expanded.append(url)
        if 'xmltv.php' in lowered:
            expanded.append(re.sub(r'xmltv\.php', 'epg.php', url, flags=re.IGNORECASE))
        elif 'epg.php' in lowered:
            expanded.append(re.sub(r'epg\.php', 'xmltv.php', url, flags=re.IGNORECASE))

    final_candidates = []
    seen_final = set()
    for url in expanded:
        url = _to_text(url).strip()
        if not url or url in seen_final:
            continue
        seen_final.add(url)
        final_candidates.append(url)
        if 'proxy.liyao.space' not in url:
            proxied = 'https://proxy.liyao.space/------{}'.format(url)
            if proxied not in seen_final:
                seen_final.add(proxied)
                final_candidates.append(proxied)
    return final_candidates


def _download_epg_payload(epg_url, m3u_url=''):
    candidates = _build_epg_candidates(epg_url, m3u_url=m3u_url)
    last_exc = None
    for candidate in candidates:
        try:
            res = SESSION.get(candidate, timeout=max(DEFAULT_TIMEOUT, 20))
            if res.status_code != 200:
                log('EPG HTTP {} para {}'.format(res.status_code, candidate), level=2)
                continue
            content = res.content or b''
            if not content:
                log('EPG vazio em {}'.format(candidate), level=2)
                continue
            if candidate.lower().endswith('.gz') or content[:2] == b'\x1f\x8b':
                return gzip.GzipFile(fileobj=io.BytesIO(content)).read()
            return content
        except Exception as exc:
            last_exc = exc
            log('EPG falhou em {}: {}'.format(candidate, exc), level=2)
    raise Exception('Erro ao baixar EPG: {}'.format(last_exc or epg_url))


def _build_xmltv_meta(xmltv_file):
    first_ts = None
    last_ts = None
    total_programmes = 0
    for event, elem in ET.iterparse(xmltv_file, events=('end',)):
        if elem.tag != 'programme':
            elem.clear()
            continue
        start_ts = _parse_xmltv_datetime(elem.get('start'))
        stop_ts = _parse_xmltv_datetime(elem.get('stop'))
        if start_ts is not None:
            if first_ts is None or start_ts < first_ts:
                first_ts = int(start_ts)
        if stop_ts is not None:
            if last_ts is None or stop_ts > last_ts:
                last_ts = int(stop_ts)
        total_programmes += 1
        elem.clear()

    first_day = ''
    last_day = ''
    if first_ts is not None:
        first_day = _local_day_window(first_ts)[0]
    if last_ts is not None:
        last_day = _local_day_window(max(0, int(last_ts) - 1))[0]

    return {
        'version': EPG_XMLTV_META_VERSION,
        'generated_at': int(time.time()),
        'raw_mtime': int(os.path.getmtime(xmltv_file)),
        'raw_size': int(os.path.getsize(xmltv_file)),
        'first_ts': int(first_ts or 0),
        'last_ts': int(last_ts or 0),
        'first_day': first_day,
        'last_day': last_day,
        'programme_count': int(total_programmes),
    }


def _read_xmltv_meta(meta_file, xmltv_file):
    if not os.path.exists(meta_file):
        return None
    try:
        with io.open(meta_file, 'r', encoding='utf-8') as fh:
            data = json.load(fh)
    except Exception as exc:
        log('Falha ao ler metadados XMLTV {}: {}'.format(meta_file, exc), level=2)
        return None
    try:
        if data.get('version') != EPG_XMLTV_META_VERSION:
            return None
        if int(data.get('raw_mtime') or 0) != int(os.path.getmtime(xmltv_file)):
            return None
        if int(data.get('raw_size') or 0) != int(os.path.getsize(xmltv_file)):
            return None
        return data
    except Exception:
        return None


def _write_xmltv_meta(meta_file, data):
    try:
        temp_path = meta_file + '.tmp'
        with io.open(temp_path, 'w', encoding='utf-8') as fh:
            json.dump(data, fh, ensure_ascii=False)
        try:
            if os.path.exists(meta_file):
                os.remove(meta_file)
        except Exception:
            pass
        os.rename(temp_path, meta_file)
    except Exception as exc:
        log('Falha ao gravar metadados XMLTV {}: {}'.format(meta_file, exc), level=2)


def _ensure_xmltv_meta(m3u_url, epg_url, xmltv_file):
    meta_file = _xmltv_meta_file_for_source(m3u_url, epg_url)
    meta = _read_xmltv_meta(meta_file, xmltv_file)
    if meta is not None:
        return meta
    try:
        meta = _build_xmltv_meta(xmltv_file)
        _write_xmltv_meta(meta_file, meta)
        return meta
    except Exception as exc:
        log('Falha gerando metadados do XMLTV {}: {}'.format(xmltv_file, exc), level=2)
        return None


def _xmltv_covers_day(meta, day_key, day_start_utc, day_end_utc):
    if not isinstance(meta, dict):
        return False
    first_ts = int(meta.get('first_ts') or 0)
    last_ts = int(meta.get('last_ts') or 0)
    if last_ts <= 0:
        return False
    if day_start_utc < first_ts or day_start_utc >= last_ts:
        return False
    meta_day = _to_text(meta.get('last_day')).strip()
    if meta_day and day_key > meta_day:
        return False
    if int(last_ts) < int(day_end_utc):
        # Ainda pode cobrir o início do dia, mas para grade diária preferimos garantir o dia inteiro.
        return False
    return True


def _refresh_xmltv_source(m3u_url, epg_url, xmltv_file):
    payload = _download_epg_payload(epg_url, m3u_url=m3u_url)
    ensure_dir(EPG_XMLTV_DIR)
    temp_path = xmltv_file + '.tmp'
    with io.open(temp_path, 'wb') as fh:
        fh.write(payload)
    try:
        if os.path.exists(xmltv_file):
            os.remove(xmltv_file)
    except Exception:
        pass
    os.rename(temp_path, xmltv_file)
    return _ensure_xmltv_meta(m3u_url, epg_url, xmltv_file)


def _ensure_local_xmltv(m3u_url, epg_url, day_key=None, day_start_utc=None, day_end_utc=None):
    xmltv_file = _xmltv_file_for_source(m3u_url, epg_url)
    if day_key is None or day_start_utc is None or day_end_utc is None:
        day_key, day_start_utc, day_end_utc = _local_day_window()

    if not os.path.exists(xmltv_file):
        _refresh_xmltv_source(m3u_url, epg_url, xmltv_file)
        return xmltv_file

    meta = _ensure_xmltv_meta(m3u_url, epg_url, xmltv_file)
    is_fresh = _file_is_fresh(xmltv_file)
    if is_fresh and _xmltv_covers_day(meta, day_key, day_start_utc, day_end_utc):
        return xmltv_file

    try:
        new_meta = _refresh_xmltv_source(m3u_url, epg_url, xmltv_file)
        if _xmltv_covers_day(new_meta, day_key, day_start_utc, day_end_utc):
            return xmltv_file
    except Exception as exc:
        # Se o XML antigo ainda cobre o dia e o refresh falhou, seguimos com ele para não travar a UI.
        if meta and _xmltv_covers_day(meta, day_key, day_start_utc, day_end_utc):
            log('EPG refresh falhou, reutilizando XML coberto: {}'.format(exc), level=2)
            return xmltv_file
        raise

    return xmltv_file

def clear_epg_cache():
    removed = 0
    seen = set()
    for base_path in (EPG_CACHE_DIR, EPG_XMLTV_DIR, EPG_INDEX_DIR):
        if base_path in seen:
            continue
        seen.add(base_path)
        try:
            ensure_dir(base_path)
            for filename in os.listdir(base_path):
                path = os.path.join(base_path, filename)
                try:
                    if os.path.isfile(path):
                        os.remove(path)
                        removed += 1
                    elif os.path.isdir(path):
                        shutil.rmtree(path)
                        removed += 1
                except Exception:
                    pass
        except Exception:
            pass
    return removed


def _epg_channel_keys(channel):
    keys = []
    for value in (channel.get('tvg_id'), channel.get('tvg_name'), channel.get('name')):
        for alias in _channel_alias_forms(value):
            if alias and alias not in keys:
                keys.append(alias)
    return keys


def _score_channel_match(channel_tokens, alias_tokens):
    if not channel_tokens or not alias_tokens:
        return 0.0
    overlap = len(channel_tokens & alias_tokens)
    if overlap <= 0:
        return 0.0
    union = len(channel_tokens | alias_tokens)
    if union <= 0:
        return 0.0
    coverage = float(overlap) / float(len(channel_tokens))
    jaccard = float(overlap) / float(union)
    return (coverage * 0.65) + (jaccard * 0.35)


def _resolve_epg_entry_for_channel(channel, channel_entries):
    direct_keys = _epg_channel_keys(channel)
    for key in direct_keys:
        match = channel_entries.get(key)
        if match:
            return match, 'direct', key

    candidate_tokens = []
    for key in direct_keys:
        tokens = set(_tokenize_key(key))
        if tokens:
            candidate_tokens.append(tokens)

    if not candidate_tokens:
        return None, '', ''

    best_score = 0.0
    best_match = None
    best_alias = ''
    for alias, entry in channel_entries.items():
        alias_tokens = set(_tokenize_key(alias))
        if not alias_tokens:
            continue
        alias_score = 0.0
        for channel_token_set in candidate_tokens:
            score = _score_channel_match(channel_token_set, alias_tokens)
            if score > alias_score:
                alias_score = score
        if alias_score > best_score:
            best_score = alias_score
            best_match = entry
            best_alias = alias

    if best_match is not None and best_score >= 0.86:
        return best_match, 'fuzzy', best_alias
    return None, '', ''


def _find_text(element, tag_name):
    for child in list(element):
        if child.tag == tag_name and child.text:
            return _to_text(child.text).strip()
    return ''


def _determine_current_next(schedule, now_ts):
    current = None
    upcoming = None
    for item in schedule:
        start_ts = int(item.get('start') or 0)
        stop_ts = int(item.get('stop') or 0)
        if start_ts <= now_ts < stop_ts:
            current = item
        elif start_ts > now_ts and upcoming is None:
            upcoming = item
        if current and upcoming:
            break
    return current, upcoming


def _build_day_epg_index(xmltv_file, now_ts, day_key, day_start_utc, day_end_utc):
    channel_aliases = {}
    schedules_by_channel = {}
    primary_names = {}
    try:
        for event, elem in ET.iterparse(xmltv_file, events=('end',)):
            if elem.tag == 'channel':
                channel_id = _normalize_key(elem.get('id'))
                if channel_id:
                    aliases = set([channel_id])
                    primary_name = ''
                    for child in list(elem):
                        if child.tag == 'display-name' and child.text:
                            display_name = _to_text(child.text).strip()
                            if display_name and not primary_name:
                                primary_name = display_name
                            for alias in _channel_alias_forms(display_name):
                                if alias:
                                    aliases.add(alias)
                    channel_aliases[channel_id] = aliases
                    if primary_name:
                        primary_names[channel_id] = primary_name
                elem.clear()
                continue
            if elem.tag != 'programme':
                continue
            channel_attr = _normalize_key(elem.get('channel'))
            if not channel_attr:
                elem.clear()
                continue
            start_ts = _parse_xmltv_datetime(elem.get('start'))
            stop_ts = _parse_xmltv_datetime(elem.get('stop'))
            if start_ts is None or stop_ts is None:
                elem.clear()
                continue
            if stop_ts <= day_start_utc or start_ts >= day_end_utc:
                elem.clear()
                continue
            title = _find_text(elem, 'title')
            desc = _find_text(elem, 'desc')
            schedules_by_channel.setdefault(channel_attr, []).append({
                'title': title,
                'desc': desc,
                'start': int(start_ts),
                'stop': int(stop_ts)
            })
            elem.clear()
    except Exception as exc:
        log('Falha processando XMLTV {}: {}'.format(xmltv_file, exc), level=2)
        raise

    channel_entries = {}
    for channel_id, schedule in schedules_by_channel.items():
        schedule.sort(key=lambda item: (int(item.get('start') or 0), int(item.get('stop') or 0)))
        current, upcoming = _determine_current_next(schedule, now_ts)
        entry = {
            'current': current,
            'next': upcoming,
            'schedule': schedule,
            'day_key': day_key,
            'source_channel': channel_id,
            'source_name': primary_names.get(channel_id, channel_id),
        }
        aliases = set([channel_id])
        aliases.update(channel_aliases.get(channel_id, set()))
        for alias in aliases:
            if alias:
                channel_entries[alias] = entry

    return {
        'version': EPG_CACHE_VERSION,
        'generated_at': int(now_ts),
        'day_key': day_key,
        'raw_mtime': int(os.path.getmtime(xmltv_file)),
        'raw_size': int(os.path.getsize(xmltv_file)),
        'channels': channel_entries,
    }


def _read_cached_index(index_file, xmltv_file, day_key):
    if not _file_is_fresh(index_file):
        return None
    try:
        with io.open(index_file, 'r', encoding='utf-8') as fh:
            data = json.load(fh)
    except Exception as exc:
        log('Falha ao ler índice EPG {}: {}'.format(index_file, exc), level=2)
        return None
    try:
        if data.get('version') != EPG_CACHE_VERSION:
            return None
        if data.get('day_key') != day_key:
            return None
        if int(data.get('raw_mtime') or 0) != int(os.path.getmtime(xmltv_file)):
            return None
        if int(data.get('raw_size') or 0) != int(os.path.getsize(xmltv_file)):
            return None
        return data
    except Exception:
        return None


def _write_cached_index(index_file, data):
    try:
        ensure_dir(EPG_INDEX_DIR)
        temp_path = index_file + '.tmp'
        with io.open(temp_path, 'w', encoding='utf-8') as fh:
            json.dump(data, fh, ensure_ascii=False)
        try:
            if os.path.exists(index_file):
                os.remove(index_file)
        except Exception:
            pass
        os.rename(temp_path, index_file)
    except Exception as exc:
        log('Falha ao gravar índice EPG {}: {}'.format(index_file, exc), level=2)


def _refresh_cached_runtime_state(data, now_ts):
    if not isinstance(data, dict):
        return False
    channels = data.get('channels', {})
    if not isinstance(channels, dict):
        return False
    changed = False
    for entry in channels.values():
        if not isinstance(entry, dict):
            continue
        schedule = entry.get('schedule') or []
        if not isinstance(schedule, list) or not schedule:
            continue
        current, upcoming = _determine_current_next(schedule, now_ts)
        old_current = entry.get('current') or {}
        old_next = entry.get('next') or {}
        if (not _epg_item_matches(old_current, current or {})) or (not _epg_item_matches(old_next, upcoming or {})):
            entry['current'] = current
            entry['next'] = upcoming
            changed = True
    if changed:
        data['generated_at'] = int(now_ts)
    return changed


def _get_epg_index(m3u_url, epg_url):
    now_ts = int(time.time())
    day_key, day_start_utc, day_end_utc = _local_day_window(now_ts)
    xmltv_file = _ensure_local_xmltv(m3u_url, epg_url, day_key=day_key, day_start_utc=day_start_utc, day_end_utc=day_end_utc)
    index_file = _index_file_for_source(m3u_url, epg_url, day_key)
    cached = _read_cached_index(index_file, xmltv_file, day_key)
    if cached is not None:
        if _refresh_cached_runtime_state(cached, now_ts):
            _write_cached_index(index_file, cached)
        return cached
    built = _build_day_epg_index(xmltv_file, now_ts, day_key, day_start_utc, day_end_utc)
    _write_cached_index(index_file, built)
    return built


def get_epg_for_channels(m3u_url, channels):
    epg_url = get_m3u_epg_url(m3u_url)
    if not epg_url or not channels:
        return {}

    channel_key_map = {}
    wanted = set()
    for channel in channels:
        keys = _epg_channel_keys(channel)
        channel_key_map[channel.get('id')] = keys
        wanted.update(keys)
    if not wanted:
        return {}

    index_data = _get_epg_index(m3u_url, epg_url)
    channel_entries = index_data.get('channels', {}) if isinstance(index_data, dict) else {}

    resolved = {}
    matched_direct = 0
    matched_fuzzy = 0
    for channel in channels:
        match, match_mode, matched_key = _resolve_epg_entry_for_channel(channel, channel_entries)
        if match:
            if match_mode == 'direct':
                matched_direct += 1
            elif match_mode == 'fuzzy':
                matched_fuzzy += 1
            resolved[channel.get('id')] = match
    if channels:
        try:
            log('EPG [{}] canais={} direto={} fuzzy={} sem_match={}'.format(
                _source_key(m3u_url, epg_url)[:8],
                len(channels),
                matched_direct,
                matched_fuzzy,
                max(0, len(channels) - len(resolved))
            ))
        except Exception:
            pass
    return resolved


def _epg_item_matches(a, b):
    if not a or not b:
        return False
    return (
        int(a.get('start') or 0) == int(b.get('start') or 0) and
        int(a.get('stop') or 0) == int(b.get('stop') or 0) and
        _to_text(a.get('title') or '') == _to_text(b.get('title') or '')
    )


def _build_list_epg_plot(entry):
    if not entry:
        return ''
    schedule = entry.get('schedule') or []
    current = entry.get('current') or {}
    now_ts = int(time.time())
    lines = []
    if schedule:
        lines.append('[COLOR gold]Grade do dia[/COLOR]')
        for item in schedule:
            stop_ts = int(item.get('stop') or 0)
            start_ts = int(item.get('start') or 0)
            if stop_ts and stop_ts <= now_ts:
                continue
            start_label = _format_brazil_time(start_ts) if start_ts else ''
            title = item.get('title', '') or 'Sem título'
            if current and _epg_item_matches(item, current):
                lines.append('[COLOR aquamarine][{}] {}[/COLOR]'.format(start_label, title) if start_label else '[COLOR aquamarine]{}[/COLOR]'.format(title))
            else:
                lines.append('[{}] {}'.format(start_label, title) if start_label else title)
    return '\n'.join([line for line in lines if line])


def describe_epg_entry(entry):
    if not entry:
        return {
            'label_suffix': '',
            'plot': '',
            'current_title': '',
            'next_title': '',
            'current_start': '',
            'next_start': '',
            'current_desc': '',
        }

    current = entry.get('current') or {}
    upcoming = entry.get('next') or {}
    label_suffix = current.get('title', '') if current.get('title') else ''
    current_start = _format_brazil_time(current.get('start')) if current.get('start') else ''
    next_start = _format_brazil_time(upcoming.get('start')) if upcoming.get('start') else ''
    current_desc = _to_text(current.get('desc') or '').strip()
    next_desc = _to_text(upcoming.get('desc') or '').strip()

    blocks = []
    if current.get('title'):
        if current_start:
            blocks.append('[COLOR aquamarine]Agora: [{}] {}[/COLOR]'.format(current_start, current.get('title', '')))
        else:
            blocks.append('[COLOR aquamarine]Agora: {}[/COLOR]'.format(current.get('title', '')))
        if current_desc:
            blocks.append('[COLOR white]{}[/COLOR]'.format(current_desc))

    if upcoming.get('title'):
        if blocks:
            blocks.append('')
        if next_start:
            blocks.append('[COLOR aquamarine]Próximo: [{}] {}[/COLOR]'.format(next_start, upcoming.get('title', '')))
        else:
            blocks.append('[COLOR aquamarine]Próximo: {}[/COLOR]'.format(upcoming.get('title', '')))
        if next_desc:
            blocks.append('[COLOR white]{}[/COLOR]'.format(next_desc))

    return {
        'label_suffix': label_suffix,
        'plot': '\n'.join(blocks),
        'current_title': current.get('title', ''),
        'next_title': upcoming.get('title', ''),
        'current_start': current_start,
        'next_start': next_start,
        'current_desc': current_desc,
        'next_desc': next_desc,
    }

def format_epg_entry(entry):
    meta = describe_epg_entry(entry)
    return meta.get('label_suffix', ''), _build_list_epg_plot(entry)


def convert_to_m3u8(url):
    if not url:
        return url

    if '|' in url:
        url = url.split('|', 1)[0]
    elif '%7C' in url:
        url = url.split('%7C', 1)[0]

    lowered = url.lower()
    if ('.m3u8' not in lowered and '/hl' not in lowered and url.count('/') > 4 and
            '.mp4' not in lowered and '.avi' not in lowered):
        try:
            from six.moves.urllib.parse import urlparse
            parsed = urlparse(url)
            host_part = '{}://{}'.format(parsed.scheme, parsed.netloc)
            remainder = url.split(host_part, 1)[1]
            url = host_part + '/live' + remainder
            filename = basename(url)
            if filename.lower().endswith('.ts'):
                url = url[:-3] + '.m3u8'
            else:
                url = url + '.m3u8'
        except Exception as exc:
            log('convert_to_m3u8 falhou para {}: {}'.format(url, exc), level=2)
    return url
