# coding: utf-8
"""
Background service for plugin.video.glotzbox


Goals:
- Periodically check remote JSONs (movies, series, favorites) on HTTPS.
- Use conditional GET (ETag / Last-Modified).
- Only trigger refresh when something really changed.
- Stay robust: never crash Kodi, never require a valid addon_handle.
- NEW: Orphan Artwork Cache Cleanup on start + max 1x/24h

Notes:
- Auth happens via common.get_authenticated_request().
- Service usually has NO addon_handle (sys.argv has no handle) -> handle stays -1.
"""
from typing import Optional, Dict, Any, Tuple, List

import os
import sys
import json
import time
import ssl
import hashlib
import traceback
import urllib.request
import urllib.error
import urllib.parse
import re
import threading

import xbmc
import xbmcaddon
import xbmcgui
import xbmcvfs

import common

# ---------------------------------------------------------------------------
# Prefetch throttles
#
# Kodi starts the service on every boot. To avoid hammering the server we
# throttle the *details.json* refresh loop. You asked to keep it aggressive,
# so we default to 1 hour.
# ---------------------------------------------------------------------------

# How often the service is allowed to run the series-details prefetch when the
# series list itself is unchanged.
SERIES_PREFETCH_INTERVAL_HOURS = 1  # hours

# Where we store the per-series details.json cache (one file per series) and
# the meta information used for conditional requests (ETag/Last-Modified).
# NOTE: Must match series.py (v18+) naming.
SERIES_DETAILS_CACHE_DIR = os.path.join(common.addon_data_dir, "cache_series_details_json")
SERIES_DETAILS_META_DIR  = os.path.join(common.addon_data_dir, "cache_series_details_json_meta")

# State file for throttling + book-keeping (per installation)
SERIES_PREFETCH_STATE_FILE = os.path.join(common.addon_data_dir, "series_details_prefetch_state.json")

# Max number of details.json files to fetch per run (you asked to fetch all; 200 covers all typical lists)
SERIES_PREFETCH_LIMIT_PER_RUN = 200

# Max artwork URLs to prefetch per run (poster/fanart/stills)
SERIES_ARTWORK_PREFETCH_LIMIT = 400


# Window property to prevent multiple concurrent prefetch runs
SERIES_PREFETCH_PROP = f"{common.addon_id}.series.details.prefetch.running"

# When the service updates details.json on disk, bump this timestamp so series.py can drop stale in-memory caches.
DETAILS_BUMP_PROP = f"{common.addon_id}.series.details.bump"

LOGP = f"[{common.addon_id} service.py]"
SERVICE_BUILD = 'details-prefetch-v2'


def log(msg, lvl=xbmc.LOGINFO):
    try:
        xbmc.log(f"{LOGP} {msg}", lvl)
    except Exception:
        pass


# ---------------------------------------------------------------------
# Addon handle safe getter (Services haben normalerweise KEIN Handle)
# ---------------------------------------------------------------------
def get_addon_handle_safe(default=-1):
    argv = getattr(sys, "argv", []) or []
    if len(argv) > 1:
        try:
            h = int(argv[1])
            if h >= 0:
                return h
        except Exception:
            pass
    return default


# ---------------------------------------------------------------------
# Settings helpers
# ---------------------------------------------------------------------
def _get_setting_str(key, default=""):
    try:
        val = common.addon.getSetting(key)
        return val if isinstance(val, str) else default
    except Exception:
        return default


def _get_setting_int(key, default=0):
    try:
        raw = common.addon.getSetting(key)
        return int(raw)
    except Exception:
        return default


def _get_interval_seconds(default=3600):
    for k in ("service_interval", "bg_interval", "background_refresh_interval"):
        v = _get_setting_int(k, 0)
        if v and v > 0:
            return v
    return default


NET_TIMEOUT = max(5, _get_setting_int("net_timeout", 20))


# ---------------------------------------------------------------------
# SSL + urlopen wrapper
# ---------------------------------------------------------------------
try:
    _SSL_CTX = ssl.create_default_context()
except Exception:
    _SSL_CTX = None



# Startup refresh behavior
# Always run HEAD-checks for Series + Movies on startup and on interval ticks.
STARTUP_DO_SERIES = True
STARTUP_DO_MOVIES = True

def _robust_urlopen(req, timeout=NET_TIMEOUT):
    if _SSL_CTX:
        return urllib.request.urlopen(req, context=_SSL_CTX, timeout=timeout)
    return urllib.request.urlopen(req, timeout=timeout)


# ---------------------------------------------------------------------
# Only refresh UI when our addon container is visible
# ---------------------------------------------------------------------

def _refresh_if_addon_visible():
    """Refresh nur dann auslösen, wenn wir im Glotzbox-Container sind.

    Wichtig:
    - Keine Refreshes während Fullscreen/Playback.
    - Refresh nur im Root bzw. in den Top-Level-Listen (Filme/Serien/Favoriten),
      damit Navigation (Staffeln/Episoden) nicht „zurückspringt“.
    """
    try:
        try:
            if xbmc.getCondVisibility("Window.IsVisible(fullscreenvideo)"):
                return
        except Exception:
            pass
        try:
            if xbmc.getCondVisibility("Player.HasMedia"):
                return
        except Exception:
            pass

        path = xbmc.getInfoLabel("Container.FolderPath") or ""
        p = path.lower()
        base = f"plugin://{common.addon_id}".lower()
        if not p.startswith(base):
            return

        # Root (Hauptmenü)
        if p.rstrip("/") in (base, base + "/"):
            xbmc.executebuiltin("Container.Refresh")
            return

        # Nur Top-Level Listen refreshen
        allow = ("action=show_serien", "action=show_filme", "action=show_favorites")
        suppress = (
            "action=show_seasons",
            "action=show_episodes",
            "action=show_fav_seasons",
            "action=show_fav_episodes",
            "action=play_",
        )

        if any(a in p for a in allow) and not any(s in p for s in suppress):
            xbmc.executebuiltin("Container.Refresh")
        else:
            log(f"UI Refresh suppressed (not top-level): {path}", xbmc.LOGDEBUG)
    except Exception:
        pass


# ---------------------------------------------------------------------
# Remote host/path normalization
# ---------------------------------------------------------------------
def _normalize_host_and_basepath():
    host_raw = _get_setting_str("ftp_host", "").strip()
    base_path_setting = _get_setting_str("ftp_base_path", "").strip()

    scheme = "https"
    host_clean = host_raw

    if host_raw.startswith(("http://", "https://")):
        scheme = host_raw.split("://", 1)[0].lower()
        host_clean = host_raw.split("://", 1)[1]

    if "@" in host_clean:
        host_clean = host_clean.split("@", 1)[-1]

    host_clean = (
        host_clean
        .split("/")[0]
        .split("?")[0]
        .split("#")[0]
        .rstrip(":/ ")
    )

    base_path = "/user/downloads/kodiAddon/"
    try:
        if base_path_setting:
            temp_path = base_path_setting.strip()
            if temp_path and temp_path != "/":
                base_path = temp_path.rstrip("/") + "/"
    except Exception:
        pass

    if not base_path.startswith("/"):
        base_path = "/" + base_path

    return scheme, host_clean, base_path


# ---------------------------------------------------------------------
# Meta I/O
# ---------------------------------------------------------------------
def _meta_path(filename):
    return os.path.join(common.addon_data_dir, f"{filename}.meta.json")


def _load_meta(path):
    try:
        if not xbmcvfs.exists(path):
            return {}
        with xbmcvfs.File(path, "r") as fh:
            raw = fh.read()
        j = json.loads(raw) if raw else {}
        return j if isinstance(j, dict) else {}
    except Exception:
        return {}


