# -*- coding: utf-8 -*-
"""
ČSFD Toplist – pomocná knihovna pro Kodi plugin.

Funkce:
- build_urls(category)
- extract_items_from_html(...)
- prepare_all_with_progress(category, fetch_html=True, fetch_details=True, progress=None)
- get_items(category, fetch_html, fetch_details)
- clean_cache(toplist_html=True, details=True)
- diff_by_id(old_items, new_items)
- cache_stats()  <-- NOVÉ: robustní statistika cache (OS walk + VFS fallback)
- cache_sizes()  <-- NOVÉ: zkrácená verze (jen bytes), pro backwards kompatibilitu

Cache:
- Toplist HTML: csfd_filmy_*.html, csfd_serialy_*.html (profil doplňku)
- Detaily: details_cache/ (profil doplňku)
- TMDb: tmdb_cache/ (profil doplňku)  <-- NOVÉ
"""
import re
import time
import os
import json
import hashlib
from typing import List, Dict, Optional, Tuple, Callable

import xbmc
import xbmcaddon
import xbmcvfs

from resources.lib.utils.log_utils import get_logger

log = get_logger()

import requests  # ČSFD i TMDb (sjednoceno kvůli Anubis PoW cookies session)

ADDON = xbmcaddon.Addon()
ADDON_ID = ADDON.getAddonInfo('id')

BASE = "https://www.csfd.cz"
HEADERS = {
    "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                   "AppleWebKit/537.36 (KHTML, like Gecko) "
                   "Chrome/120.0.0.0 Safari/537.36"),
    "Accept-Language": "cs-CZ,cs;q=0.9,en;q=0.8",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
OFFSETS = [0, 100, 200]

# --- TMDb konst/URL (NOVÉ) ---
TMDB_API_URL = "https://api.themoviedb.org/3"
TMDB_IMG_BASE = "https://image.tmdb.org/t/p/w500"


# ------------------------------- util -----------------------------------------
def profile_path(*parts) -> str:
    root = xbmcvfs.translatePath(ADDON.getAddonInfo('profile'))
    if not root.endswith(('/', '\\')):
        root += '/'
    xbmcvfs.mkdirs(root)
    return root + '/'.join(parts)

def _write_text(path: str, text: str) -> None:
    f = xbmcvfs.File(path, 'w')
    try:
        f.write(bytearray(text, 'utf-8'))
    finally:
        f.close()

def _read_text(path: str) -> str:
    f = xbmcvfs.File(path, 'r')
    try:
        data = f.read()
    finally:
        f.close()
    if isinstance(data, bytes):
        return data.decode('utf-8', 'ignore')
    return data

# ---------------------------- Anubis PoW klient ------------------------------
# csfd.cz chrání stránky přes Anubis (https://anubis.techaro.lol) — SHA-256
# hashcash proof-of-work. Klient v prohlížeči vyřeší výzvu a dostane JWT cookie;
# my totéž uděláme v Pythonu a cookie persistujeme mezi běhy, aby PoW probíhalo
# typicky 1× za ~7 dní (TTL JWT).

_ANUBIS_MAX_ITERS = 1 << 28  # ~268M, v praxi se nikdy nevyužije
_ANUBIS_SESSION: Optional[requests.Session] = None

_anubis_challenge_re = re.compile(
    r'<script\s+id="anubis_challenge"[^>]*>\s*(\{[\s\S]*?\})\s*</script>',
    re.IGNORECASE,
)
_anubis_base_prefix_re = re.compile(
    r'<script\s+id="anubis_base_prefix"[^>]*>\s*("(?:[^"\\]|\\.)*")\s*</script>',
    re.IGNORECASE,
)


def _cookies_path() -> str:
    return profile_path('csfd_cookies.json')


def _anubis_load_cookies(session: requests.Session) -> None:
    path = _cookies_path()
    if not xbmcvfs.exists(path):
        return
    try:
        raw = _read_text(path)
        data = json.loads(raw) if raw else []
    except Exception as e:
        log.warning(f"[CSFD Anubis] Nelze nacist cookies cache: {e}")
        return
    now = time.time()
    for c in data or []:
        try:
            expires = c.get('expires')
            if expires and expires < now:
                continue
            session.cookies.set(
                c.get('name'),
                c.get('value'),
                domain=c.get('domain') or None,
                path=c.get('path') or '/',
                expires=expires,
                secure=bool(c.get('secure')),
            )
        except Exception:
            continue


def _anubis_save_cookies(session: requests.Session) -> None:
    try:
        out = []
        for c in session.cookies:
            out.append({
                'name': c.name,
                'value': c.value,
                'domain': c.domain,
                'path': c.path,
                'expires': c.expires,
                'secure': bool(c.secure),
            })
        _write_text(_cookies_path(), json.dumps(out, ensure_ascii=False))
    except Exception as e:
        log.warning(f"[CSFD Anubis] Nelze ulozit cookies cache: {e}")


def _anubis_session() -> requests.Session:
    global _ANUBIS_SESSION
    if _ANUBIS_SESSION is None:
        s = requests.Session()
        s.headers.update(HEADERS)
        _anubis_load_cookies(s)
        _ANUBIS_SESSION = s
    return _ANUBIS_SESSION


def _is_anubis_challenge(html: str) -> bool:
    if not html:
        return False
    if 'id="anubis_challenge"' in html:
        return True
    if 'Ujišťujeme se, že nejste robot' in html:
        return True
    return False


def _parse_anubis_challenge(html: str) -> Optional[dict]:
    """
    Vrátí dict: {
      'id': str, 'random_data': str, 'difficulty': int,
      'algorithm': str, 'base_prefix': str
    }
    nebo None, pokud parsování selže.
    """
    m = _anubis_challenge_re.search(html or '')
    if not m:
        return None
    try:
        payload = json.loads(m.group(1))
    except Exception as e:
        log.warning(f"[CSFD Anubis] JSON parse error: {e}")
        return None

    rules = payload.get('rules') or {}
    ch = payload.get('challenge') or {}
    random_data = ch.get('randomData') or ''
    ch_id = ch.get('id') or ''
    difficulty = rules.get('difficulty')
    try:
        difficulty = int(difficulty)
    except Exception:
        difficulty = 0
    algorithm = (rules.get('algorithm') or 'fast').lower()

    base_prefix = ''
    mp = _anubis_base_prefix_re.search(html or '')
    if mp:
        try:
            base_prefix = json.loads(mp.group(1)) or ''
        except Exception:
            base_prefix = ''

    if not random_data or not ch_id or difficulty <= 0:
        return None

    return {
        'id': ch_id,
        'random_data': random_data,
        'difficulty': difficulty,
        'algorithm': algorithm,
        'base_prefix': base_prefix,
    }


def _solve_anubis(challenge: dict) -> Tuple[str, int, float]:
    """
    SHA256(random_data + str(nonce)).hexdigest() musí začínat
    'difficulty' hex-nulami. Vrací (response_hex, nonce, elapsed_ms).
    """
    random_data = challenge['random_data']
    difficulty = int(challenge['difficulty'])
    prefix = '0' * difficulty
    rd_bytes = random_data.encode('ascii')

    t0 = time.time()
    nonce = 0
    max_iters = _ANUBIS_MAX_ITERS
    while nonce < max_iters:
        h = hashlib.sha256(rd_bytes + str(nonce).encode('ascii')).hexdigest()
        if h.startswith(prefix):
            elapsed_ms = (time.time() - t0) * 1000.0
            return h, nonce, elapsed_ms
        nonce += 1
    raise RuntimeError(f"Anubis PoW nenalezl nonce do {max_iters} iteraci (difficulty={difficulty})")


def _pass_anubis(session: requests.Session, target_url: str, html: str) -> bool:
    """
    Vyřeší PoW a zavolá pass-challenge endpoint, aby server nastavil JWT cookie.
    Vrací True při úspěchu.
    """
    ch = _parse_anubis_challenge(html)
    if not ch:
        log.warning("[CSFD Anubis] Challenge HTML bez parsovatelneho payloadu")
        return False

    log.info(f"[CSFD Anubis] Reseni PoW: algo={ch['algorithm']}, difficulty={ch['difficulty']}")
    try:
        response_hex, nonce, elapsed_ms = _solve_anubis(ch)
    except Exception as e:
        log.warning(f"[CSFD Anubis] Solve error: {e}")
        return False
    log.info(f"[CSFD Anubis] Hotovo: nonce={nonce}, {elapsed_ms:.1f}ms")

    base_prefix = ch.get('base_prefix') or ''
    pass_url = f"{BASE}{base_prefix}/.within.website/x/cmd/anubis/api/pass-challenge"
    params = {
        'response': response_hex,
        'nonce': nonce,
        'redir': target_url,
        'elapsedTime': f"{elapsed_ms:.0f}",
        'id': ch['id'],
    }
    try:
        r = session.get(pass_url, params=params, timeout=30, allow_redirects=True)
    except Exception as e:
        log.warning(f"[CSFD Anubis] pass-challenge HTTP error: {e}")
        return False

    if r.status_code >= 400:
        log.warning(f"[CSFD Anubis] pass-challenge HTTP {r.status_code}")
        return False

    _anubis_save_cookies(session)
    return True