def _save_meta(path, headers=None, size_bytes=None, sha1=None):
    try:
        meta = _load_meta(path)
        headers = headers or {}

        # Content-Length ist optional, aber hilfreich für schnelle HEAD-Checks.
        cl = None
        try:
            cl_raw = headers.get("Content-Length") or headers.get("content-length")
            if cl_raw is not None:
                cl_s = str(cl_raw).strip()
                if cl_s.isdigit():
                    cl = int(cl_s)
        except Exception:
            cl = None

        meta.update({
            "etag": headers.get("ETag", meta.get("etag", "")) or "",
            "last_modified": headers.get("Last-Modified", meta.get("last_modified", "")) or "",
            "content_length": int(cl) if isinstance(cl, int) and cl > 0 else int(meta.get("content_length", 0) or 0),
            "size": int(size_bytes if size_bytes is not None else meta.get("size", 0) or 0),
            "sha1": sha1 if sha1 is not None else (meta.get("sha1", "") or ""),
            "fetched": int(time.time())
        })

        # Falls wir size haben, aber content_length noch 0 ist -> sinnvoll befuellen.
        try:
            if not meta.get("content_length") and meta.get("size"):
                meta["content_length"] = int(meta.get("size") or 0)
        except Exception:
            pass

        with xbmcvfs.File(path, "w") as fh:
            fh.write(json.dumps(meta, ensure_ascii=False))
        return True
    except Exception:
        return False


# ---------------------------------------------------------------------
# Conditional fetch
# ---------------------------------------------------------------------
def _conditional_fetch(url, meta_path, use_conditional=True):
    """
    Returns tuple: (status, data_bytes, headers)
      status: "not_modified" | "changed" | "error"
    """
    meta = _load_meta(meta_path)
    req = common.get_authenticated_request(url)
    if not req:
        return "error", None, None

    # KodiNerds/NGINX setups sometimes keep ETag/Last-Modified stable even if the
    # underlying JSON changes (e.g. regenerated file with same validators).
    # For critical payloads (movies), we can disable conditional headers and rely
    # purely on SHA1 comparison to detect updates.
    if use_conditional:
        # Optional conditional headers.
        # Some servers/proxies can return 304 even when the underlying JSON changed.
        # For such cases (e.g. movies.json on some setups), we allow disabling
        # conditional headers and rely on a lightweight sha1 comparison instead.
        if use_conditional:
            if meta.get("etag"):
                try:
                    req.add_header("If-None-Match", meta["etag"])
                except Exception:
                    pass
            if meta.get("last_modified"):
                try:
                    req.add_header("If-Modified-Since", meta["last_modified"])
                except Exception:
                    pass

    try:
        with _robust_urlopen(req, timeout=NET_TIMEOUT) as resp:
            code = resp.getcode()

            if code == 200:
                data_bytes = resp.read() or b""
                body_sha1 = hashlib.sha1(data_bytes).hexdigest()

                if meta.get("sha1") and meta.get("sha1") == body_sha1:
                    _save_meta(meta_path, headers=resp.headers, size_bytes=len(data_bytes), sha1=body_sha1)
                    return "not_modified", None, resp.headers

                _save_meta(meta_path, headers=resp.headers, size_bytes=len(data_bytes), sha1=body_sha1)
                return "changed", data_bytes, resp.headers

            log(f"Conditional fetch unexpected code {code} for {url}", xbmc.LOGDEBUG)
            return "error", None, getattr(resp, "headers", None)

    except urllib.error.HTTPError as e:
        if e.code == 304:
            _save_meta(meta_path, headers=e.headers, size_bytes=meta.get("size", 0), sha1=meta.get("sha1", ""))
            return "not_modified", None, e.headers
        log(f"Conditional fetch HTTPError for {url}: {e}", xbmc.LOGDEBUG)
        return "error", None, getattr(e, "headers", None)
    except Exception as e:
        log(f"Conditional fetch error for {url}: {e}", xbmc.LOGDEBUG)
        return "error", None, None


# ---------------------------------------------------------------------
# Cache JSON write helper
# ---------------------------------------------------------------------

def _head_not_modified(url: str, meta_path: str, timeout: int = 8) -> Optional[bool]:
    """Lightweight HEAD probe with If-None-Match / If-Modified-Since.

    Returns:
      - True  -> remote NOT modified (safe to skip refresh)
      - False -> remote modified (caller should refresh)
      - None  -> probe failed (caller should fall back to refresh)
    """
    meta = _load_meta(meta_path) or {}
    try:
        # Build an authenticated request if the addon provides it (keeps credentials / headers consistent).
        try:
            req = common.get_authenticated_request(url)
        except Exception:
            req = None

        if req is None:
            req = urllib.request.Request(url)

        # Conditional headers from our last successful response.
        etag = meta.get("etag")
        last_mod = meta.get("last_modified")
        if etag:
            try:
                req.add_header("If-None-Match", str(etag))
            except Exception:
                pass
        if last_mod:
            try:
                req.add_header("If-Modified-Since", str(last_mod))
            except Exception:
                pass

        # Force HEAD even if the request helper created a GET.
        try:
            req.get_method = lambda: "HEAD"  # type: ignore
        except Exception:
            pass

        with _robust_urlopen(req, timeout=timeout) as resp:
            # urllib returns 200 for non-304 responses; 304 is raised as HTTPError, but be defensive.
            code = getattr(resp, "status", getattr(resp, "code", 200))
            if int(code) == 304:
                return True

            hdrs = getattr(resp, "headers", None)
            new_etag = None
            new_last_mod = None
            new_len = None
            try:
                if hdrs:
                    new_etag = hdrs.get("ETag") or hdrs.get("Etag")
                    new_last_mod = hdrs.get("Last-Modified")
                    cl = hdrs.get("Content-Length")
                    if cl:
                        try:
                            new_len = int(cl)
                        except Exception:
                            new_len = None
            except Exception:
                pass

            # Decide "not modified" using best available validators.
            not_modified = False
            if etag and new_etag and str(etag) == str(new_etag):
                not_modified = True
            elif last_mod and new_last_mod and str(last_mod) == str(new_last_mod):
                not_modified = True
            elif meta.get("content_length") and new_len and int(meta.get("content_length")) == int(new_len) and not (new_etag or new_last_mod):
                # only length-based when no stronger validators are available
                not_modified = True

            # Persist the latest validators (even when modified) so next start is fast.
            _save_meta(meta_path, headers={k:v for k,v in {
                "ETag": (new_etag or etag),
                "Last-Modified": (new_last_mod or last_mod),
                "Content-Length": (str(new_len) if new_len is not None else None),
            }.items() if v})

            return True if not_modified else False

    except urllib.error.HTTPError as e:
        if int(getattr(e, "code", 0)) == 304:
            return True
        log(f"HEAD probe failed ({e.code}) for {url}: {e}", xbmc.LOGWARNING)
        return None
    except Exception as e:
        log(f"HEAD probe exception for {url}: {e}", xbmc.LOGWARNING)
        return None

def _save_cache_json(cache_path, data_bytes):
    tmp_path = cache_path + ".tmp"
    bak_path = cache_path + ".bak"

    try:
        text = data_bytes.decode("utf-8-sig")
        data = json.loads(text)

        with xbmcvfs.File(tmp_path, "w") as fh:
            fh.write(json.dumps(data, ensure_ascii=False))

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

        if xbmcvfs.exists(cache_path):
            if not xbmcvfs.rename(cache_path, bak_path):
                raise IOError("Could not move existing cache to .bak")

        if not xbmcvfs.rename(tmp_path, cache_path):
            try:
                if xbmcvfs.exists(bak_path):
                    xbmcvfs.rename(bak_path, cache_path)
            except Exception:
                pass
            raise IOError("Could not move tmp to final cache")

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

        return True

    except Exception as e:
        log(f"Cache write failed {cache_path}: {e}", xbmc.LOGWARNING)
        try:
            if xbmcvfs.exists(tmp_path):
                xbmcvfs.delete(tmp_path)
        except Exception:
            pass
        return False


# ---------------------------------------------------------------------
# Module refresh wrappers (if present)
# ---------------------------------------------------------------------
_MOVIES_REFRESH = None
_SERIES_REFRESH = None
_FAV_REFRESH = None

try:
    import movies
    _MOVIES_REFRESH = getattr(movies, "background_refresh_movies_once", None)
except Exception as e:
    log(f"movies import failed: {e}", xbmc.LOGDEBUG)

try:
    import series
    _SERIES_REFRESH = getattr(series, "background_refresh_series_once", None)
except Exception as e:
    log(f"series import failed: {e}", xbmc.LOGDEBUG)

try:
    import favorites
    if hasattr(favorites, "load_favorites_json"):
        def _fav_refresh_wrapper():
            data, err = favorites.load_favorites_json()
            return bool(data) and not err
        _FAV_REFRESH = _fav_refresh_wrapper
except Exception as e:
    log(f"favorites import failed: {e}", xbmc.LOGDEBUG)



# ---------------------------------------------------------------------
# Details URL key normalization
# - Prefer the implementation from series.py to guarantee identical cache keys.
# - Fallback to local implementation (older installs / import issues).
# ---------------------------------------------------------------------
try:
    _SERIES_DETAILS_URL_KEY = getattr(sys.modules.get('series'), '_details_url_key', None)
except Exception:
    _SERIES_DETAILS_URL_KEY = None

# ---------------------------------------------------------------------
# Per-kind refresh logic
# ---------------------------------------------------------------------
MOVIES_JSON_FILENAME = "kodiMovie.json"
SERIES_JSON_FILENAME = "kodiSeries.json"
FAV_JSON_FILENAME    = "seriesFav.json"

MOVIES_CACHE_PATH = os.path.join(common.addon_data_dir, "movies.cache.json")
SERIES_CACHE_PATH = os.path.join(common.addon_data_dir, "series.cache.json")
FAV_CACHE_PATH    = os.path.join(common.addon_data_dir, "seriesFav.cache.json")


def _refresh_kind(kind_name, filename, cache_path, module_refresh_fn=None):
    scheme, host, base_path = _normalize_host_and_basepath()

    if not host:
        log(f"{kind_name}: Host missing -> skip", xbmc.LOGWARNING)
        return False

    url = f"{scheme}://{host}{base_path}{filename}"
    meta_p = _meta_path(filename)

    # NOTE: For movies we disable conditional request headers.
    # Some servers/proxies returned a stale 304 for kodiMovie.json even when the
    # content changed (ETag/Last-Modified not updated). We still detect changes
    # via sha1 inside _conditional_fetch, so this only increases network traffic.
    use_conditional = (kind_name != "movies")
    status, data_bytes, _headers = _conditional_fetch(url, meta_p, use_conditional=use_conditional)

    if status == "not_modified":
        log(f"{kind_name}: not modified (304/meta/sha1) -> skip refresh", xbmc.LOGINFO)
        return False

    if status == "changed":
        log(f"{kind_name}: changed -> refreshing", xbmc.LOGINFO)

        if not data_bytes:
            log(f"{kind_name}: changed, aber keine Daten empfangen.", xbmc.LOGWARNING)
            return False

        if not _save_cache_json(cache_path, data_bytes):
            log(f"{kind_name}: Cache-Write fehlgeschlagen – breche Refresh ab.", xbmc.LOGWARNING)
            return False

        if kind_name.lower() == "series":
            try:
                import series as _series_mod
                inv = getattr(_series_mod, "invalidate_series_progress_cache", None)
                if callable(inv):
                    inv()
                    log("Series: Progress-Cache via series.invalidate_series_progress_cache() invalidiert.", xbmc.LOGINFO)
            except Exception as e:
                log(f"Series: Fehler beim Invalidieren des Progress-Caches: {e}", xbmc.LOGDEBUG)

        if callable(module_refresh_fn):
            try:
                module_refresh_fn()
            except Exception as e:
                log(f"{kind_name}: module refresh failed (Hook): {e}", xbmc.LOGWARNING)

        return True

    log(f"{kind_name}: conditional check failed", xbmc.LOGWARNING)
    return False


def background_refresh_all_once(do_series: bool = True, do_movies: bool = True, do_fav: bool = True,
                               head_series: bool = False, head_movies: bool = False) -> tuple:
    """
    One-shot background refresh.
    Returns (changed_any, series_changed).

    - If head_series/head_movies is True, do a lightweight HEAD probe first.
      When HEAD indicates "not modified", we skip the heavier conditional GET.
      On probe failure we fall back to the normal refresh.
    """
    changed_any = False
    series_changed = False

    scheme, host, base_path = _normalize_host_and_basepath()
    if not host:
        log("Service: Host missing -> skip refresh", xbmc.LOGWARNING)
        return False, False

    # --- Series ---
    if do_series:
        try:
            series_url = f"{scheme}://{host}{base_path}{SERIES_JSON_FILENAME}"
            series_meta = _meta_path(SERIES_JSON_FILENAME)

            if head_series:
                h = _head_not_modified(series_url, series_meta, timeout=8)
                if h is True:
                    log("Service: Series list unchanged (HEAD) -> skip series refresh", xbmc.LOGINFO)
                elif h is None:
                    log("Service: Series HEAD probe failed -> fallback to conditional GET", xbmc.LOGINFO)
                    sc = _refresh_kind("series", SERIES_JSON_FILENAME, SERIES_CACHE_PATH, _SERIES_REFRESH)
                    if sc:
                        changed_any = True
                        series_changed = True
                else:
                    sc = _refresh_kind("series", SERIES_JSON_FILENAME, SERIES_CACHE_PATH, _SERIES_REFRESH)
                    if sc:
                        changed_any = True
                        series_changed = True
            else:
                sc = _refresh_kind("series", SERIES_JSON_FILENAME, SERIES_CACHE_PATH, _SERIES_REFRESH)
                if sc:
                    changed_any = True
                    series_changed = True
        except Exception as e:
            log(f"Service: series refresh failed: {e}", xbmc.LOGWARNING)

    # --- Movies ---
    if do_movies:
        try:
            movies_url = f"{scheme}://{host}{base_path}{MOVIES_JSON_FILENAME}"
            movies_meta = _meta_path(MOVIES_JSON_FILENAME)

            if head_movies:
                h = _head_not_modified(movies_url, movies_meta, timeout=8)
                if h is True:
                    log("Service: Movies list unchanged (HEAD) -> skip movies refresh", xbmc.LOGINFO)
                elif h is None:
                    log("Service: Movies HEAD probe failed -> fallback to conditional GET", xbmc.LOGINFO)
                    mc = _refresh_kind("movies", MOVIES_JSON_FILENAME, MOVIES_CACHE_PATH, _MOVIES_REFRESH)
                    if mc:
                        changed_any = True
                else:
                    mc = _refresh_kind("movies", MOVIES_JSON_FILENAME, MOVIES_CACHE_PATH, _MOVIES_REFRESH)
                    if mc:
                        changed_any = True
            else:
                mc = _refresh_kind("movies", MOVIES_JSON_FILENAME, MOVIES_CACHE_PATH, _MOVIES_REFRESH)
                if mc:
                    changed_any = True
        except Exception as e:
            log(f"Service: movies refresh failed: {e}", xbmc.LOGWARNING)

    # --- Favorites ---
    if do_fav:
        try:
            fc = _refresh_kind("favorites", FAV_JSON_FILENAME, FAV_CACHE_PATH, _FAV_REFRESH)
            if fc:
                changed_any = True
        except Exception as e:
            log(f"Service: favorites refresh failed: {e}", xbmc.LOGWARNING)

    return changed_any, series_changed