def _http_get(url: str, headers: Dict[str, str]) -> str:
    """
    GET přes sdílenou requests.Session (cookies mezi běhy).
    Pokud server vrátí Anubis challenge, vyřeš PoW a zkus znovu.
    """
    session = _anubis_session()
    merged_headers = dict(session.headers)
    merged_headers.update(headers or {})

    for attempt in range(2):
        r = session.get(url, headers=merged_headers, timeout=30, allow_redirects=True)
        text = r.text or ''
        if not _is_anubis_challenge(text):
            if r.status_code >= 400:
                raise RuntimeError(f"HTTP {r.status_code} pro {url}")
            _anubis_save_cookies(session)
            return text

        log.info(f"[CSFD Anubis] Detekovana challenge ({url}), pokus {attempt + 1}/2")
        ok = _pass_anubis(session, url, text)
        if not ok:
            break

    raise RuntimeError(f"Anubis challenge nebyla vyresena pro {url}")


# --------------------------- URL a cache soubory ------------------------------
def build_urls(category: str) -> List[Tuple[str, str]]:
    if category == 'films':
        root = "https://www.csfd.cz/zebricky/filmy/nejlepsi/"
        prefix = "csfd_filmy"
    elif category == 'series':
        root = "https://www.csfd.cz/zebricky/serialy/nejlepsi/"
        prefix = "csfd_serialy"
    else:
        raise ValueError("category must be 'films' or 'series'")
    res = []
    for off in OFFSETS:
        url = root if off == 0 else f"{root}?from={off}"
        fname = f"{prefix}_{off}.html"
        res.append((url, profile_path(fname)))
    return res


# ------------------------------ Parsování HTML --------------------------------
title_href_re = re.compile(
    r'\<h3[^\>]*class="film-title[^"]*"\>.*?\<a[^\>]*href="([^"]+)"[^\>]*\>([^\<]+)\</a\>',
    re.S,
)
art_id_re = re.compile(r'\<article[^\>]*id="highlight\-(\d+)"', re.S)
href_id_re = re.compile(r"/film/(\d+)\-")
rank_re = re.compile(r'\<span[^\>]*class="film-title-user"[^\>]*\>\s*(\d+)\.', re.S)
year_re = re.compile(
    r'\<span[^\>]*class="film-title-info"[^\>]*\>.*?\<span[^\>]*class="info"[^\>]*\>\((\d{4})\)\</span\>',
    re.S,
)
rating_re = re.compile(r'\<div[^\>]*class="rating-average [^"]*"[^\>]*\>(\d{1,2},\d)%\</div\>')
votes_re = re.compile(r'\<div[^\>]*class="rating-total"[^\>]*\>\s*([\s\S]*?)\<span\>', re.S)
origins_genres_re = re.compile(
    r'\<p[^\>]*class="film-origins-genres"[^\>]*\>\s*\<span[^\>]*class="info"[^\>]*\>([\s\S]*?)\</span\>\s*\</p\>',
    re.S,
)
info_country_re = re.compile(r'\<span[^\>]*class="info-country"[^\>]*\>([^\<]+)\</span\>')
tag_re = re.compile(r"\<[^>]+\>")
creators_line_re = re.compile(
    r'\<p[^\>]*class="film-creators"[^\>]*\>\s*(?:Režie|Tvůrci)\s*:\s*([\s\S]*?)\</p\>',
    re.S | re.IGNORECASE,
)
a_text_re = re.compile(r'\<a[^\>]*\>([^\<]+)\</a\>')

def extract_items_from_html(html: str, item_type: str) -> List[Dict]:
    """
    Vrátí list dictů: type, film_id, rank, title, year, countries[], genres[],
    directors[], rating_str, rating_percent, votes, runtime_min=None, _detail_href.
    """
    blocks, pos = [], 0
    while True:
        s = html.find("<article", pos)
        if s == -1:
            break
        e = html.find("</article>", s)
        if e == -1:
            break
        block = html[s: e + len("</article>")]
        if "article-content-toplist" in block:
            blocks.append(block)
        pos = e + len("</article>")

    items = []
    for b in blocks:
        m_rank = rank_re.search(b)
        if not m_rank:
            continue
        rank = int(m_rank.group(1))

        film_id = None
        m_art = art_id_re.search(b)
        if m_art:
            film_id = int(m_art.group(1))

        m_th = title_href_re.search(b)
        if m_th:
            detail_href = m_th.group(1).strip()
            title = m_th.group(2).strip()
            if film_id is None:
                m_hid = href_id_re.search(detail_href)
                if m_hid:
                    film_id = int(m_hid.group(1))
        else:
            m_t = re.search(r'\<h3[^\>]*class="film-title[^"]*"\>.*?\<a [^\>]*\>([^\<]+)\</a\>', b, re.S)
            if not m_t:
                continue
            title = m_t.group(1).strip()
            detail_href = None

        m_year = year_re.search(b)
        year = int(m_year.group(1)) if m_year else None

        m_rate = rating_re.search(b)
        if not m_rate:
            continue
        rating_str = m_rate.group(1) + "%"
        rating_percent = float(m_rate.group(1).replace(",", "."))

        m_votes = votes_re.search(b)
        votes = None
        if m_votes:
            digits = "".join(ch for ch in m_votes.group(1) if ch.isdigit())
            votes = int(digits) if digits else None

        countries, genres = [], []
        m_og = origins_genres_re.search(b)
        if m_og:
            raw = m_og.group(1)
            countries = [c.strip() for c in info_country_re.findall(raw) if c.strip()]
            text_full = tag_re.sub("", raw).strip()
            if not countries and "," in text_full:
                country_part = text_full.split(",", 1)[0]
                countries = [p.strip() for p in country_part.split("/") if p.strip()]
            if "," in text_full:
                _, genre_part = text_full.split(",", 1)
                parts = [g.strip() for token in genre_part.split("/") for g in token.split(",")]
                genres = [g for g in (p.strip() for p in parts) if g]

        directors = []
        m_cre = creators_line_re.search(b)
        if m_cre:
            directors = [m.group(1).strip() for m in a_text_re.finditer(m_cre.group(1)) if m.group(1).strip()]

        items.append({
            "type": item_type,
            "film_id": film_id,
            "rank": rank,
            "title": title,
            "year": year,
            "countries": countries,
            "genres": genres,
            "directors": directors,
            "rating_str": rating_str,
            "rating_percent": rating_percent,
            "votes": votes,
            "runtime_min": None,
            "_detail_href": detail_href
        })

    items.sort(key=lambda x: x["rank"])
    return items


# ---------------- Detaily (runtime) + cache (ČSFD) ---------------------------
film_id_from_href = re.compile(r"/film/(\d+)\-")
minutes_re = re.compile(r"\((\d+)\)\s*(?:min\.?|minut(?:a|y)?|minuty?)", re.I)

def _detail_cache_dir() -> str:
    d = profile_path("details_cache")
    xbmcvfs.mkdirs(d)
    return d

def _detail_cache_name(href: str) -> str:
    mid = None
    m = film_id_from_href.search(href or "")
    if m:
        mid = m.group(1)
    safe = (href or "").strip("/").replace("/", "_").replace("?", "_").replace("&", "_")
    return f"csfd_film_{mid}.html" if mid else f"csfd_detail_{safe or 'unknown'}.html"

def fetch_or_read_detail(detail_href: Optional[str], fetch: bool) -> Optional[str]:
    if not detail_href:
        return None
    cache_dir = _detail_cache_dir()
    cache_path = cache_dir + "/" + _detail_cache_name(detail_href)
    if xbmcvfs.exists(cache_path):
        cached = _read_text(cache_path)
        if _is_anubis_challenge(cached):
            log.info(f"[CSFD Anubis] Detail cache otravena, mazu: {cache_path}")
            try:
                xbmcvfs.delete(cache_path)
            except Exception:
                pass
        else:
            return cached
    if not fetch:
        return None
    html = _http_get(BASE + detail_href, HEADERS)
    _write_text(cache_path, html)
    time.sleep(0.6)
    return html

def extract_runtime_from_detail(detail_html: Optional[str]) -> Optional[int]:
    if not detail_html:
        return None
    m = minutes_re.search(detail_html)
    if m:
        try:
            return int(m.group(1))
        except ValueError:
            return None
    return None


# ----------------------------- Orchestrace ------------------------------------
def merge_items(list_of_lists: List[List[Dict]]) -> List[Dict]:
    by_rank = {}
    for items in list_of_lists:
        for it in items:
            r = it["rank"]
            if r not in by_rank:
                by_rank[r] = it
    return [by_rank[k] for k in sorted(by_rank.keys())]