def _safe_mkdir(p):
    try:
        if p and not xbmcvfs.exists(p):
            xbmcvfs.mkdirs(p)
    except Exception:
        pass


def _details_url_key(url: str) -> str:
    """Stable key for details_url to avoid token/ts/sig churn but keep identity (pid/id).

    Keeps only selected query params (pid,id,imdb,tmdb,slug,season,episode,lang).
    Removes common volatile auth params (token,ts,expires,sig,signature,key,auth).
    """
    try:
        if callable(_SERIES_DETAILS_URL_KEY):
            return str(_SERIES_DETAILS_URL_KEY(url) or '').strip()
    except Exception:
        pass

    try:
        u = (url or '').strip()
        if not u:
            return ''
        p = urllib.parse.urlsplit(u)
        base = f"{p.scheme}://{p.netloc}{p.path}"
        q = urllib.parse.parse_qsl(p.query, keep_blank_values=True)
        if not q:
            return base
        keep = {'pid','id','imdb','tmdb','slug','season','episode','lang'}
        drop = {'token','ts','t','time','expires','exp','sig','signature','key','auth','hash'}
        kept = [(k,v) for (k,v) in q if (k.lower() in keep) and (k.lower() not in drop)]
        if not kept:
            # If nothing kept, fall back to base only
            return base
        kept.sort(key=lambda kv: (kv[0].lower(), kv[1]))
        return base + '?' + urllib.parse.urlencode(kept)
    except Exception:
        return (url or '').strip()


def _hash_url(u: str) -> str:
    """Hash for details cache filenames.

    IMPORTANT: Must match series.py (_details_cache_path) => sha1(details_key).
    """
    try:
        return hashlib.sha1((u or "").encode("utf-8", "ignore")).hexdigest()
    except Exception:
        try:
            return hashlib.sha1(str(u).encode("utf-8", "ignore")).hexdigest()
        except Exception:
            return ""


def _read_state():
    try:
        if not _exists_any(SERIES_PREFETCH_STATE_FILE):
            return {"last_ts": 0, "seen": {}}
        with xbmcvfs.File(SERIES_PREFETCH_STATE_FILE, "r") as fh:
            raw = fh.read()
        st = json.loads(raw) if raw else {}
        if not isinstance(st, dict):
            return {"last_ts": 0, "seen": {}}
        st.setdefault("last_ts", 0)
        st.setdefault("seen", {})
        if not isinstance(st["seen"], dict):
            st["seen"] = {}
        return st
    except Exception:
        return {"last_ts": 0, "seen": {}}


def _write_state(st):
    try:
        with xbmcvfs.File(SERIES_PREFETCH_STATE_FILE, "w") as fh:
            fh.write(json.dumps(st or {}, ensure_ascii=False))
        return True
    except Exception:
        return False


def _load_series_list_for_prefetch():
    """
    Nutzt bevorzugt den bereits lokal gecachten series.cache.json (von _refresh_kind).
    Fallback: direkte Remote-Abfrage (auth).
    """
    # 1) lokaler Cache
    try:
        if _exists_any(SERIES_CACHE_PATH):
            with xbmcvfs.File(SERIES_CACHE_PATH, "r") as fh:
                raw = fh.read()
            data = json.loads(raw) if raw else []
            if isinstance(data, list):
                return data
    except Exception:
        pass

    # 2) Remote Fallback
    try:
        scheme, host, base_path = _normalize_host_and_basepath()
        if not host:
            return []
        url = f"{scheme}://{host}{base_path}{SERIES_JSON_FILENAME}"
        req = common.get_authenticated_request(url)
        if not req:
            return []
        with _robust_urlopen(req, timeout=NET_TIMEOUT) as resp:
            if resp.getcode() != 200:
                return []
            data = json.loads((resp.read() or b"").decode("utf-8-sig"))
            return data if isinstance(data, list) else []
    except Exception:
        return []


def _download_and_cache_artwork(url: str):
    try:
        if not url or not isinstance(url, str):
            return False
        u = url.strip()
        if not (u.startswith("http://") or u.startswith("https://")):
            return False
        # in den Serien-Image-Cache (wie sonst auch)
        cache_dir = CACHE_DIRS.get("series") or os.path.join(common.addon_data_dir, "cache_series_images")
        return bool(common.download_and_cache_image(u, cache_dir=cache_dir, timeout_s=15))
    except Exception:
        return False


def run_series_details_prefetch(force=False):
    """
    Lädt neue/fehlende details.json + Artwork für Serien im Hintergrund.
    - KEIN Container.Refresh (Navigation bleibt stabil)
    - Throttled per Interval
    """
    # Player läuft? -> skip
    try:
        if xbmc.getCondVisibility("Player.HasMedia"):
            log("Series-Prefetch: skip (Player läuft)", xbmc.LOGDEBUG)
            return
    except Exception:
        pass

    log(f"details_prefetch: start force={force}", xbmc.LOGINFO)

    now_ts = int(time.time())
    st = _read_state()
    last_ts = int(st.get("last_ts") or 0)
    interval_sec = int(SERIES_PREFETCH_INTERVAL_HOURS) * 3600

    if (not force) and last_ts and (now_ts - last_ts) < interval_sec:
        log("Series-Prefetch: skip (throttled)", xbmc.LOGDEBUG)
        log(f"details_prefetch: skipped (throttled) interval_h={SERIES_PREFETCH_INTERVAL_HOURS}", xbmc.LOGINFO)
        return

    win = None
    try:
        win = xbmcgui.Window(10000)
        if win.getProperty(SERIES_PREFETCH_PROP) == "1":
            log("Series-Prefetch: skip (läuft bereits)", xbmc.LOGDEBUG)
            return
        win.setProperty(SERIES_PREFETCH_PROP, "1")
    except Exception:
        win = None

    log(f"details_prefetch: start force={force} interval_h={SERIES_PREFETCH_INTERVAL_HOURS} limit={SERIES_PREFETCH_LIMIT_PER_RUN}", xbmc.LOGINFO)

    _safe_mkdir(SERIES_DETAILS_CACHE_DIR)
    _safe_mkdir(SERIES_DETAILS_META_DIR)

    try:
        series_list = _load_series_list_for_prefetch()
        if not series_list:
            log("Series-Prefetch: keine Serienliste gefunden.", xbmc.LOGINFO)
            st["last_ts"] = now_ts
            _write_state(st)
            return

        seen = st.get("seen") or {}
        fetched = 0
        checked = 0
        changed = 0
        not_modified = 0
        missing = 0
        artwork_done = 0

        # Sammle Artwork URLs (Poster/Fanart + Season Poster/Still optional aus details.json)
        artwork_urls = []

        for serie in series_list:
            if not isinstance(serie, dict):
                continue

            details_url = (serie.get("details_url") or "").strip()
            if not (details_url.startswith("http://") or details_url.startswith("https://")):
                continue

            # Artwork vom Serien-Eintrag (sofort)
            for k in ("poster", "backcover", "fanart"):
                u = (serie.get(k) or "").strip() if isinstance(serie.get(k), str) else ""
                if u.startswith("http"):
                    artwork_urls.append(u)

            checked += 1
            details_key = _details_url_key(details_url)
            h = _hash_url(details_key)
            if not h:
                continue

            cache_fp = os.path.join(SERIES_DETAILS_CACHE_DIR, f"{h}.json")
            meta_fp  = os.path.join(SERIES_DETAILS_META_DIR, f"{h}.meta.json")

            if not _exists_any(cache_fp):
                missing += 1

            # Wenn wir schon eine Datei haben, nur noch "conditional" prüfen, aber nicht aggressiv neu laden
            status, data_bytes, _hdr = _conditional_fetch(details_url, meta_fp)

            if status == "changed":
                changed += 1
                if data_bytes and _save_cache_json(cache_fp, data_bytes):
                    fetched += 1
                    seen[details_key] = now_ts
                    # aus details.json zusätzliche Artwork URLs ziehen
                    try:
                        j = json.loads(data_bytes.decode("utf-8-sig"))
                        if isinstance(j, dict):
                            for sk, sv in j.items():
                                if isinstance(sv, dict):
                                    sp = sv.get("poster")
                                    if isinstance(sp, str) and sp.startswith("http"):
                                        artwork_urls.append(sp)
                                    for ep in (sv.get("episodes") or []):
                                        if isinstance(ep, dict):
                                            still = ep.get("still")
                                            if isinstance(still, str) and still.startswith("http"):
                                                artwork_urls.append(still)
                    except Exception:
                        pass
                else:
                    log(f"Series-Prefetch: details cache write failed for {details_url}", xbmc.LOGWARNING)

            elif status == "not_modified":
                not_modified += 1
                # wenn Datei fehlt, einmalig laden (kein 304 liefert Body)
                if not _exists_any(cache_fp):
                    req = common.get_authenticated_request(details_url)
                    if req:
                        try:
                            with _robust_urlopen(req, timeout=NET_TIMEOUT) as resp:
                                if resp.getcode() == 200:
                                    b = resp.read() or b""
                                    if b and _save_cache_json(cache_fp, b):
                                        fetched += 1
                                        seen[details_key] = now_ts
                        except Exception:
                            pass

            # Limitierung
            if fetched >= int(SERIES_PREFETCH_LIMIT_PER_RUN):
                break

        # Artwork Prefetch (dedupe + limit)
        if artwork_urls:
            uniq = []
            sset = set()
            for u in artwork_urls:
                if u and u not in sset:
                    sset.add(u)
                    uniq.append(u)
            for u in uniq[: int(SERIES_ARTWORK_PREFETCH_LIMIT)]:
                if _download_and_cache_artwork(u):
                    artwork_done += 1

        st["seen"] = seen
        st["last_ts"] = now_ts
        _write_state(st)

        # Signal UI-side memory cache invalidation (same Kodi session)
        try:
            if fetched > 0:
                xbmcgui.Window(10000).setProperty(DETAILS_BUMP_PROP, str(now_ts))
        except Exception:
            pass

        log(f"details_prefetch: checked={checked} changed={changed} not_modified={not_modified} missing={missing} fetched={fetched} artwork_cached={artwork_done}", xbmc.LOGINFO)

    except Exception as e:
        log(f"Series-Prefetch Fehler: {e}\n{traceback.format_exc()}", xbmc.LOGWARNING)
    finally:
        try:
            if win:
                win.setProperty(SERIES_PREFETCH_PROP, "")
        except Exception:
            pass


def _start_series_prefetch_thread(force=False):
    """
    Prefetch nicht im Service-Hauptthread blockieren.
    """
    try:
        th = threading.Thread(target=run_series_details_prefetch, args=(force,))
        th.daemon = True
        th.start()
        log(f"Series-Prefetch: thread started force={force}", xbmc.LOGINFO)
        return True
    except Exception:
        return False


# ---------------------------------------------------------------------
# Orphan Artwork Cache Cleanup (Start + 1x/Tag)
# ---------------------------------------------------------------------
CACHE_CLEANUP_INTERVAL_HOURS = 24
CACHE_CLEANUP_STATE_FILE = os.path.join(common.addon_data_dir, "cache_cleanup_state.json")
CACHE_CLEANUP_PROP = f"{common.addon_id}.cache.cleanup.running"

CACHE_DIRS = {
    "movies": os.path.join(common.addon_data_dir, "cache_movies_images"),
    "series": os.path.join(common.addon_data_dir, "cache_series_images"),
    "fav_series": os.path.join(common.addon_data_dir, "cache_fav_series_images"),
}

# ---------------------------------------------------------------------
# Payload Cache Cleanup (Favorites) – nur Cache, kann jederzeit neu gebaut werden
# ---------------------------------------------------------------------
PAYLOAD_GC_INTERVAL_HOURS = 6
PAYLOAD_GC_STATE_FILE = os.path.join(common.addon_data_dir, "payload_gc_state.json")
PAYLOAD_GC_PROP = f"{common.addon_id}.payload.gc.running"

# Payload-Cache-Verzeichnisse (aktuell nur Favorites)
PAYLOAD_CACHE_DIRS = {
    "fav_payload": os.path.join(common.addon_data_dir, "payload_cache_fav"),
}


IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
_HASHED_CACHE_RE = re.compile(r"^[0-9a-fA-F]{32}(\.[A-Za-z0-9]{1,8})?$")  # tolerant


def _ensure_dir_slash(p):
    try:
        if p and not p.endswith(("/", "\\")):
            return p + "/"
    except Exception:
        pass
    return p


def _translate_path(p):
    try:
        return xbmcvfs.translatePath(p)
    except Exception:
        return p


def _exists_any(path):
    """
    exists() mit VFS + OS-Fallback (wichtig, weil xbmcvfs.exists manchmal "zickt")
    """
    if not path:
        return False
    try:
        if xbmcvfs.exists(path):
            return True
    except Exception:
        pass
    try:
        lp = _translate_path(path)
        return os.path.exists(lp)
    except Exception:
        return False


def _listdir_any(dir_path):
    """
    listdir() mit VFS + OS-Fallback.
    Gibt (dirs, files) zurück.
    """
    try:
        dirs, files = xbmcvfs.listdir(_ensure_dir_slash(dir_path))
        return (dirs or []), (files or [])
    except Exception:
        pass

    try:
        lp = _translate_path(dir_path)
        if os.path.isdir(lp):
            return [], os.listdir(lp)
    except Exception:
        pass

    return [], []


def _vfs_join(parent, child):
    """
    Join für VFS-Pfade (immer '/'), damit Windows-Backslashes nicht kaputt machen.
    """
    parent = (parent or "").rstrip("/").rstrip("\\")
    child = (child or "").lstrip("/").lstrip("\\")
    return parent + "/" + child


def _read_json_file(path):
    try:
        if not path or not _exists_any(path):
            return None
        with xbmcvfs.File(path, "r") as fh:
            raw = fh.read()
        if not raw:
            return None
        return json.loads(raw)
    except Exception:
        return None


def _extract_http_urls(obj, out_set):
    if obj is None:
        return
    if isinstance(obj, str):
        s = obj.strip()
        if s.startswith("http://") or s.startswith("https://"):
            out_set.add(s)
        return
    if isinstance(obj, dict):
        # URLs können bei einigen Cache-Dateien auch als dict-KEY vorkommen (z.B. alte watched_status oder URL->Meta Maps)
        for k, v in obj.items():
            if isinstance(k, str):
                ks = k.strip()
                if ks.startswith("http://") or ks.startswith("https://"):
                    out_set.add(ks)
            _extract_http_urls(v, out_set)
        return
    if isinstance(obj, list):
        for v in obj:
            _extract_http_urls(v, out_set)