def get_items(category: str, fetch_html: bool, fetch_details: bool) -> List[Dict]:
    """
    Jednoduchá orchetrace bez progress baru (ponecháno).
    """
    url_defs = build_urls(category)
    html_pages = []
    for url, path in url_defs:
        need_fetch = fetch_html or not xbmcvfs.exists(path)
        html = None
        if not need_fetch:
            html = _read_text(path)
            if _is_anubis_challenge(html):
                log.info(f"[CSFD Anubis] Toplist cache otravena, mazu: {path}")
                try:
                    xbmcvfs.delete(path)
                except Exception:
                    pass
                html = None
                need_fetch = True
        if need_fetch:
            html = _http_get(url, HEADERS)
            _write_text(path, html)
            time.sleep(0.8)
        html_pages.append(html)

    item_type = 'series' if category == 'series' else 'film'
    lists = [extract_items_from_html(h, item_type) for h in html_pages]
    merged = merge_items(lists)

    if fetch_details or xbmcvfs.exists(_detail_cache_dir()):
        for it in merged:
            href = it.get("_detail_href")
            detail_html = fetch_or_read_detail(href, fetch=fetch_details)
            runtime = extract_runtime_from_detail(detail_html) if detail_html else None
            if runtime is not None:
                it["runtime_min"] = runtime

    for it in merged:
        it.pop("_detail_href", None)
    return merged


# ---------- TMDb cache + enrichment (NOVÉ) ------------------------------------
def _tmdb_cache_dir() -> str:
    d = profile_path("tmdb_cache")
    xbmcvfs.mkdirs(d)
    return d

def _tmdb_map_path() -> str:
    return _tmdb_cache_dir() + "/map.json"

def _tmdb_detail_path(kind: str, tmdb_id: int, lang: str) -> str:
    # kind: 'movie' | 'tv'
    return _tmdb_cache_dir() + f"/detail_{kind}_{tmdb_id}_{lang}.json"

def _tmdb_load_json(path: str) -> Optional[dict]:
    if not xbmcvfs.exists(path):
        return None
    f = xbmcvfs.File(path, 'r')
    try:
        raw = f.read()
    finally:
        f.close()
    if isinstance(raw, bytes):
        raw = raw.decode('utf-8', 'ignore')
    try:
        return json.loads(raw)
    except Exception:
        return None

def _tmdb_save_json(path: str, data: dict) -> None:
    f = xbmcvfs.File(path, 'w')
    try:
        s = json.dumps(data, ensure_ascii=False)
        f.write(bytearray(s, 'utf-8'))
    finally:
        f.close()

def _tmdb_map_load() -> dict:
    data = _tmdb_load_json(_tmdb_map_path())
    return data or {}

def _tmdb_map_save(data: dict) -> None:
    _tmdb_save_json(_tmdb_map_path(), data)

def _tmdb_api_key() -> Optional[str]:
    k = ADDON.getSetting('tmdb_api_key') or ''
    return k.strip() or None

def _tmdb_search(title: str, year: Optional[int], is_tv: bool) -> Optional[int]:
    """
    Vrátí tmdb_id pro název/rok (využívá map cache).
    """
    api_key = _tmdb_api_key()
    if not api_key or not title:
        return None

    key_title = (title or '').strip().casefold()
    key_year = str(year or '')
    map_key = f"{'tv' if is_tv else 'movie'}|{key_title}|{key_year}"

    m = _tmdb_map_load()
    if map_key in m:
        try:
            return int(m[map_key])
        except Exception:
            pass

    endpoint = f"{TMDB_API_URL}/search/{'tv' if is_tv else 'movie'}"
    params = {
        'api_key': api_key,
        'query': title,
        'include_adult': 'false',
        'language': 'cs-CZ',
    }
    if year:
        params['first_air_date_year' if is_tv else 'year'] = int(year)

    try:
        r = requests.get(endpoint, params=params, timeout=10)
        r.raise_for_status()
        results = (r.json() or {}).get('results') or []
        if not results and year:
            params.pop('first_air_date_year', None)
            params.pop('year', None)
            r2 = requests.get(endpoint, params=params, timeout=10)
            r2.raise_for_status()
            results = (r2.json() or {}).get('results') or []
        if results:
            tmdb_id = int(results[0].get('id'))
            m[map_key] = tmdb_id
            _tmdb_map_save(m)
            return tmdb_id
    except Exception as e:
        log.warning(f"TMDb search error: {e}")
    return None