def _url_to_keep_names(url):
    keep = set()
    if not isinstance(url, str) or not url:
        return keep

    # basename (altes Schema)
    try:
        p = urllib.parse.urlparse(url).path or ""
        base = os.path.basename(p).strip()
        if base:
            keep.add(base)
    except Exception:
        pass

    # md5 (neues Schema)
    try:
        h = hashlib.md5(url.encode("utf-8")).hexdigest()
    except Exception:
        return keep

    ext = ""
    try:
        ext = os.path.splitext(urllib.parse.urlparse(url).path or "")[1] or ""
    except Exception:
        ext = ""

    if not ext or len(ext) > 8:
        ext = ".jpg"

    variants = {ext.lower(), ".jpg", ".jpeg", ".png", ".webp"}
    for e in variants:
        keep.add(h + e)

    return keep


def _load_last_cleanup_ts():
    data = _read_json_file(CACHE_CLEANUP_STATE_FILE)
    if isinstance(data, dict):
        try:
            return int(data.get("last_ts", 0))
        except Exception:
            return 0
    return 0


def _save_last_cleanup_ts(ts_int):
    try:
        with xbmcvfs.File(CACHE_CLEANUP_STATE_FILE, "w") as fh:
            fh.write(json.dumps({"last_ts": int(ts_int)}, ensure_ascii=False))
        return True
    except Exception:
        return False


def _iter_cache_files(cache_dir, max_depth=2):
    """
    Robust iterator:
    1) Try xbmcvfs.listdir (VFS)
    2) If it yields nothing but local path exists -> fallback to os.walk

    Yields tuples: (kind, root_dir, filename)
      kind: "vfs" or "os"
    """
    if not cache_dir:
        return

    vfs_dir = _ensure_dir_slash(cache_dir)
    local_dir = _translate_path(cache_dir)

    vfs_exists = False
    try:
        vfs_exists = xbmcvfs.exists(cache_dir)
    except Exception:
        vfs_exists = False

    os_exists = False
    try:
        os_exists = os.path.isdir(local_dir)
    except Exception:
        os_exists = False

    yielded_any = False

    # --- 1) xbmcvfs.listdir ---
    if vfs_exists:
        stack = [(vfs_dir, 0)]
        while stack:
            d, depth = stack.pop()
            try:
                dirs, files = xbmcvfs.listdir(_ensure_dir_slash(d))
            except Exception as e:
                log(f"Cache-Cleanup: xbmcvfs.listdir failed for {d}: {e}", xbmc.LOGWARNING)
                break

            if depth < max_depth:
                for sub in dirs or []:
                    if not sub:
                        continue
                    stack.append((_vfs_join(d, sub) + "/", depth + 1))

            for fn in files or []:
                if fn:
                    yielded_any = True
                    yield "vfs", d.rstrip("/"), fn

    # --- 2) os.walk fallback ---
    if (not yielded_any) and os_exists:
        try:
            base_depth = local_dir.rstrip("/").count(os.sep)
            for root, dirs, files in os.walk(local_dir):
                depth = root.count(os.sep) - base_depth
                if depth > max_depth:
                    dirs[:] = []
                    continue
                for fn in files:
                    if fn:
                        yield "os", root, fn
        except Exception as e:
            log(f"Cache-Cleanup: os.walk failed for {local_dir}: {e}", xbmc.LOGWARNING)


def _delete_file_any(path):
    # Try OS delete first (fast) when local path exists, else xbmcvfs
    try:
        lp = _translate_path(path)
        if os.path.isfile(lp):
            try:
                os.remove(lp)
                return True
            except Exception:
                pass
    except Exception:
        pass

    try:
        if xbmcvfs.delete(path) or (not xbmcvfs.exists(path)):
            return True
    except Exception:
        pass
    return False


def _cleanup_cache_dir(cache_dir, keep_names):
    removed = 0
    scanned = 0
    seen = 0

    vfs_exists = False
    try:
        vfs_exists = xbmcvfs.exists(cache_dir)
    except Exception:
        vfs_exists = False

    local_dir = _translate_path(cache_dir)
    os_exists = False
    try:
        os_exists = os.path.isdir(local_dir)
    except Exception:
        os_exists = False

    # Debug pro Cache-Dir (damit wir sehen warum files_seen=0 ist)
    log(f"Cache-Cleanup: dir='{cache_dir}' | vfs_exists={vfs_exists} | local='{local_dir}' | os_exists={os_exists}", xbmc.LOGINFO)

    for kind, d, fn in _iter_cache_files(cache_dir, max_depth=2):
        seen += 1

        ext = os.path.splitext(fn)[1].lower()
        is_image = ext in IMAGE_EXTS
        is_hashed = bool(_HASHED_CACHE_RE.match(fn))

        if not (is_image or is_hashed):
            continue

        scanned += 1
        if fn in keep_names:
            continue

        if kind == "os":
            p = os.path.join(d, fn)
        else:
            p = _vfs_join(d, fn)

        if _delete_file_any(p):
            removed += 1

    return removed, scanned, seen


def _iter_json_files_recursive(root_dir, max_depth=3, max_files=2500):
    """Yield absolute file paths (VFS-compatible) to .json files under root_dir up to max_depth.
    Skips image cache directories to keep it fast.
    """
    if not root_dir:
        return
    # Skip image cache dirs (contain many non-json files)
    skip_names = set()
    try:
        for _d in CACHE_DIRS.values():
            bn = os.path.basename(_translate_path(_d).rstrip("/"))
            if bn:
                skip_names.add(bn)
    except Exception:
        skip_names.update({"cache_movies_images", "cache_series_images", "cache_fav_series_images"})

    seen = 0
    stack = [(root_dir, 0)]
    visited = set()

    while stack and seen < int(max_files):
        d, depth = stack.pop()
        if not d or d in visited:
            continue
        visited.add(d)

        # VFS-first
        try:
            dirs, files = xbmcvfs.listdir(_ensure_dir_slash(d))
            for fn in files or []:
                if fn and fn.lower().endswith(".json"):
                    yield _vfs_join(d, fn)
                    seen += 1
                    if seen >= int(max_files):
                        return
            if depth < int(max_depth):
                for sub in dirs or []:
                    if not sub:
                        continue
                    if sub in skip_names:
                        continue
                    stack.append((_vfs_join(d, sub), depth + 1))
            continue
        except Exception:
            pass

        # OS fallback
        try:
            lp = _translate_path(d)
            if not os.path.isdir(lp):
                continue
            for fn in os.listdir(lp):
                if not fn:
                    continue
                fp = os.path.join(lp, fn)
                if os.path.isfile(fp) and fn.lower().endswith(".json"):
                    yield fp
                    seen += 1
                    if seen >= int(max_files):
                        return
            if depth < int(max_depth):
                for fn in os.listdir(lp):
                    if not fn:
                        continue
                    if fn in skip_names:
                        continue
                    fp = os.path.join(lp, fn)
                    if os.path.isdir(fp):
                        stack.append((fp, depth + 1))
        except Exception:
            pass