def _tmdb_detail(tmdb_id: int, is_tv: bool, lang: str) -> Optional[dict]:
    api_key = _tmdb_api_key()
    if not api_key or not tmdb_id:
        return None

    path = _tmdb_detail_path('tv' if is_tv else 'movie', tmdb_id, lang)
    cached = _tmdb_load_json(path)
    if cached:
        return cached

    endpoint = f"{TMDB_API_URL}/{('tv' if is_tv else 'movie')}/{tmdb_id}"
    params = {'api_key': api_key, 'language': lang}
    try:
        r = requests.get(endpoint, params=params, timeout=10)
        r.raise_for_status()
        data = r.json() or {}
        _tmdb_save_json(path, data)
        return data
    except Exception as e:
        log.warning(f"TMDb detail error: {e}")
    return None

def tmdb_enrich_item(it: dict, is_tv: bool) -> dict:
    """
    Dle 'title' a 'year' dohledá tmdb_id a stáhne detail (CZ -> EN fallback).
    Do položky doplní: 'tmdb_id', 'poster_path', 'plot', volitelně 'vote_avg', 'vote_count'.
    """
    title = it.get('title') or ''
    year = it.get('year')
    if not title:
        return it

    tmdb_id = _tmdb_search(title, year, is_tv)
    if not tmdb_id:
        return it

    it['tmdb_id'] = tmdb_id

    # CZ priorita → EN fallback
    d_cz = _tmdb_detail(tmdb_id, is_tv, 'cs-CZ') or {}
    d_en = None

    def _pick(field_name: str) -> Optional[str]:
        val = d_cz.get(field_name)
        if not val:
            nonlocal d_en
            d_en = d_en or (_tmdb_detail(tmdb_id, is_tv, 'en-US') or {})
            val = d_en.get(field_name)
        return val

    poster = _pick('poster_path')
    overview = _pick('overview')
    name_key = 'name' if is_tv else 'title'
    localized_title = d_cz.get(name_key) or d_cz.get('title') or d_cz.get('name')

    if poster:
        it['poster_path'] = poster
    if overview:
        it['plot'] = overview
    if localized_title:
        it['title'] = localized_title

    vote_avg = d_cz.get('vote_average') or (d_en.get('vote_average') if d_en else None)
    vote_cnt = d_cz.get('vote_count')   or (d_en.get('vote_count') if d_en else None)
    if vote_avg is not None:
        try:
            it['vote_avg'] = float(vote_avg)
        except Exception:
            pass
    if vote_cnt is not None:
        try:
            it['vote_count'] = int(vote_cnt)
        except Exception:
            pass

    return it


def prepare_all_with_progress(category: str,
                              fetch_html: bool = True,
                              fetch_details: bool = True,
                              progress: Optional[Callable[[int, str], None]] = None) -> List[Dict]:
    """
    Kompletní příprava s průběhem:
    0–20%: stahování toplist HTML (0/100/200)
    20–25%: parsování + sloučení
    25–100%: detaily (runtime) + TMDb enrich (CZ→EN)
    """
    def p(pct: int, msg: str):
        if progress:
            try:
                progress(pct, msg)
            except Exception:
                pass

    urls = build_urls(category)
    html_pages: List[str] = []
    for i, (url, path) in enumerate(urls, start=1):
        p(min(5 + int(15 * (i-1) / max(1, len(urls))), 20), f"Stahuji toplist {i}/{len(urls)}…")
        need_fetch = fetch_html or not xbmcvfs.exists(path)
        html = None
        if not need_fetch:
            html = _read_text(path)
            if _is_anubis_challenge(html):
                log.info(f"[CSFD Anubis] Toplist cache otravena, mazu: {path}")
                try:
                    xbmcvfs.delete(path)
                except Exception:
                    pass
                html = None
                need_fetch = True
        if need_fetch:
            p(min(5 + int(15 * (i-1) / max(1, len(urls))), 20), f"ČSFD: ověřuji přístup (Anubis)…")
            html = _http_get(url, HEADERS)
            _write_text(path, html)
            time.sleep(0.8)
        html_pages.append(html)

    item_type = 'series' if category == 'series' else 'film'
    p(22, "Parsování…")
    lists = [extract_items_from_html(h, item_type) for h in html_pages]
    merged = merge_items(lists)
    p(25, f"Nalezeno položek: {len(merged)}")

    if (fetch_details or xbmcvfs.exists(_detail_cache_dir())) and merged:
        total = len(merged)
        is_tv = (category == 'series')
        for idx, it in enumerate(merged, start=1):
            pct = 25 + int(75 * idx / max(1, total))

            # 1) runtime z ČSFD detailu
            p(pct, f"Detaily {idx}/{total} – {it.get('title')}")
            href = it.get("_detail_href")
            detail_html = fetch_or_read_detail(href, fetch=fetch_details)
            runtime = extract_runtime_from_detail(detail_html) if detail_html else None
            if runtime is not None:
                it["runtime_min"] = runtime

            # 2) TMDb obohacení, pokud je k dispozici API key
            if _tmdb_api_key():
                p(pct, f"Detaily+TMDb {idx}/{total} – {it.get('title')}")
                try:
                    tmdb_enrich_item(it, is_tv=is_tv)
                except Exception as e:
                    log.warning(f"TMDb enrich error: {e}")
    else:
        p(100, "Hotovo (detaily přeskočeny).")

    for it in merged:
        it.pop("_detail_href", None)
    p(100, "Dokončeno.")
    return merged


# --------------------------- Diff mezi snapshoty ------------------------------
def _key_from_item(it: Dict) -> str:
    """
    Klíč pro porovnávání: preferuje film_id, fallback title+year.
    """
    fid = it.get("film_id")
    if fid is not None:
        return f"id:{fid}"
    return f"title:{(it.get('title') or '').strip().lower()}\nyear:{it.get('year') or ''}"

def diff_by_id(old_items: List[Dict], new_items: List[Dict]) -> Dict[str, int]:
    """
    Vrátí počty přidaných a odebraných položek.
    """
    old_keys = { _key_from_item(x) for x in (old_items or []) }
    new_keys = { _key_from_item(x) for x in (new_items or []) }
    added = len(new_keys - old_keys)
    removed = len(old_keys - new_keys)
    return {"added": added, "removed": removed}


# ------------------------------ Mazání cache ----------------------------------
def _list_profile_files() -> Tuple[list, list]:
    """
    Vrátí (dirs, files) v profilové složce doplňku (bez rekurze).
    """
    prof = xbmcvfs.translatePath(ADDON.getAddonInfo('profile'))
    if not prof.endswith(('/', '\\')):
        prof += '/'
    xbmcvfs.mkdirs(prof)
    dirs, files = xbmcvfs.listdir(prof)
    files = [prof + f for f in files]
    dirs = [prof + d for d in dirs]
    return dirs, files

def _rmtree(path: str) -> int:
    """
    Rekurzivně smaže složku; vrací počet smazaných souborů/složek.
    """
    if not xbmcvfs.exists(path):
        return 0
    count = 0
    dirs, files = xbmcvfs.listdir(path)
    for f in files:
        full = path.rstrip('/\\') + '/' + f
        if xbmcvfs.delete(full):
            count += 1
    for d in dirs:
        full = path.rstrip('/\\') + '/' + d
        count += _rmtree(full)
    if xbmcvfs.rmdir(path):
        count += 1
    return count

def clean_cache(toplist_html: bool = True, details: bool = True,
                cookies: bool = False) -> Dict[str, int]:
    """
    Smaže cache v profilu doplňku:
    - toplist_html: soubory csfd_filmy_*.html, csfd_serialy_*.html
    - details: složka details_cache/ (rekurzivně) + tmdb_cache/
    - cookies: soubor csfd_cookies.json (Anubis JWT session) + reset in-memory session
    """
    global _ANUBIS_SESSION
    deleted_toplist = 0
    deleted_details = 0
    deleted_cookies = 0

    if toplist_html:
        _, files = _list_profile_files()
        for f in files:
            name = f.rsplit('/', 1)[-1]
            if (name.startswith('csfd_filmy_') or name.startswith('csfd_serialy_')) and name.endswith('.html'):
                if xbmcvfs.delete(f):
                    deleted_toplist += 1

    if details:
        dcache = profile_path('details_cache')
        if xbmcvfs.exists(dcache):
            deleted_details = _rmtree(dcache)
        tcache = profile_path('tmdb_cache')
        if xbmcvfs.exists(tcache):
            _rmtree(tcache)

    if cookies:
        cpath = _cookies_path()
        if xbmcvfs.exists(cpath):
            if xbmcvfs.delete(cpath):
                deleted_cookies += 1
        _ANUBIS_SESSION = None

    return {
        'toplist_deleted': deleted_toplist,
        'details_deleted': deleted_details,
        'cookies_deleted': deleted_cookies,
    }


# ----------------------- Robustní statistika cache (NOVÉ) ---------------------
def _profile_root() -> str:
    prof = xbmcvfs.translatePath(ADDON.getAddonInfo('profile'))
    if not prof.endswith(('/', '\\')):
        prof += '/'
    xbmcvfs.mkdirs(prof)
    return prof

def _safe_join(base: str, name: str) -> str:
    return (base.rstrip('/\\') + '/' + name).replace('\\', '/')