def _collect_keep_names_from_all_jsons(return_urls=False):
    """
    Wichtig: VFS + OS-Fallback, damit keep-set nicht leer bleibt,
    wenn xbmcvfs.exists/listdir mal spinnt.

    return_urls=False -> (keep_names, url_count)
    return_urls=True  -> (keep_names, url_count, urls_set)

    Hinweis: Scannt rekursiv auch Unterordner (z.B. series payload_cache),
    überspringt aber die großen Image-Cache-Verzeichnisse.
    """
    keep = set()
    urls = set()

    try:
        if not common.addon_data_dir or not _exists_any(common.addon_data_dir):
            return (keep, 0, urls) if return_urls else (keep, 0)
    except Exception:
        return (keep, 0, urls) if return_urls else (keep, 0)

    # Rekursiv JSON-Dateien sammeln (inkl. payload_cache)
    for p in _iter_json_files_recursive(common.addon_data_dir, max_depth=3, max_files=2500):
        data = _read_json_file(p)
        _extract_http_urls(data, urls)

    for u in urls:
        keep.update(_url_to_keep_names(u))

    if return_urls:
        return keep, len(urls), urls
    return keep, len(urls)


# ---------------------------------------------------------------------
# Watched-Status Cleanup (Hash-Dateien prunen wenn Medien verschwinden)
# ---------------------------------------------------------------------
_WATCHED_VIDEO_EXT_RE = re.compile(r"\.(mkv|mp4|avi|m4v|mov|ts|m2ts|wmv|webm|iso|strm)(\?|$)", re.IGNORECASE)
_WATCHED_EP_RE = re.compile(r"(?:^|[\\/])[^\\/]*S\d{1,2}E\d{1,2}[^\\/]*", re.IGNORECASE)


def _pick_existing_in_addon_data(candidates):
    for name in candidates:
        p = os.path.join(common.addon_data_dir, name)
        if _exists_any(p):
            return p
    return os.path.join(common.addon_data_dir, candidates[0])


def _load_watched_movies(path):
    if hasattr(common, "load_watched_status_movies"):
        return common.load_watched_status_movies(path)
    data = _read_json_file(path)
    return data if isinstance(data, dict) else {}


def _save_watched_movies(path, data):
    if hasattr(common, "save_watched_status_movies"):
        return common.save_watched_status_movies(path, data)
    # Fallback: compact dump
    try:
        with xbmcvfs.File(path, "w") as fh:
            fh.write(json.dumps(data, ensure_ascii=False, separators=(",", ":")))
        return True
    except Exception:
        return False


def _load_watched_series(path):
    if hasattr(common, "load_watched_status_series"):
        return common.load_watched_status_series(path)
    data = _read_json_file(path)
    return data if isinstance(data, dict) else {}


def _save_watched_series(path, data):
    if hasattr(common, "save_watched_status_series"):
        return common.save_watched_status_series(path, data)
    try:
        with xbmcvfs.File(path, "w") as fh:
            fh.write(json.dumps(data, ensure_ascii=False, separators=(",", ":")))
        return True
    except Exception:
        return False


def cleanup_watched_status_from_urls(urls_set):
    """
    Entfernt aus watched_status_series.json + watched_status_movies(.json)
    alle Hash-Keys, deren Medien-URL nicht mehr in den aktuellen JSONs vorkommt.

    Wichtig:
    - Hashes sind nicht umkehrbar, daher bestimmen wir die "keep"-Hashes,
      indem wir aus vorhandenen Medien-URLs die Hashes neu berechnen.
    - Failsafe: wenn wir keine keep-Hashes finden, löschen wir nichts.
    """
    try:
        if not urls_set:
            log("Watched-Cleanup: skip (urls_set leer).", xbmc.LOGINFO)
            return

        series_fp = os.path.join(common.addon_data_dir, "watched_status_series.json")
        movies_fp = _pick_existing_in_addon_data(["watched_status_movies.json", "watched_status_movie.json"])

        keep_movies = set()
        keep_series = set()

        # Build keep sets
        for u in urls_set:
            if not isinstance(u, str):
                continue
            url = u.strip()
            if not url or not (url.startswith("http://") or url.startswith("https://")):
                continue
            if not _WATCHED_VIDEO_EXT_RE.search(url):
                continue

            # Episode erkennen
            if _WATCHED_EP_RE.search(url):
                if hasattr(common, "series_watched_id"):
                    keep_series.add(common.series_watched_id(url))
            else:
                if hasattr(common, "movies_watched_id"):
                    keep_movies.add(common.movies_watched_id(url))

        removed_s = 0
        removed_m = 0

        # Series prune (nur wenn keep_series vorhanden)
        # Safety: wenn wir nur extrem wenige Episode-URLs finden (z.B. weil keine Episoden-Cachefiles vorhanden sind),
        # dann prunen wir NICHT, um keinen Watch-Status aus Versehen zu verlieren.
        if _exists_any(series_fp) and keep_series and len(keep_series) >= 3:
            watched_series = _load_watched_series(series_fp)
            before = len(watched_series)
            watched_series = {k: v for k, v in watched_series.items() if k in keep_series}
            removed_s = before - len(watched_series)
            if removed_s > 0:
                _save_watched_series(series_fp, watched_series)
        elif _exists_any(series_fp) and keep_series and len(keep_series) < 3:
            log(f"Watched-Cleanup: skip series prune (keep_series={len(keep_series)} zu klein).", xbmc.LOGINFO)

        # Movies prune (nur wenn keep_movies vorhanden)
        if _exists_any(movies_fp) and keep_movies:
            watched_movies = _load_watched_movies(movies_fp)
            before = len(watched_movies)
            watched_movies = {k: v for k, v in watched_movies.items() if k in keep_movies}
            removed_m = before - len(watched_movies)
            if removed_m > 0:
                _save_watched_movies(movies_fp, watched_movies)

        if (removed_s or removed_m):
            log(
                f"Watched-Cleanup: series_removed={removed_s} movies_removed={removed_m} "
                f"(keep_series={len(keep_series)} keep_movies={len(keep_movies)})",
                xbmc.LOGINFO
            )
        else:
            log(
                f"Watched-Cleanup: nothing to remove (keep_series={len(keep_series)} keep_movies={len(keep_movies)})",
                xbmc.LOGDEBUG
            )

    except Exception as e:
        log(f"Watched-Cleanup Fehler: {e}\n{traceback.format_exc()}", xbmc.LOGWARNING)


def run_orphan_artwork_cleanup(force=False):
    try:
        if xbmc.getCondVisibility("Player.HasMedia"):
            log("Cache-Cleanup: skip (Player läuft)", xbmc.LOGINFO)
            return
    except Exception:
        pass

    now_ts = int(time.time())
    last_ts = _load_last_cleanup_ts()
    interval_sec = CACHE_CLEANUP_INTERVAL_HOURS * 3600

    if not force and last_ts and (now_ts - last_ts) < interval_sec:
        left = interval_sec - (now_ts - last_ts)
        log(f"Cache-Cleanup: skip (noch {left}s bis zum nächsten Lauf)", xbmc.LOGINFO)
        return

    win = None
    try:
        win = xbmcgui.Window(10000)
        if win.getProperty(CACHE_CLEANUP_PROP) == "1":
            log("Cache-Cleanup: skip (läuft bereits)", xbmc.LOGINFO)
            return
        win.setProperty(CACHE_CLEANUP_PROP, "1")
    except Exception:
        win = None

    log(f"Cache-Cleanup: gestartet (force={force}, last_ts={last_ts})", xbmc.LOGINFO)

    try:
        keep, url_count, urls = _collect_keep_names_from_all_jsons(return_urls=True)

        removed_total = 0
        scanned_total = 0
        seen_total = 0

        per_dir = {}

        for key, cdir in CACHE_DIRS.items():
            r, s, seen = _cleanup_cache_dir(cdir, keep)
            removed_total += r
            scanned_total += s
            seen_total += seen
            per_dir[key] = {"seen": seen, "scanned": s, "removed": r}

        try:
            cleanup_watched_status_from_urls(urls)
        except Exception as e:
            log(f"Watched-Cleanup failed: {e}", xbmc.LOGWARNING)

        _save_last_cleanup_ts(now_ts)

        log(
            f"Cache-Cleanup: done | urls_found={url_count} keep_names={len(keep)} "
            f"files_seen={seen_total} scanned={scanned_total} removed={removed_total} per_dir={per_dir}",
            xbmc.LOGINFO
        )

    except Exception as e:
        log(f"Cache-Cleanup Fehler: {e}\n{traceback.format_exc()}", xbmc.LOGWARNING)
    finally:
        try:
            if win:
                win.setProperty(CACHE_CLEANUP_PROP, "")
        except Exception:
            pass