def _file_size_vfs(path: str) -> int:
    """Záložní způsob přes VFS (na některých platformách může vracet 0)."""
    if not xbmcvfs.exists(path):
        return 0
    f = None
    try:
        f = xbmcvfs.File(path)  # 'r' default
        sz = f.size()
        return int(sz) if sz is not None else 0
    except Exception:
        return 0
    finally:
        try:
            if f: f.close()
        except Exception:
            pass

def _dir_size_vfs(path: str) -> Tuple[int, int]:
    """Rekurze přes xbmcvfs (fallback). Vrací (bytes, files_count)."""
    if not xbmcvfs.exists(path):
        return 0, 0
    total = 0
    count = 0
    try:
        dirs, files = xbmcvfs.listdir(path)
        for fname in files:
            full = _safe_join(path, fname)
            total += _file_size_vfs(full)
            count += 1
        for dname in dirs:
            full = _safe_join(path, dname)
            b, c = _dir_size_vfs(full)
            total += b
            count += c
    except Exception:
        pass
    return total, count

def _dir_size_os(path: str) -> Tuple[int, int]:
    """
    Primární způsob: rekurze přes os.walk nad REAL cestou (po translatePath).
    Vrací (bytes, files_count).
    """
    try:
        if not os.path.isdir(path):
            return 0, 0
        total = 0
        count = 0
        for root, _dirs, files in os.walk(path):
            for fn in files:
                fp = os.path.join(root, fn)
                try:
                    total += os.path.getsize(fp)
                    count += 1
                except OSError:
                    pass
        return total, count
    except Exception:
        return 0, 0

def _files_size_os(dir_path: str, name_predicate) -> Tuple[int, int]:
    """
    Sečte velikosti souborů v JEDNÉ složce (ne rekurzivně) přes OS API.
    name_predicate: funkce(name)->bool pro výběr souborů.
    Vrací (bytes, files_count).
    """
    try:
        if not os.path.isdir(dir_path):
            return 0, 0
        total = 0
        count = 0
        for name in os.listdir(dir_path):
            if not name_predicate(name):
                continue
            fp = os.path.join(dir_path, name)
            if os.path.isfile(fp):
                try:
                    total += os.path.getsize(fp)
                    count += 1
                except OSError:
                    pass
        return total, count
    except Exception:
        return 0, 0

def cache_stats() -> Dict[str, int]:
    """
    Vrátí statistiky cache:
    - 'toplist_bytes': součet csfd_filmy_*.html + csfd_serialy_*.html (jen v kořeni profilu)
    - 'toplist_files': počet těchto HTML souborů
    - 'details_bytes': rekurzivní velikost složky details_cache/
    - 'details_files': počet souborů v details_cache
    - 'total_bytes': součet obou
    - 'profile_root': absolutní real path profilu (pro debug)
    - 'details_dir': absolutní real path details_cache

    Používá OS metody jako primární (spolehlivé), VFS jen jako fallback.
    """
    prof = _profile_root()  # translatePath -> real path
    prof_real = prof

    # toplist HTML v kořeni profilu (nerekurzivně)
    def is_toplist_html(name: str) -> bool:
        return (name.startswith('csfd_filmy_') or name.startswith('csfd_serialy_')) and name.endswith('.html')

    toplist_bytes, toplist_files = _files_size_os(prof_real, is_toplist_html)
    if toplist_files == 0 and toplist_bytes == 0:
        # fallback přes VFS
        try:
            dirs, files = xbmcvfs.listdir(prof)
            for fname in files:
                if is_toplist_html(fname):
                    full = _safe_join(prof, fname)
                    toplist_bytes += _file_size_vfs(full)
                    toplist_files += 1
        except Exception:
            pass

    # details_cache rekurzivně
    details_dir = profile_path('details_cache')  # real path
    details_bytes, details_files = _dir_size_os(details_dir)
    if details_files == 0 and details_bytes == 0:
        # fallback přes VFS
        details_bytes, details_files = _dir_size_vfs(details_dir)

    return {
        'toplist_bytes': int(toplist_bytes),
        'toplist_files': int(toplist_files),
        'details_bytes': int(details_bytes),
        'details_files': int(details_files),
        'total_bytes': int(toplist_bytes + details_bytes),
        'profile_root': prof_real,
        'details_dir': details_dir,
    }

def cache_sizes() -> Dict[str, int]:
    s = cache_stats()
    return {
        'toplist_bytes': s['toplist_bytes'],
        'details_bytes': s['details_bytes'],
        'total_bytes': s['total_bytes'],
    }