# ---------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------
def run_service():
    common.addon_handle = get_addon_handle_safe()

    try:
        if common.addon_data_dir and not xbmcvfs.exists(common.addon_data_dir):
            xbmcvfs.mkdirs(common.addon_data_dir)
    except Exception:
        pass

    # Cache dirs sicher anlegen (verhindert "exists false / walk nichts" Chaos)
    try:
        for _k, _d in CACHE_DIRS.items():
            if _d and not xbmcvfs.exists(_d):
                xbmcvfs.mkdirs(_d)
    except Exception:
        pass

    interval = _get_interval_seconds(3600)
    log(f"Service gestartet ({SERVICE_BUILD}), Intervall={interval}s")

    if common.addon_handle == -1:
        log("Service-Mode erkannt: kein Addon-Handle in sys.argv (ok).", xbmc.LOGDEBUG)

    try:
        changed, series_changed = background_refresh_all_once(do_series=True, do_movies=True, do_fav=True, head_series=False, head_movies=False)
        # Details/Artwork Prefetch im Hintergrund (ohne GUI Refresh)
        if series_changed:
            _start_series_prefetch_thread(force=True)
        else:
            log("Service: Series list unchanged -> run details prefetch (throttled)", xbmc.LOGINFO)
            _start_series_prefetch_thread(force=False)

        if changed:
            _refresh_if_addon_visible()
    except Exception as e:
        log(f"Initial BG refresh failed: {e}", xbmc.LOGWARNING)

    # Cleanup beim Start (force=True)
    try:
        run_orphan_artwork_cleanup(force=True)
    except Exception as e:
        log(f"Initial Cache-Cleanup failed: {e}", xbmc.LOGWARNING)

    # Payload-Cache GC beim Start (force=True)
    try:
        run_payload_cache_gc(force=True)
    except Exception as e:
        log(f"Initial Payload-GC failed: {e}", xbmc.LOGWARNING)

    mon = xbmc.Monitor()
    while not mon.abortRequested():
        if mon.waitForAbort(interval):
            break

        try:
            changed, series_changed = background_refresh_all_once(do_series=True, do_movies=True, do_fav=True, head_series=False, head_movies=False)
            # Prefetch gelegentlich laufen lassen (throttled)
            _start_series_prefetch_thread(force=False)
            if changed:
                _refresh_if_addon_visible()
        except Exception as e:
            log(f"Periodic BG refresh failed: {e}\n{traceback.format_exc()}", xbmc.LOGWARNING)

        try:
            run_orphan_artwork_cleanup(force=False)
        except Exception as e:
            log(f"Periodic Cache-Cleanup failed: {e}", xbmc.LOGWARNING)

        try:
            run_payload_cache_gc(force=False)
        except Exception as e:
            log(f"Periodic Payload-GC failed: {e}", xbmc.LOGWARNING)

    log("Service stopped")



def _load_last_payload_gc_ts():
    data = _read_json_file(PAYLOAD_GC_STATE_FILE)
    if isinstance(data, dict):
        try:
            return int(data.get("last_ts", 0))
        except Exception:
            return 0
    return 0


def _save_last_payload_gc_ts(ts_int):
    try:
        with xbmcvfs.File(PAYLOAD_GC_STATE_FILE, "w") as fh:
            fh.write(json.dumps({"last_ts": int(ts_int)}, ensure_ascii=False))
        return True
    except Exception:
        return False


def _mtime_any(path):
    """mtime für VFS + OS"""
    if not path:
        return 0
    try:
        st = xbmcvfs.Stat(path)
        mt_attr = getattr(st, "st_mtime", None)
        mt = mt_attr() if callable(mt_attr) else mt_attr
        return int(mt or 0)
    except Exception:
        pass
    try:
        lp = _translate_path(path)
        return int(os.path.getmtime(lp))
    except Exception:
        return 0



def _gc_payload_dir(cache_dir, now_ts, max_age_days=30, max_files=2000):
    """Löscht alte Payload-Cache-Dateien (.json) nach Alter und/oder count-limit."""
    removed = 0
    scanned = 0

    if not cache_dir or not _exists_any(cache_dir):
        return removed, scanned

    # VFS listdir first
    dirs, files = _listdir_any(cache_dir)
    candidates = []
    for fn in (files or []):
        if not fn:
            continue
        lfn = fn.lower()
        if not lfn.endswith(".json"):
            continue
        if lfn.endswith(".tmp") or lfn.endswith(".bak"):
            continue
        fp = _vfs_join(cache_dir, fn) if not os.path.isabs(fn) else fn
        mt = _mtime_any(fp)
        candidates.append((mt, fp))

    scanned = len(candidates)
    if not candidates:
        return removed, scanned

    max_age_sec = int(max_age_days) * 86400
    # 1) age-based
    keep = []
    for mt, fp in candidates:
        if mt and (now_ts - mt) > max_age_sec:
            if _delete_file_any(fp):
                removed += 1
        else:
            keep.append((mt, fp))

    # 2) count-based (delete oldest)
    if int(max_files) > 0 and len(keep) > int(max_files):
        keep.sort(key=lambda t: (t[0] or 0))
        to_del = keep[: max(0, len(keep) - int(max_files))]
        for _mt, fp in to_del:
            if _delete_file_any(fp):
                removed += 1

    return removed, scanned


def run_payload_cache_gc(force=False):
    """Payload-Cache GC (Favorites). Throttled: max alle PAYLOAD_GC_INTERVAL_HOURS."""
    # Player läuft? -> lieber skip (IO sparen)
    try:
        if xbmc.getCondVisibility("Player.HasMedia"):
            log("Payload-GC: skip (Player läuft)", xbmc.LOGDEBUG)
            return
    except Exception:
        pass

    now_ts = int(time.time())
    last_ts = _load_last_payload_gc_ts()
    interval_sec = int(PAYLOAD_GC_INTERVAL_HOURS) * 3600

    if not force and last_ts and (now_ts - last_ts) < interval_sec:
        return

    win = None
    try:
        win = xbmcgui.Window(10000)
        if win.getProperty(PAYLOAD_GC_PROP) == "1":
            return
        win.setProperty(PAYLOAD_GC_PROP, "1")
    except Exception:
        win = None

    removed_total = 0
    scanned_total = 0
    try:
        for key, pdir in PAYLOAD_CACHE_DIRS.items():
            r, s = _gc_payload_dir(pdir, now_ts, max_age_days=30, max_files=2000)
            removed_total += r
            scanned_total += s

        _save_last_payload_gc_ts(now_ts)

        log(f"Payload-GC: done | scanned={scanned_total} removed={removed_total}", xbmc.LOGINFO)
    except Exception as e:
        log(f"Payload-GC Fehler: {e}\n{traceback.format_exc()}", xbmc.LOGWARNING)
    finally:
        try:
            if win:
                win.setProperty(PAYLOAD_GC_PROP, "")
        except Exception:
            pass

if __name__ == "__main__":
    run_service()