# coding: utf-8
import sys
import os
import urllib.parse
import urllib.request
import json
import xbmc
import xbmcaddon
import xbmcplugin
import xbmcvfs
import base64
import time
import xbmcgui
import ssl
import re
import traceback
from datetime import datetime, timezone
import threading
import hashlib
from typing import Any, Optional
import socket

# Centralized timeout constants (seconds)
TIMEOUT_SHORT = 5       # quick probes, SHA1 sidecars
TIMEOUT_DEFAULT = 20    # standard HTTP requests
TIMEOUT_LONG = 60       # large file downloads
ADDON_BOOT_RETRY_ATTEMPTS = 24  # 24 × 0.25 s = ~6 s boot-race window

# Globaler Socket-Timeout als Sicherheitsnetz gegen DNS-Hänger und
# andere Low-Level-Blockaden, die urllib-Timeouts nicht abfangen.
try:
    socket.setdefaulttimeout(15)
except Exception:
    pass



# -------------------------------------------------------------------
# SSL Context (reuse)
# -------------------------------------------------------------------
_SSL_CTX = None
_SSL_CTX_LOCK = threading.Lock()

def _build_ssl_context():
    """Create a verified TLS context (explicit hardening)."""
    ctx = ssl.create_default_context()
    try:
        ctx.check_hostname = True
    except Exception:
        pass
    try:
        ctx.verify_mode = ssl.CERT_REQUIRED
    except Exception:
        pass
    try:
        if hasattr(ssl, "TLSVersion"):
            ctx.minimum_version = ssl.TLSVersion.TLSv1_2
    except Exception:
        pass
    return ctx

def get_ssl_context():
    """Create and reuse a single SSL context (tiny CPU win, fewer allocations)."""
    global _SSL_CTX
    if _SSL_CTX is None:
        try:
            with _SSL_CTX_LOCK:
                if _SSL_CTX is None:
                    _SSL_CTX = _build_ssl_context()
        except Exception:
            _SSL_CTX = _build_ssl_context()
    return _SSL_CTX

# -------------------------------------------------------------------
# Addon-Infos
# -------------------------------------------------------------------
def _get_addon_robust():
    """
    Boot-Race-Schutz: Auf CoreELEC/LibreELEC startet Kodi service.py beim
    Boot manchmal BEVOR der AddonManager addon.xml indiziert hat ->
    xbmcaddon.Addon() wirft dann RuntimeError 'Unknown addon id'.
    Wir warten bis zu ~6s mit Retry, danach fallback auf feste Plugin-ID.
    """
    last_err = None
    try:
        mon = xbmc.Monitor()
    except Exception:
        mon = None
    for _ in range(ADDON_BOOT_RETRY_ATTEMPTS):  # ADDON_BOOT_RETRY_ATTEMPTS * 0.25s = ~6s
        try:
            return xbmcaddon.Addon()
        except RuntimeError as e:
            last_err = e
        if mon is not None:
            if mon.waitForAbort(0.25):
                break
        else:
            import time as _t
            _t.sleep(0.25)
    # Letzter Ausweg: explizite Plugin-ID
    try:
        return xbmcaddon.Addon('plugin.video.glotzbox')
    except Exception:
        if last_err is not None:
            raise last_err
        raise

addon = _get_addon_robust()
addon_id = addon.getAddonInfo('id')
addon_version = addon.getAddonInfo('version')
addon_name = addon.getAddonInfo('name')

# Handle robust holen (Fallback -1, default.py überschreibt)
try:
    _raw_handle = sys.argv[1] if len(sys.argv) > 1 else ""
    addon_handle = int(_raw_handle) if str(_raw_handle).lstrip('-').isdigit() else -1
except Exception:
    addon_handle = -1

base_url = sys.argv[0] if len(sys.argv) > 0 else ""

# -------------------------------------------------------------------
# Pfade
# -------------------------------------------------------------------
addon_path = xbmcvfs.translatePath(addon.getAddonInfo('path'))
addon_data_dir = xbmcvfs.translatePath(addon.getAddonInfo('profile'))

try:
    if addon_data_dir and not xbmcvfs.exists(addon_data_dir):
        xbmc.log(f"[{addon_id}] Erstelle Datenverzeichnis: {addon_data_dir}", xbmc.LOGINFO)
        xbmcvfs.mkdirs(addon_data_dir)
except Exception as e:
    try:
        xbmc.log(f"[{addon_id}] mkdirs fehlgeschlagen für {addon_data_dir}: {e}", xbmc.LOGDEBUG)
    except Exception:
        pass


# -------------------------------------------------------------------
# Shared window properties / addon runtime helpers
# -------------------------------------------------------------------
class WindowProps:
    MOVIES_BG_RUNNING = f"{addon_id}.bg.movies.running"
    MOVIES_REFRESH_TS = f"{addon_id}.movies.refresh.ts"
    MOVIES_IMAGE_PREFETCH_TRIGGER = f"{addon_id}.movies.image.prefetch.trigger"
    MOVIES_REFRESH_PENDING_UNTIL = f"{addon_id}.movies.refresh.pending_until"
    MOVIES_LISTING_TS = f"{addon_id}.movies.listing.ts"
    MOVIES_REFRESH_REQ_TS = f"{addon_id}.movies.refresh.req.ts"
    MOVIES_LIST_BUILT_TS = f"{addon_id}.movies.list.built.ts"
    MOVIES_WATCHED_UPDATED_TS = f"{addon_id}.movies.watched.updated.ts"
    MOVIES_LISTING_DATA_SIG = f"{addon_id}.movies.listing.data_sig"

    SERIES_DETAILS_PREFETCH_RUNNING = f"{addon_id}.series.details.prefetch.running"
    SERIES_DETAILS_PREFETCH_TRIGGER = f"{addon_id}.series.details.prefetch.trigger"
    SERIES_DETAILS_BUMP = f"{addon_id}.series.details.bump"

    PAYLOAD_GC_RUNNING = f"{addon_id}.payload.gc.running"
    CACHE_CLEANUP_RUNNING = f"{addon_id}.cache.cleanup.running"

    LAST_PLAYED_TVSHOW_TITLE = "glotzbox.last_played_tvshow_title"
    LAST_SERIES_CACHE_KEY = "glotzbox.last_series_cache_key"


PAYLOAD_CACHE_DIR = os.path.join(addon_data_dir, "payload_cache")
FAVORITES_PAYLOAD_CACHE_DIR = os.path.join(addon_data_dir, "payload_cache_fav")


def resolve_addon_handle(default: int = -1) -> int:
    try:
        argv = getattr(sys, "argv", []) or []
        if len(argv) > 1:
            h = int(argv[1])
            if h >= 0:
                return h
    except Exception:
        pass
    try:
        h = int(globals().get("addon_handle", default))
        if h >= 0:
            return h
    except Exception:
        pass
    return int(default)


def _window(window_id: int = 10000):
    return xbmcgui.Window(int(window_id))


def get_window_property(name: str, default: str = "", window_id: int = 10000) -> str:
    try:
        val = _window(window_id).getProperty(name)
        if val in (None, ""):
            return default
        return val
    except Exception:
        return default


def set_window_property(name: str, value, window_id: int = 10000) -> bool:
    try:
        _window(window_id).setProperty(name, "" if value is None else str(value))
        return True
    except Exception:
        return False


def clear_window_property(name: str, window_id: int = 10000) -> bool:
    try:
        _window(window_id).clearProperty(name)
        return True
    except Exception:
        try:
            _window(window_id).setProperty(name, "")
            return True
        except Exception:
            return False


# -------------------------------------------------------------------
# Exceptions
# -------------------------------------------------------------------
# (Keine Addon-eigenen Exceptions mehr nötig – Download-Feature wurde entfernt.)


def wait_or_abort(monitor: Optional[object], seconds: float) -> bool:
    """Abort-aware wait helper. Returns True when Kodi abort was requested."""
    try:
        sec = float(seconds or 0.0)
    except Exception:
        sec = 0.0
    if sec < 0:
        sec = 0.0

    mon = monitor
    if mon is None:
        try:
            mon = xbmc.Monitor()
        except Exception:
            mon = None

    if mon is not None:
        try:
            return bool(mon.waitForAbort(sec))
        except Exception as e:
            try:
                xbmc.log(f"[{addon_id}] wait_or_abort: waitForAbort fehlgeschlagen ({e}), fallback auf abortRequested", xbmc.LOGDEBUG)
            except Exception:
                pass
            try:
                if mon.abortRequested():
                    return True
            except Exception:
                pass

    try:
        xbmc.sleep(int(sec * 1000))
    except Exception:
        pass
    return False


# -------------------------------------------------------------------
# Helpers (Dateinamen / URL / FTP-Host Normalisierung)
# -------------------------------------------------------------------
def sanitize_filename(filename: str) -> str:
    """Return a local-safe filename segment (no traversal, no path separators)."""
    try:
        name = os.path.basename(str(filename or "").strip())
        name = re.sub(r'[\\/*?:"<>|]', "_", name)
        # Prevent parent traversal tokens ("..") from surviving sanitization.
        while ".." in name:
            name = name.replace("..", "_")
        # Avoid hidden/empty-like names consisting only of dots/spaces.
        name = name.strip().strip(".")
        return name
    except Exception:
        return ""


# -------------------------------------------------------------------
# URL helpers (Log-Redaction + Stable Keys)
# -------------------------------------------------------------------

_URL_CRED_RE = re.compile(r"(https?://)([^/@\s]+):([^/@\s]+)@", re.IGNORECASE)

# Matches Kodi pipe-notation auth headers, both URL-encoded and plain:
#   |Authorization=Basic%20dXNlcjpwYXNz
#   |Authorization=Basic dXNlcjpwYXNz
#   |authorization=basic%20...   (case-insensitive)
_PIPE_AUTH_RE = re.compile(
    r"(\|[^|]*?[Aa]uthorization\s*=\s*[Bb]asic(?:%20|\s+))[A-Za-z0-9+/=]+",
    re.IGNORECASE,
)

_SENSITIVE_QUERY_KEYS = {
    'token', 'access_token', 'auth', 'authorization', 'key', 'apikey', 'api_key',
    'session', 'sid', 'signature', 'sig', 'hmac', 'hash', 'nonce', 'expires', 'exp',
    'credential', 'credentials', 'password', 'passwd', 'pwd', 'secret', 'bearer'
}


def _mask_sensitive_query_params(raw_url: str) -> str:
    """Redact volatile/auth query values while preserving URL shape for logs."""
    try:
        base, sep, pipe = str(raw_url).partition('|')
        if not base.lower().startswith(('http://', 'https://')):
            return raw_url
        parts = urllib.parse.urlsplit(base)
        if not parts.query:
            return raw_url

        changed = False
        out_q = []
        for k, v in urllib.parse.parse_qsl(parts.query, keep_blank_values=True):
            if (k or '').strip().lower() in _SENSITIVE_QUERY_KEYS:
                out_q.append((k, '***'))
                changed = True
            else:
                out_q.append((k, v))
        if not changed:
            return raw_url

        new_query = urllib.parse.urlencode(out_q, doseq=True)
        redacted = urllib.parse.urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
        return redacted + (sep + pipe if sep else '')
    except Exception:
        return raw_url


def mask_url(url: str) -> str:
    """Redacts credentials/tokens in URLs for log output.

    Handles standard ``user:pass@host`` notation, Kodi's pipe-notation
    auth headers (``|Authorization=Basic%20<base64>``), and common auth/query
    token parameters (``token``, ``access_token``, ``sig``, ``apikey``, ...).
    """
    try:
        if not url:
            return ""
        s = str(url)
        # 1) user:pass@host  →  ***:***@host
        s = _URL_CRED_RE.sub(r"\1***:***@", s)
        # 2) |Authorization=Basic <base64>  →  |Authorization=Basic ***
        s = _PIPE_AUTH_RE.sub(r"\1***", s)
        # 3) ?token=... / ?sig=... / ?apikey=... → keep key, redact value
        s = _mask_sensitive_query_params(s)
        return s
    except Exception:
        return "***"


def details_url_key(url: str) -> str:
    """Stable key for a details_url (used for local maps/cache coordination).

    Many backends append volatile query params (token/ts/expires/sig/...) to the
    details.json URL. For caching we must **not** treat these as identity, but we
    also must avoid collisions across different series that share the same path.

    Strategy:
      - keep scheme + netloc + path
      - keep query params except a small blacklist of volatile/auth params
      - sort remaining params to make the key deterministic
      - drop fragment

    Returns the normalized URL string (NOT a hash).
    """
    try:
        u = (url or '').strip()
        if not u:
            return ''
        p = urllib.parse.urlsplit(u)
        if not p.scheme or not p.netloc:
            return u

        # Never keep embedded credentials in cache keys.
        host = p.hostname or ""
        if p.port:
            host = f"{host}:{p.port}"
        if not host:
            # Fallback: strip potential userinfo manually.
            host = str(p.netloc).split("@")[-1]

        volatile = {
            'token','ts','timestamp','t','time','expires','exp',
            'signature','sig','hash','hmac','nonce',
            'auth','key','apikey','api_key','access_token','session','sid',
            'rand','random','_','cb','cachebust','cachebuster'
        }

        q = []
        try:
            for k, v in urllib.parse.parse_qsl(p.query, keep_blank_values=True):
                if (k or '').strip().lower() in volatile:
                    continue
                q.append((k, v))
        except Exception:
            q = []

        try:
            q.sort(key=lambda kv: (kv[0], kv[1]))
        except Exception:
            pass

        query = urllib.parse.urlencode(q, doseq=True) if q else ''
        return urllib.parse.urlunsplit((p.scheme, host, p.path, query, ''))
    except Exception:
        return (url or '').strip()


def details_url_key_legacy_sha1(url: str) -> str:
    """Legacy migration helper used by older versions: sha1(full_url)."""
    try:
        return hashlib.sha1((url or "").encode("utf-8", errors="ignore")).hexdigest()
    except Exception:
        return ""

def sha1_hex(data: bytes) -> str:
    try:
        return hashlib.sha1(data or b"").hexdigest()
    except Exception:
        return ""


def read_text_file(path: str) -> Optional[str]:
    try:
        if not path or not xbmcvfs.exists(path):
            return None
        with xbmcvfs.File(path, "r") as fh:
            raw = fh.read()
        if raw is None:
            return None
        if isinstance(raw, bytes):
            raw = raw.decode("utf-8", errors="replace")
        return (raw or "").strip()
    except Exception:
        return None


def write_text_file_atomic(path: str, text: str, backup: bool = True, durable: bool = True) -> bool:
    try:
        b = (text or "").encode("utf-8", errors="replace")
        return atomic_write_bytes(path, b, backup=backup, durable=durable)
    except Exception:
        try:
            with xbmcvfs.File(path, "w") as fh:
                fh.write(text or "")
            return True
        except Exception:
            return False


def fetch_remote_sha1(url: str, timeout_s: int = 10) -> Optional[str]:
    """Fetches <url>.sha1 and returns a 40-hex digest, or None if unavailable.

    Silent by design (returns None on any problem) so callers can fall back to
    HEAD/conditional-GET without extra branching. Failures are logged at
    LOGDEBUG level to help diagnose issues like:
      - the server returning an HTML error page as the .sha1 body
      - a proxy stripping the file
      - transient 5xx during boot
    """
    try:
        if not url:
            return None
        sha_url = f"{url}.sha1"
        req = get_authenticated_request(sha_url)
        if not req:
            return None
        with retry_urlopen(req, context=get_ssl_context(), timeout=timeout_s, retries=1, retry_delay=2.0) as r:
            code = r.getcode()
            if code != 200:
                try:
                    xbmc.log(
                        f"[{addon_id}] fetch_remote_sha1: non-200 ({code}) for {mask_url(sha_url)}",
                        xbmc.LOGDEBUG,
                    )
                except Exception:
                    pass
                return None
            raw = r.read() or b""
        s = raw.decode("utf-8", errors="replace").strip()
        if len(s) == 40 and all(c in "0123456789abcdefABCDEF" for c in s):
            return s.lower()
        # Body present but not a valid hex-40 digest - very likely an HTML
        # error page, a redirect response written as text, or a truncated file.
        # Log a short preview so the Kodi log tells us *why* the sidecar is bad.
        try:
            preview = s[:40].replace("\r", " ").replace("\n", " ")
            xbmc.log(
                f"[{addon_id}] fetch_remote_sha1: invalid sha1 body for "
                f"{mask_url(sha_url)} (len={len(s)}, preview={preview!r})",
                xbmc.LOGDEBUG,
            )
        except Exception:
            pass
        return None
    except Exception as e:
        try:
            xbmc.log(
                f"[{addon_id}] fetch_remote_sha1: exception for {mask_url(url)}.sha1: {e}",
                xbmc.LOGDEBUG,
            )
        except Exception:
            pass
        return None


def _details_sha1_map_path() -> str:
    try:
        return os.path.join(addon_data_dir, "details_sha1_map.json")
    except Exception:
        return ""


def load_details_sha1_map() -> dict:
    """Returns mapping: details_url_key -> details_sha1 (from kodiSeries/seriesFav)."""
    try:
        p = _details_sha1_map_path()
        return safe_load_json_file(p, {}) if p else {}
    except Exception:
        return {}


def save_details_sha1_map(m: dict) -> bool:
    try:
        p = _details_sha1_map_path()
        if not p:
            return False
        if not isinstance(m, dict):
            m = {}
        return atomic_write_json(p, m, indent=2, durable=True, backup=True)
    except Exception:
        return False




# -------------------------------------------------------------------
# VFS/FS Helper + Atomic JSON (für alle Module nutzbar)
# -------------------------------------------------------------------

def fs_local(path):
    """translatePath Wrapper (liefert best-effort einen lokalen OS-Pfad)."""
    try:
        return xbmcvfs.translatePath(path) if path else path
    except Exception:
        return path


def fs_exists(path):
    """xbmcvfs.exists + os.path.exists fallback (wichtig wegen vfs_exists=False)."""
    if not path:
        return False
    try:
        if xbmcvfs.exists(path):
            return True
    except Exception:
        pass
    try:
        lp = fs_local(path)
        return os.path.exists(lp) if lp else False
    except Exception:
        return False


def fs_mkdirs(path):
    if not path:
        return False
    try:
        if fs_exists(path):
            return True
        if xbmcvfs.mkdirs(path):
            return True
    except Exception:
        pass
    try:
        os.makedirs(fs_local(path), exist_ok=True)
        return True
    except Exception:
        return False


def fs_delete(path):
    if not path:
        return False
    try:
        if xbmcvfs.delete(path):
            return True
    except Exception:
        pass
    try:
        lp = fs_local(path)
        if lp and os.path.isfile(lp):
            os.remove(lp)
            return True
    except Exception:
        pass
    return False


def fs_rmdir(path):
    if not path:
        return False
    try:
        if xbmcvfs.rmdir(path):
            return True
    except Exception:
        pass
    try:
        os.rmdir(fs_local(path))
        return True
    except Exception:
        return False


def fs_listdir(path):
    """Gibt (dirs, files) zurück wie xbmcvfs.listdir."""
    try:
        return xbmcvfs.listdir(path)
    except Exception:
        pass
    try:
        lp = fs_local(path)
        entries = os.listdir(lp)
        dirs = [e for e in entries if os.path.isdir(os.path.join(lp, e))]
        files = [e for e in entries if os.path.isfile(os.path.join(lp, e))]
        return dirs, files
    except Exception:
        return [], []


def fs_rename(src, dst):
    """Versucht xbmcvfs.rename, fallback os.replace auf translatePath."""
    if not src or not dst:
        return False
    try:
        if xbmcvfs.rename(src, dst):
            return True
    except Exception:
        pass
    try:
        os.replace(fs_local(src), fs_local(dst))
        return True
    except Exception:
        return False


def fsync_dir_for(path_):
    """Best-effort fsync auf das Verzeichnis (Linux rename-durability)."""
    try:
        d = os.path.dirname(path_) or "."
        if hasattr(os, "O_DIRECTORY"):
            fd = os.open(d, os.O_RDONLY | os.O_DIRECTORY)
        else:
            fd = os.open(d, os.O_RDONLY)
        try:
            os.fsync(fd)
        finally:
            os.close(fd)
    except Exception:
        pass


_ATOMIC_WRITE_LOCKS_GUARD = threading.Lock()
_ATOMIC_WRITE_LOCKS = {}

def _atomic_write_lock_for(path_: str):
    """Return a stable per-destination lock to serialize same-target writes."""
    key = str(path_ or "")
    with _ATOMIC_WRITE_LOCKS_GUARD:
        lk = _ATOMIC_WRITE_LOCKS.get(key)
        if lk is None:
            lk = threading.Lock()
            _ATOMIC_WRITE_LOCKS[key] = lk
        return lk

def _atomic_unique_tmp_path(path_: str) -> str:
    try:
        return f"{path_}.tmp.{os.getpid()}.{threading.get_ident()}.{int(time.time() * 1000000)}"
    except Exception:
        return f"{path_}.tmp.{int(time.time() * 1000000)}"


def atomic_write_bytes(dest_path, data_bytes, backup=False, durable=False):
    """
    Atomisch schreiben:
      - tmp schreiben
      - optional: fsync(tmp) + dir fsync (durable=True)
      - optional: Backup-Rotation (.bak -> .bak.prev, dest -> .bak)
      - tmp -> dest (os.replace)
    Fallback: wenn kein lokaler OS-Pfad möglich ist, nutzt es VFS-Variante (ohne fsync).

    Wichtig: gleichzeitige Writes auf dasselbe Ziel werden serialisiert und jede
    Operation bekommt einen einzigartigen tmp-Namen, damit keine Race Condition
    zwischen mehreren Schreiber-Threads entsteht.
    """
    if not dest_path or data_bytes is None:
        return False

    lock = _atomic_write_lock_for(dest_path)
    with lock:
        # Kodi/VFS -> echter Pfad fürs OS (wichtig für fsync/os.replace)
        try:
            real_dest = xbmcvfs.translatePath(dest_path)
        except Exception:
            real_dest = dest_path

        # Wenn es immer noch nach VFS aussieht, können wir nicht sauber fsyncen.
        if not isinstance(real_dest, str) or "://" in real_dest:
            try:
                tmp_path = _atomic_unique_tmp_path(dest_path)
                bak_path = dest_path + ".bak"
                bak_prev_path = dest_path + ".bak.prev"

                # Zielordner sicherstellen
                try:
                    dest_dir = os.path.dirname(dest_path)
                    if dest_dir:
                        fs_mkdirs(dest_dir)
                except Exception:
                    pass

                # tmp löschen (unique tmp -> nur best-effort)
                try:
                    if fs_exists(tmp_path):
                        fs_delete(tmp_path)
                except Exception:
                    pass

                # tmp schreiben (VFS)
                with xbmcvfs.File(tmp_path, "wb") as f:
                    f.write(data_bytes)

                # Guard against 0-byte tmp files
                try:
                    tmp_size = None
                    try:
                        real_tmp = fs_local(tmp_path)
                        if real_tmp and os.path.isabs(real_tmp) and os.path.exists(real_tmp):
                            tmp_size = os.path.getsize(real_tmp)
                    except Exception:
                        tmp_size = None
                    if tmp_size is None:
                        st = xbmcvfs.Stat(tmp_path)
                        sz_attr = getattr(st, 'st_size', None)
                        tmp_size = int(sz_attr() if callable(sz_attr) else (sz_attr or 0))
                    if data_bytes and int(tmp_size or 0) == 0:
                        try:
                            fs_delete(tmp_path)
                        except Exception:
                            pass
                        return False
                except Exception:
                    pass

                # Backup
                if backup:
                    try:
                        if fs_exists(bak_path):
                            try:
                                if fs_exists(bak_prev_path):
                                    fs_delete(bak_prev_path)
                            except Exception:
                                pass
                            fs_rename(bak_path, bak_prev_path)

                        if fs_exists(dest_path):
                            fs_rename(dest_path, bak_path)
                    except Exception:
                        raise

                # tmp -> dest
                if fs_rename(tmp_path, dest_path):
                    try:
                        dest_size = None
                        try:
                            real_dest2 = fs_local(dest_path)
                            if real_dest2 and os.path.isabs(real_dest2) and os.path.exists(real_dest2):
                                dest_size = os.path.getsize(real_dest2)
                        except Exception:
                            dest_size = None
                        if dest_size is None:
                            st2 = xbmcvfs.Stat(dest_path)
                            sz2_attr = getattr(st2, 'st_size', None)
                            dest_size = int(sz2_attr() if callable(sz2_attr) else (sz2_attr or 0))
                        if data_bytes and int(dest_size or 0) == 0:
                            try:
                                fs_delete(dest_path)
                            except Exception:
                                pass
                            try:
                                if fs_exists(bak_path):
                                    xbmcvfs.rename(bak_path, dest_path)
                            except Exception:
                                pass
                            return False
                    except Exception:
                        pass

                    try:
                        if fs_exists(bak_prev_path):
                            fs_delete(bak_prev_path)
                    except Exception:
                        pass
                    return True

                # overwrite-fallback
                try:
                    if fs_exists(dest_path):
                        fs_delete(dest_path)
                except Exception:
                    pass

                if fs_rename(tmp_path, dest_path):
                    try:
                        if fs_exists(bak_prev_path):
                            fs_delete(bak_prev_path)
                    except Exception:
                        pass
                    return True

                # copy-fallback
                try:
                    if xbmcvfs.copy(tmp_path, dest_path):
                        try:
                            fs_delete(tmp_path)
                        except Exception:
                            pass
                        try:
                            if fs_exists(bak_prev_path):
                                fs_delete(bak_prev_path)
                        except Exception:
                            pass
                        return True
                except Exception:
                    pass

                return False

            except Exception as e:
                xbmc.log(f"[{addon_id}] atomic_write_bytes: VFS write failed ({dest_path}): {e}", xbmc.LOGWARNING)
                return False

        # ----- OS-durable branch -----
        tmp = _atomic_unique_tmp_path(real_dest)
        bak = real_dest + ".bak"
        bak_prev = real_dest + ".bak.prev"

        try:
            # Zielordner sicherstellen
            try:
                d = os.path.dirname(real_dest)
                if d and not os.path.exists(d):
                    os.makedirs(d, exist_ok=True)
            except Exception:
                pass

            # tmp aufräumen (unique tmp -> nur best-effort)
            try:
                if os.path.exists(tmp):
                    os.remove(tmp)
            except Exception:
                pass

            # 1) TMP schreiben (+ optional fsync)
            with open(tmp, "wb") as fh:
                fh.write(data_bytes)
                fh.flush()
                if durable:
                    os.fsync(fh.fileno())

            # 2) Backup Rotation
            if backup:
                try:
                    if os.path.exists(bak):
                        try:
                            if os.path.exists(bak_prev):
                                os.remove(bak_prev)
                        except Exception:
                            pass
                        os.replace(bak, bak_prev)
                except Exception:
                    pass

                if os.path.exists(real_dest):
                    os.replace(real_dest, bak)

            # 3) TMP -> dest (atomic replace)
            os.replace(tmp, real_dest)

            # 4) dir fsync
            if durable:
                fsync_dir_for(real_dest)

            # 5) bak_prev weg (bak bleibt als Recovery)
            try:
                if os.path.exists(bak_prev):
                    os.remove(bak_prev)
            except Exception:
                pass

            return True

        except Exception as e:
            xbmc.log(f"[{addon_id}] atomic_write_bytes: failed ({dest_path}): {e}", xbmc.LOGWARNING)
            xbmc.log(traceback.format_exc(), xbmc.LOGDEBUG)

            # rollback best-effort
            if backup:
                try:
                    if (not os.path.exists(real_dest)) and os.path.exists(bak):
                        os.replace(bak, real_dest)
                except Exception:
                    pass
                try:
                    if os.path.exists(bak_prev) and not os.path.exists(bak):
                        os.replace(bak_prev, bak)
                except Exception:
                    pass

            return False

def atomic_write_json(dest_path, obj, indent=None, encoding="utf-8", durable=True, backup=True):
    """JSON atomisch schreiben (default: backup=True + durable=True)."""
    try:
        s = json.dumps(obj, ensure_ascii=False, indent=indent)
        data = s.encode(encoding, errors="replace")
        return atomic_write_bytes(dest_path, data, backup=backup, durable=durable)
    except Exception as e:
        xbmc.log(f"[{addon_id}] atomic_write_json: failed ({dest_path}): {e}", xbmc.LOGWARNING)
        xbmc.log(traceback.format_exc(), xbmc.LOGDEBUG)
        return False




def safe_load_json_file(path, default):
    """
    Lädt JSON robust:
      1) final
      2) .bak (recovered -> final)
      3) .tmp (recovered -> final)
    Gibt default zurück, wenn alles fehlschlägt.
    """
    if not path:
        return default

    final_p = path
    bak_p = path + ".bak"
    tmp_p = path + ".tmp"

    def _read_json(p):
        if not fs_exists(p):
            return None
        try:
            with xbmcvfs.File(p, "rb") as f:
                raw = f.read()

            if not raw:
                return None

            if isinstance(raw, str):
                raw_b = raw.encode("utf-8", "replace")
            else:
                raw_b = raw

            # UTF-8 BOM entfernen (falls vorhanden)
            if raw_b.startswith(b"\xef\xbb\xbf"):
                raw_b = raw_b[3:]

            txt = raw_b.decode("utf-8", "replace").strip()
            if not txt:
                return None

            return json.loads(txt)
        except Exception as e:
            xbmc.log(f"[{addon_id}] safe_load_json_file: read failed ({p}): {e}", xbmc.LOGDEBUG)
            return None

    data = _read_json(final_p)
    if data is not None:
        return data

    data_bak = _read_json(bak_p)
    if data_bak is not None:
        xbmc.log(f"[{addon_id}] safe_load_json_file: RECOVER from .bak -> {final_p}", xbmc.LOGWARNING)
        try:
            try:
                if fs_exists(final_p):
                    fs_delete(final_p)
            except Exception:
                pass
            try:
                xbmcvfs.copy(bak_p, final_p)
            except Exception:
                fs_rename(bak_p, final_p)
        except Exception:
            pass
        return data_bak

    data_tmp = _read_json(tmp_p)
    if data_tmp is not None:
        xbmc.log(f"[{addon_id}] safe_load_json_file: RECOVER from .tmp -> {final_p}", xbmc.LOGWARNING)
        try:
            try:
                if fs_exists(final_p):
                    fs_delete(final_p)
            except Exception:
                pass
            if fs_rename(tmp_p, final_p):
                return data_tmp
            try:
                xbmcvfs.copy(tmp_p, final_p)
            except Exception:
                pass
        except Exception:
            pass
        return data_tmp

    return default


# Backward-Compat:
# Einige Module/Service-Versionen erwarten common.safe_read_json().
# Wir mappen das bewusst auf safe_load_json_file(), damit ältere Aufrufer
# nicht crashen, wenn wir intern die Namen vereinheitlichen.
def safe_read_json(path, default=None):
    """Alias für :func:`safe_load_json_file` (historischer Funktionsname)."""
    return safe_load_json_file(path, default)



def strip_url_credentials(u: str) -> str:
    """
    Entfernt user:pass@ aus einer URL, damit Watched-Keys stabil bleiben,
    auch wenn sich Credentials ändern.
    """
    try:
        p = urllib.parse.urlsplit(u)
        if not p.scheme or not p.netloc:
            return u
        host = p.hostname or p.netloc.split("@")[-1]
        if p.port:
            host = f"{host}:{p.port}"
        return urllib.parse.urlunsplit((p.scheme, host, p.path, p.query, p.fragment))
    except Exception:
        return u


def normalize_media_key(key: str) -> str:
    """
    Normalisiert einen Identifier-Key (für watched_status Keys):
      - strip
      - unquote
      - credentials entfernen (user:pass@)
    """
    k = (key or "").strip()
    if not k:
        return ""
    try:
        k = urllib.parse.unquote(k)
    except Exception:
        pass
    return strip_url_credentials(k)


def watched_entry(ws: dict, key: str):
    """
    Holt einen watched_status Eintrag robust:
      - versucht normalisierten Key
      - fallback: raw key
      - last resort: scan -> wenn alte Keys gespeichert wurden (z.B. mit credentials)
    """
    if not isinstance(ws, dict):
        return None

    raw = (key or "").strip()
    k_norm = normalize_media_key(raw)

    # 1) direkt normalisiert
    if k_norm and k_norm in ws:
        return ws[k_norm]

    # 2) direkt raw
    if raw and raw in ws:
        return ws[raw]

    # 3) last resort: scan über alle keys (für alte Einträge mit user:pass@ usw.)
    if k_norm:
        try:
            for k, v in ws.items():
                if isinstance(k, str) and normalize_media_key(k) == k_norm:
                    return v
        except Exception:
            pass

    return None




def get_filename_from_url(url, fallback_name="download", use_extension_only=False):
    """
    Liefert Dateinamen aus URL.
    use_extension_only=True => nur Extension behalten, Name = fallback_name.
    """
    try:
        path = urllib.parse.urlparse(url).path
        filename = os.path.basename(path)
        if use_extension_only:
            _, ext = os.path.splitext(filename)
            return fallback_name + (ext if ext else ".jpg")
        return filename if filename else fallback_name + ".mkv"
    except Exception:
        return fallback_name + (".mkv" if not use_extension_only else ".jpg")



# -------------------------------------------------------------------
# Image handling (Kodi Texture-Cache)
# -------------------------------------------------------------------
# Artwork wird ausschliesslich ueber Kodis eigenen Texture-Cache verwaltet.
# Remote-URLs werden direkt an ListItem.setArt() uebergeben – Kodi laedt
# die Bilder beim ersten Anzeigen herunter und cached sie automatisch in
# ~/.kodi/userdata/Thumbnails/.  Ein eigener Addon-Cache ist nicht noetig
# und wurde entfernt, um doppelten Speicherverbrauch zu vermeiden.
# -------------------------------------------------------------------

def resolve_artwork_for_listing(url: str, cache_dir: Optional[str] = None, prefer_cache: bool = False) -> str:
    """Artwork-Resolver fuer Kodi-ListItems.

    Gibt immer die Original-URL zurueck, damit Kodi seinen eigenen
    Texture-Cache nutzt.  Die Parameter cache_dir und prefer_cache
    werden aus Kompatibilitaet akzeptiert aber ignoriert.
    """
    try:
        if not isinstance(url, str) or not url:
            return url or ""
        return url
    except Exception:
        return url or ""


def get_cache_filename(url: str, cache_dir: Optional[str] = None, fallback_name: str = "image") -> str:
    """Stub – kein Addon-eigener Image-Cache mehr.  Gibt leeren String zurueck."""
    return ""


def get_cached_image(url: str, cache_dir: Optional[str] = None) -> str:
    """Stub – kein Addon-eigener Image-Cache mehr.  Gibt leeren String zurueck."""
    return ""


def download_and_cache_image(url: str, cache_dir: Optional[str] = None, timeout_s: int = 20, force: bool = False) -> str:
    """Stub – kein Addon-eigener Image-Cache mehr.  Gibt leeren String zurueck.

    Kodi laedt Artwork automatisch ueber seinen Texture-Cache, wenn die
    Remote-URL via ListItem.setArt() uebergeben wird.
    """
    return ""


# -------------------------------------------------------------------
# Kodi Texture-Cache Vorwaermung (Prefetch)
# -------------------------------------------------------------------
# Der Service kann Bilder proaktiv in Kodis eigenen Texture-Cache
# (~/.kodi/userdata/Thumbnails/) schreiben, damit sie beim ersten
# Oeffnen einer Liste sofort angezeigt werden.
#
# Ablauf:
#   1) Pruefen ob die URL bereits im Texture-Cache ist (SQLite)
#   2) Bild per HTTP herunterladen (mit Addon-Auth)
#   3) In Kodis Thumbnails-Ordner mit dem korrekten CRC-Dateinamen speichern
#   4) Eintrag in Kodis Textures-DB schreiben
# -------------------------------------------------------------------

import sqlite3
import binascii

_KODI_TEXTURES_DB_PATH = None
_KODI_TEXTURES_DB_LOCK = threading.Lock()


def _kodi_texture_crc(url: str) -> str:
    """CRC32-Hash wie Kodi ihn intern fuer Texture-Cache-Dateinamen berechnet."""
    try:
        crc = binascii.crc32(url.lower().encode("utf-8")) & 0xFFFFFFFF
        return f"{crc:08x}"
    except Exception:
        return ""


def _kodi_thumbnails_dir() -> str:
    try:
        return xbmcvfs.translatePath("special://thumbnails/")
    except Exception:
        return ""


def _kodi_textures_db_path() -> str:
    """Findet Kodis Textures-Datenbank (auto-detect Version)."""
    global _KODI_TEXTURES_DB_PATH
    if _KODI_TEXTURES_DB_PATH:
        return _KODI_TEXTURES_DB_PATH
    try:
        db_dir = xbmcvfs.translatePath("special://database/")
        for ver in (13, 14, 15, 12):
            p = os.path.join(db_dir, f"Textures{ver}.db")
            if os.path.isfile(p):
                _KODI_TEXTURES_DB_PATH = p
                return p
    except Exception:
        pass
    return ""


def _detect_image_ext_from_bytes(data: bytes) -> str:
    """Erkennt Bildformat aus Magic Bytes (JPEG, PNG, GIF, WebP, BMP, AVIF)."""
    try:
        if not data or len(data) < 12:
            return ".jpg"
        if data[:3] == b"\xff\xd8\xff":
            return ".jpg"
        if data[:8] == b"\x89PNG\r\n\x1a\n":
            return ".png"
        if data[:6] in (b"GIF87a", b"GIF89a"):
            return ".gif"
        if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
            return ".webp"
        if data[:2] == b"BM":
            return ".bmp"
        if b"ftypavif" in data[:32] or b"ftypavis" in data[:32]:
            return ".avif"
    except Exception:
        pass
    return ".jpg"


def is_in_kodi_texture_cache(url: str, db_conn=None) -> bool:
    """Prueft ob eine URL in Kodis Texture-Cache liegt UND die Datei existiert.

    Wenn ein DB-Eintrag vorhanden ist aber die Datei fehlt oder leer ist,
    wird der verwaiste DB-Eintrag entfernt und False zurueckgegeben.
    """
    try:
        if not url:
            return False
        thumb_dir = _kodi_thumbnails_dir()

        def _check(conn, close_after=False):
            try:
                cursor = conn.execute("SELECT id, cachedurl FROM texture WHERE url=? LIMIT 1", (url,))
                row = cursor.fetchone()
                if row is None:
                    return False
                tex_id, cached_url = row
                # Pruefen ob die Datei wirklich existiert
                if cached_url and thumb_dir:
                    full_path = os.path.join(thumb_dir, cached_url)
                    try:
                        if os.path.isfile(full_path) and os.path.getsize(full_path) > 0:
                            return True
                    except Exception:
                        pass
                    # DB-Eintrag da aber Datei fehlt/leer -> aufraumen
                    try:
                        conn.execute("DELETE FROM sizes WHERE idtexture=?", (tex_id,))
                    except Exception:
                        pass
                    try:
                        conn.execute("DELETE FROM texture WHERE id=?", (tex_id,))
                        conn.commit()
                    except Exception:
                        pass
                    return False
                return True  # kein thumb_dir -> kann nicht pruefen, vertraue DB
            finally:
                if close_after:
                    try:
                        conn.close()
                    except Exception:
                        pass

        if db_conn:
            return _check(db_conn, close_after=False)

        db_path = _kodi_textures_db_path()
        if not db_path:
            return False
        conn = sqlite3.connect(db_path, timeout=5)
        return _check(conn, close_after=True)
    except Exception:
        return False


def precache_image_in_kodi(url: str, timeout_s: int = 15) -> bool:
    """Laedt ein Bild herunter und schreibt es direkt in Kodis Texture-Cache.

    - Kein Addon-eigener Cache-Ordner noetig
    - Kein doppelter Speicherverbrauch
    - Bild ist beim naechsten Listing-Aufbau sofort verfuegbar

    Returns True wenn das Bild gecacht wurde (oder bereits gecacht war).
    """
    try:
        if not url or not isinstance(url, str):
            return False
        u = url.strip()
        if not u.startswith(("http://", "https://")):
            return False

        db_path = _kodi_textures_db_path()
        thumb_dir = _kodi_thumbnails_dir()
        if not db_path or not thumb_dir:
            return False

        # Bereits gecacht?
        if is_in_kodi_texture_cache(u):
            return True

        # Bild herunterladen (mit Addon-Auth)
        req = get_authenticated_request(u)
        if not req:
            return False

        ctx = get_ssl_context()
        try:
            with retry_urlopen(req, context=ctx, timeout=max(5, int(timeout_s)), retries=1, retry_delay=2.0) as resp:
                code = getattr(resp, "getcode", lambda: 200)()
                if code != 200:
                    return False
                data = resp.read() or b""
        except urllib.error.HTTPError:
            return False

        if not data or len(data) < 16:
            return False

        # Dateiendung aus Magic Bytes erkennen
        ext = _detect_image_ext_from_bytes(data)

        # CRC-Hash berechnen (wie Kodi intern)
        crc = _kodi_texture_crc(u)
        if not crc:
            return False

        # Pfad im Thumbnails-Ordner: z.B. Thumbnails/a/a1b2c3d4.jpg
        subdir = crc[0]
        cached_relpath = f"{subdir}/{crc}{ext}"
        full_dir = os.path.join(thumb_dir, subdir)
        full_path = os.path.join(full_dir, f"{crc}{ext}")

        # Datei atomisch schreiben (nutzt den bewährten atomic_write_bytes Writer)
        if not atomic_write_bytes(full_path, data, backup=False, durable=True):
            return False

        # Verifikation: Datei muss existieren und >0 Byte sein, bevor DB-Eintrag kommt
        try:
            if not os.path.isfile(full_path) or os.path.getsize(full_path) <= 0:
                return False
        except Exception:
            return False

        # Eintrag in Kodis Textures-DB schreiben
        with _KODI_TEXTURES_DB_LOCK:
            try:
                conn = sqlite3.connect(db_path, timeout=10)
                try:
                    conn.execute(
                        "INSERT OR IGNORE INTO texture (url, cachedurl, imagehash, lasthashcheck) "
                        "VALUES (?, ?, '', datetime('now'))",
                        (u, cached_relpath),
                    )
                    conn.commit()
                finally:
                    conn.close()
            except Exception as e:
                xbmc.log(f"[{addon_id}] precache_image DB: {e}", xbmc.LOGDEBUG)

        return True

    except Exception as e:
        xbmc.log(f"[{addon_id}] precache_image failed: {mask_url(url)} -> {e}", xbmc.LOGDEBUG)
        return False


def precache_images_batch(urls, timeout_s: int = 15) -> tuple:
    """Cached mehrere Bild-URLs in Kodis Texture-Cache (batch-optimiert).

    Downloads happen OUTSIDE the DB lock to avoid blocking other DB users
    while a slow HTTP request is pending.

    Returns: (downloaded, skipped, failed)
    """
    if not urls:
        return 0, 0, 0

    db_path = _kodi_textures_db_path()
    thumb_dir = _kodi_thumbnails_dir()
    if not db_path or not thumb_dir:
        return 0, 0, len(urls)

    downloaded = 0
    skipped = 0
    failed = 0

    # --- Phase 1: DB check (with lock) -> find URLs that need downloading ---
    urls_to_download = []  # list of stripped URL strings
    with _KODI_TEXTURES_DB_LOCK:
        try:
            conn = sqlite3.connect(db_path, timeout=10)
        except Exception:
            return 0, 0, len(urls)

        try:
            for url in urls:
                try:
                    u = (url or "").strip()
                    if not u or not u.startswith(("http://", "https://")):
                        failed += 1
                        continue

                    cursor = conn.execute("SELECT id, cachedurl FROM texture WHERE url=? LIMIT 1", (u,))
                    row = cursor.fetchone()
                    if row:
                        tex_id, cached_url = row
                        cached_ok = False
                        if cached_url and thumb_dir:
                            fp = os.path.join(thumb_dir, cached_url)
                            try:
                                cached_ok = os.path.isfile(fp) and os.path.getsize(fp) > 0
                            except Exception:
                                cached_ok = False
                        else:
                            cached_ok = True
                        if cached_ok:
                            skipped += 1
                            continue
                        # DB-Eintrag da aber Datei fehlt -> entfernen, neu laden
                        try:
                            conn.execute("DELETE FROM sizes WHERE idtexture=?", (tex_id,))
                        except Exception:
                            pass
                        try:
                            conn.execute("DELETE FROM texture WHERE id=?", (tex_id,))
                            conn.commit()
                        except Exception:
                            pass

                    urls_to_download.append(u)

                except Exception:
                    failed += 1
        finally:
            try:
                conn.close()
            except Exception:
                pass

    if not urls_to_download:
        return downloaded, skipped, failed

    # --- Phase 2: Download images (NO lock held) ---
    fetched = []  # list of (url, data_bytes)
    ctx = get_ssl_context()
    for u in urls_to_download:
        try:
            req = get_authenticated_request(u)
            if not req:
                failed += 1
                continue

            try:
                with retry_urlopen(req, context=ctx, timeout=max(5, int(timeout_s)), retries=1, retry_delay=2.0) as resp:
                    if getattr(resp, "getcode", lambda: 200)() != 200:
                        failed += 1
                        continue
                    data = resp.read() or b""
            except Exception:
                failed += 1
                continue

            if not data or len(data) < 16:
                failed += 1
                continue

            fetched.append((u, data))

        except Exception:
            failed += 1

    if not fetched:
        return downloaded, skipped, failed

    # --- Phase 3: Write files + DB insert (with lock) ---
    with _KODI_TEXTURES_DB_LOCK:
        try:
            conn = sqlite3.connect(db_path, timeout=10)
        except Exception:
            failed += len(fetched)
            return downloaded, skipped, failed

        try:
            for u, data in fetched:
                try:
                    # Nochmal pruefen ob zwischenzeitlich gecacht (anderer Thread)
                    cursor = conn.execute("SELECT id, cachedurl FROM texture WHERE url=? LIMIT 1", (u,))
                    row = cursor.fetchone()
                    if row:
                        tex_id, cached_url = row
                        # Datei wirklich vorhanden + nicht leer?
                        file_ok = False
                        if cached_url and thumb_dir:
                            fp = os.path.join(thumb_dir, cached_url)
                            try:
                                file_ok = os.path.isfile(fp) and os.path.getsize(fp) > 0
                            except Exception:
                                file_ok = False
                        else:
                            file_ok = True  # kann nicht pruefen
                        if file_ok:
                            skipped += 1
                            continue
                        # DB-Eintrag da aber Datei fehlt -> entfernen, frisch schreiben
                        try:
                            conn.execute("DELETE FROM sizes WHERE idtexture=?", (tex_id,))
                        except Exception:
                            pass
                        try:
                            conn.execute("DELETE FROM texture WHERE id=?", (tex_id,))
                            conn.commit()
                        except Exception:
                            pass

                    ext = _detect_image_ext_from_bytes(data)
                    crc = _kodi_texture_crc(u)
                    if not crc:
                        failed += 1
                        continue

                    subdir = crc[0]
                    cached_relpath = f"{subdir}/{crc}{ext}"
                    full_path = os.path.join(thumb_dir, subdir, f"{crc}{ext}")

                    if not atomic_write_bytes(full_path, data, backup=False, durable=True):
                        failed += 1
                        continue

                    try:
                        if not os.path.isfile(full_path) or os.path.getsize(full_path) <= 0:
                            failed += 1
                            continue
                    except Exception:
                        failed += 1
                        continue

                    conn.execute(
                        "INSERT OR IGNORE INTO texture (url, cachedurl, imagehash, lasthashcheck) "
                        "VALUES (?, ?, '', datetime('now'))",
                        (u, cached_relpath),
                    )
                    conn.commit()
                    downloaded += 1

                except Exception:
                    failed += 1

        finally:
            try:
                conn.close()
            except Exception:
                pass

    return downloaded, skipped, failed


def cleanup_orphan_kodi_textures(keep_urls: set, server_prefix: str) -> int:
    """Entfernt verwaiste Bilder aus Kodis Texture-Cache.

    Loescht nur Texturen deren URL mit server_prefix beginnt UND
    nicht mehr in keep_urls enthalten ist.  So werden niemals Texturen
    von anderen Quellen (Skins, andere Addons) angefasst.

    Args:
        keep_urls: Set aller aktuell gueltigen Artwork-URLs
        server_prefix: z.B. "https://meinserver.de" – nur Texturen
                       mit diesem Praefix werden geprueft

    Returns:
        Anzahl entfernter Texturen
    """
    if not server_prefix:
        return 0

    db_path = _kodi_textures_db_path()
    thumb_dir = _kodi_thumbnails_dir()
    if not db_path or not thumb_dir:
        return 0

    removed = 0
    with _KODI_TEXTURES_DB_LOCK:
        try:
            conn = sqlite3.connect(db_path, timeout=10)
            try:
                cursor = conn.execute(
                    "SELECT id, url, cachedurl FROM texture WHERE url LIKE ?",
                    (f"{server_prefix}%",),
                )
                rows = cursor.fetchall()

                for tex_id, tex_url, cached_url in rows:
                    if tex_url in keep_urls:
                        continue

                    # Cached-Datei loeschen
                    if cached_url and thumb_dir:
                        full_path = os.path.join(thumb_dir, cached_url)
                        try:
                            if os.path.isfile(full_path):
                                os.remove(full_path)
                        except Exception:
                            pass

                    # sizes-Tabelle bereinigen (best-effort, Tabelle existiert nicht immer)
                    try:
                        conn.execute("DELETE FROM sizes WHERE idtexture=?", (tex_id,))
                    except Exception:
                        pass

                    # texture-Eintrag loeschen
                    try:
                        conn.execute("DELETE FROM texture WHERE id=?", (tex_id,))
                    except Exception:
                        pass
                    removed += 1

                if removed:
                    conn.commit()
            finally:
                conn.close()
        except Exception as e:
            try:
                xbmc.log(f"[{addon_id}] cleanup_orphan_kodi_textures: {e}", xbmc.LOGDEBUG)
            except Exception:
                pass

    return removed


def normalize_ftp_host_basepath():
    """
    Einheitliche Normalisierung wie in service.py/movies.py/series.py.
    Returns: (host_final, base_path, scheme)
    """
    ftp_host = (addon.getSetting("ftp_host") or "").strip()
    base_path_setting = (addon.getSetting("ftp_base_path") or "").strip()

    scheme = "https"
    if ftp_host.lower().startswith("http://"):
        try:
            xbmc.log(
                f"[{addon_id}] HTTP-Konfiguration erkannt - automatisch auf HTTPS umgestellt.",
                xbmc.LOGWARNING,
            )
        except Exception:
            pass

    # scheme entfernen
    if ftp_host.startswith(("http://", "https://")):
        ftp_host = ftp_host.split("://", 1)[1]

    # evtl. user@ rauswerfen
    if "@" in ftp_host:
        ftp_host = ftp_host.split("@")[-1]

    # host isolieren
    host_final = ftp_host.split("/")[0].split("?")[0].split("#")[0].rstrip(":/ ")

    # base_path
    if base_path_setting and base_path_setting != "/":
        base_path = base_path_setting.rstrip("/") + "/"
    else:
        base_path = "/user/downloads/kodiAddon/"

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

    return host_final, base_path, scheme


# -------------------------------------------------------------------
# URL-Erstellung
# -------------------------------------------------------------------
# -------------------------------------------------------------------
# Medien-Sortierung (Addon-eigene Persistenz, unabhängig von Kodi-Viewstates)
# -------------------------------------------------------------------
SORT_CONFIG_FILENAMES = {
    "movies": "movies_sort.json",
    "series": "series_sort.json",
}
_SORT_ALLOWED_BY = {"added", "imdb"}
_SORT_ALLOWED_ORDER = {"asc", "desc"}


def _sort_config_path(kind: str) -> str:
    try:
        filename = SORT_CONFIG_FILENAMES.get(str(kind or "").strip().lower())
        if not filename:
            return ""
        return os.path.join(addon_data_dir, filename)
    except Exception:
        return ""


def _default_sort_config(kind: str) -> dict:
    return {"sort_by": "added", "sort_order": "desc"}


def _sanitize_sort_config(kind: str, cfg) -> dict:
    if not isinstance(cfg, dict):
        cfg = {}
    sort_by = str(cfg.get("sort_by", "added") or "added").strip().lower()
    if sort_by not in _SORT_ALLOWED_BY:
        sort_by = "added"
    sort_order = str(cfg.get("sort_order", "desc") or "desc").strip().lower()
    if sort_order not in _SORT_ALLOWED_ORDER:
        sort_order = "desc"
    return {"sort_by": sort_by, "sort_order": sort_order}


def load_sort_config(kind: str) -> dict:
    kind = str(kind or "").strip().lower()
    path = _sort_config_path(kind)
    default_cfg = _default_sort_config(kind)
    if not path:
        return dict(default_cfg)

    cfg = safe_load_json_file(path, None)
    created = False
    if not isinstance(cfg, dict):
        cfg = dict(default_cfg)
        created = True

    sanitized = _sanitize_sort_config(kind, cfg)
    if created or sanitized != cfg:
        try:
            atomic_write_json(path, sanitized, indent=2, durable=True, backup=True)
            xbmc.log(f"[{addon_id}] {kind}_sort.json {'created' if created else 'updated'}: {sanitized}", xbmc.LOGINFO)
        except Exception as e:
            xbmc.log(f"[{addon_id}] {kind}_sort.json write failed ({path}): {e}", xbmc.LOGWARNING)
    else:
        try:
            xbmc.log(f"[{addon_id}] {kind}_sort.json loaded: {sanitized}", xbmc.LOGINFO)
        except Exception:
            pass
    return sanitized


def save_sort_config(kind: str, sort_by: str = "added", sort_order: str = "desc") -> dict:
    kind = str(kind or "").strip().lower()
    cfg = _sanitize_sort_config(kind, {"sort_by": sort_by, "sort_order": sort_order})
    path = _sort_config_path(kind)
    if path:
        try:
            atomic_write_json(path, cfg, indent=2, durable=True, backup=True)
            xbmc.log(f"[{addon_id}] {kind}_sort.json saved: {cfg}", xbmc.LOGINFO)
        except Exception as e:
            xbmc.log(f"[{addon_id}] {kind}_sort.json save failed ({path}): {e}", xbmc.LOGWARNING)
    return cfg


def media_sort_label(sort_by: str = "added", sort_order: str = "desc") -> str:
    sort_by = str(sort_by or "added").strip().lower()
    sort_order = str(sort_order or "desc").strip().lower()
    if sort_by == "added" and sort_order == "asc":
        return "Hinzugefügt (älteste zuerst)"
    if sort_by == "imdb" and sort_order == "asc":
        return "IMDb-Bewertung (tiefste zuerst)"
    if sort_by == "imdb" and sort_order == "desc":
        return "IMDb-Bewertung (höchste zuerst)"
    return "Hinzugefügt (neueste zuerst)"


def prompt_sort_config_change(kind: str):
    kind = str(kind or "").strip().lower()
    cfg = load_sort_config(kind)
    options = [
        ("added", "desc", "Hinzugefügt (neueste zuerst)"),
        ("added", "asc", "Hinzugefügt (älteste zuerst)"),
        ("imdb", "desc", "IMDb-Bewertung (höchste zuerst)"),
        ("imdb", "asc", "IMDb-Bewertung (tiefste zuerst)"),
    ]
    labels = [opt[2] for opt in options]
    current = (cfg.get("sort_by", "added"), cfg.get("sort_order", "desc"))
    preselect = 0
    for idx, opt in enumerate(options):
        if (opt[0], opt[1]) == current:
            preselect = idx
            break
    heading = "Filme sortieren" if kind == "movies" else "Serien sortieren"
    try:
        xbmc.log(f"[{addon_id}] {kind}_sort.json selector opened: current={current}", xbmc.LOGINFO)
    except Exception:
        pass
    try:
        choice = xbmcgui.Dialog().select(heading, labels, preselect=preselect)
    except TypeError:
        choice = xbmcgui.Dialog().select(heading, labels)
    if choice is None or int(choice) < 0:
        try:
            xbmc.log(f"[{addon_id}] {kind}_sort.json selector cancelled", xbmc.LOGINFO)
        except Exception:
            pass
        return None
    chosen = options[int(choice)]
    try:
        xbmc.log(f"[{addon_id}] {kind}_sort.json selector choice: sort_by={chosen[0]} sort_order={chosen[1]}", xbmc.LOGINFO)
    except Exception:
        pass
    return save_sort_config(kind, chosen[0], chosen[1])


def parse_added_datetime(value) -> datetime:
    epoch = datetime(1970, 1, 1)
    try:
        if isinstance(value, datetime):
            dt = value
            if dt.tzinfo is not None:
                return dt.astimezone(timezone.utc).replace(tzinfo=None)
            return dt
    except Exception:
        pass

    try:
        s = str(value or "").strip()
    except Exception:
        s = ""
    if not s:
        return epoch

    iso_candidate = s
    if iso_candidate.endswith("Z"):
        iso_candidate = iso_candidate[:-1] + "+00:00"
    try:
        dt = datetime.fromisoformat(iso_candidate)
        if dt.tzinfo is not None:
            dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
        return dt
    except Exception:
        pass

    normalized = s.replace("T", " ").replace("Z", "").strip()
    for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
        try:
            return datetime.strptime(normalized, fmt)
        except Exception:
            continue
    return epoch


def normalize_dateadded_string(value) -> str:
    dt = parse_added_datetime(value)
    if dt == datetime(1970, 1, 1):
        try:
            raw = str(value or "").strip()
        except Exception:
            raw = ""
        return raw.replace("T", " ").replace("Z", "").strip()
    return dt.strftime("%Y-%m-%d %H:%M:%S")


def build_added_sorttitle(value, title: str = "", descending: bool = True) -> str:
    dt = parse_added_datetime(value)
    try:
        ts = int(dt.timestamp())
    except Exception:
        ts = 0
    ts = max(0, int(ts or 0))
    rank = (9999999999 - ts) if descending else ts
    return f"{rank:010d} {(title or '').strip()}".strip()


def parse_rating_value(value):
    try:
        if value is None:
            return None
        if isinstance(value, (int, float)):
            return float(value)
        s = str(value).strip().replace(",", ".")
        if not s:
            return None
        return float(s)
    except Exception:
        return None


def _sort_title_value(item) -> str:
    try:
        return str((item or {}).get("name", "") or "").casefold()
    except Exception:
        try:
            return str((item or {}).get("name", "") or "").lower()
        except Exception:
            return ""


def _rating_value_from_item(item) -> float | None:
    try:
        return parse_rating_value((item or {}).get("imdbRating", None))
    except Exception:
        return None


def sort_media_items(items, kind: str, cfg=None):
    cfg = _sanitize_sort_config(kind, cfg or load_sort_config(kind))
    sort_by = cfg.get("sort_by", "added")
    sort_order = cfg.get("sort_order", "desc")
    data = list(items or [])

    if sort_by == "imdb":
        def imdb_key(item):
            rating = _rating_value_from_item(item)
            dt = parse_added_datetime((item or {}).get("added", ""))
            try:
                ts = int(dt.timestamp())
            except Exception:
                ts = 0
            missing = 1 if rating is None else 0
            if sort_order == "asc":
                return (missing, float(rating or 0.0), -ts, _sort_title_value(item))
            return (missing, -float(rating or 0.0), -ts, _sort_title_value(item))

        data.sort(key=imdb_key)
    else:
        def added_key(item):
            dt = parse_added_datetime((item or {}).get("added", ""))
            try:
                ts = int(dt.timestamp())
            except Exception:
                ts = 0
            missing = 1 if dt == datetime(1970, 1, 1) else 0
            if sort_order == "asc":
                return (missing, ts, _sort_title_value(item))
            return (missing, -ts, _sort_title_value(item))

        data.sort(key=added_key)

    try:
        xbmc.log(f"[{addon_id}] {kind}_sort.json applied: sort_by={sort_by} sort_order={sort_order} items={len(data)}", xbmc.LOGINFO)
    except Exception:
        pass
    return data, cfg


def safe_end_of_directory(handle=-1, succeeded=True, cache_to_disc=True):
    """Sicherer Wrapper fuer xbmcplugin.endOfDirectory().

    Wird von settings.py und anderen Modulen aufgerufen.
    Ignoriert ungueltige Handles und faengt alle Exceptions ab.
    """
    try:
        h = int(handle) if handle is not None else -1
        if h >= 0:
            xbmcplugin.endOfDirectory(h, succeeded=succeeded, cacheToDisc=cache_to_disc)
    except Exception:
        pass


def build_url(query_dict):
    """
    Erstellt eine Plugin-URL aus einem Dictionary von Parametern.
    Gibt bei leerem Dict einfach base_url zurück (ohne '?').

    Sicherheit: URL-Parameter werden credential-safe gemacht
    (user:pass@ wird entfernt), damit keine Zugangsdaten in
    plugin://-URLs im Kodi-Log landen.
    """
    if not query_dict:
        return base_url

    safe_query = {}
    try:
        items = query_dict.items() if hasattr(query_dict, "items") else []
    except Exception:
        items = []

    for k, v in items:
        key = str(k) if k is not None else ""
        val = v
        try:
            if isinstance(v, str) and v:
                val = strip_url_credentials(v)
        except Exception:
            val = v
        safe_query[key] = val

    return base_url + '?' + urllib.parse.urlencode(safe_query)


# -------------------------------------------------------------------
# Watched-Status Management (BOM-safe + wirklich atomar)
# -------------------------------------------------------------------
def load_watched_status(filepath):
    """
    Unified watched-status loader:
    nutzt safe_load_json_file() (final -> .bak -> .tmp + Recovery).
    Rückgabe immer dict.
    """
    data = safe_load_json_file(filepath, {})
    return data if isinstance(data, dict) else {}


def save_watched_status(filepath, data):
    """
    Unified watched-status saver:
    nutzt atomic_write_json() (backup=True + durable=True).
    """
    if not filepath or not isinstance(data, dict):
        xbmc.log(f"[{addon_id}] save_watched_status: ungültige Daten/Pfad", xbmc.LOGERROR)
        return False

    # watched soll crash-safe sein: backup + durable
    return atomic_write_json(filepath, data, indent=4, durable=True, backup=True)








# -------------------------------------------------------------------
# Watched-Status Movies V2 (hashed keys, no lastplayed)
# -------------------------------------------------------------------
_MOVIES_WATCHED_HASH_DIGEST_BYTES = 12  # 12 bytes -> 16 chars base64url (no padding)

def movies_watched_id(identifier: str) -> str:
    """
    Erzeugt einen stabilen, kurzen Key für watched_status_movies.json.
    - normalisiert (URL-decode + Credentials entfernen bei http(s))
    - hashed (blake2s, 12 bytes) -> base64url ohne Padding
    """
    ident = normalize_media_key(identifier)
    if not ident:
        return ""
    try:
        d = hashlib.blake2s(ident.encode("utf-8"), digest_size=_MOVIES_WATCHED_HASH_DIGEST_BYTES).digest()
        return base64.urlsafe_b64encode(d).decode("ascii").rstrip("=")
    except Exception:
        # Fallback: md5 hex (32 chars)
        try:
            return hashlib.md5(ident.encode("utf-8")).hexdigest()
        except Exception:
            return ""

def migrate_watched_status_movies_dict(old_ws: dict) -> dict:
    """
    Migriert watched_status_movies.json von:
      {<url>: {"playcount": 1, "lastplayed": "..."}}
    nach:
      {<hash>: {"playcount": 1}}
    Doppelte (mit/ohne Credentials) werden zusammengeführt.
    """
    if not isinstance(old_ws, dict):
        return {}

    new_ws = {}
    for k, v in old_ws.items():
        if not isinstance(v, dict):
            continue
        try:
            pc = int(v.get("playcount", 0) or 0)
        except Exception:
            pc = 0
        if pc <= 0:
            continue

        wid = movies_watched_id(k)
        if not wid:
            continue

        # merge: behalte höchstes playcount (mind. 1)
        try:
            old_pc = int((new_ws.get(wid) or {}).get("playcount", 0) or 0)
        except Exception:
            old_pc = 0
        new_ws[wid] = {"playcount": max(old_pc, max(1, pc))}

    return new_ws

def load_watched_status_movies(filepath: str) -> dict:
    """
    Lädt watched_status_movies.json robust (final/.bak/.tmp) und migriert automatisch,
    falls noch alte URL-Keys / lastplayed enthalten sind.
    """
    ws = safe_load_json_file(filepath, {})
    if not isinstance(ws, dict):
        ws = {}

    needs_migration = False
    try:
        for k, v in ws.items():
            if _looks_like_url_or_path(k):
                needs_migration = True
                break
            if isinstance(v, dict) and "lastplayed" in v:
                needs_migration = True
                break
    except Exception:
        needs_migration = False

    if needs_migration:
        new_ws = migrate_watched_status_movies_dict(ws)
        # sofort klein + crash-safe zurückschreiben
        try:
            atomic_write_bytes(filepath, _compact_json_bytes(new_ws), backup=True, durable=True)
        except Exception:
            pass
        return new_ws

    # Normalisieren: nur playcount behalten
    cleaned = {}
    try:
        for k, v in ws.items():
            if not isinstance(k, str) or not k:
                continue
            if not isinstance(v, dict):
                continue
            try:
                pc = int(v.get("playcount", 0) or 0)
            except Exception:
                pc = 0
            if pc > 0:
                cleaned[k] = {"playcount": max(1, pc)}
    except Exception:
        cleaned = ws if isinstance(ws, dict) else {}

    # wenn wir Felder entfernt haben (z.B. lastplayed/sonstiges), direkt kompakt speichern
    if cleaned != ws:
        try:
            atomic_write_bytes(filepath, _compact_json_bytes(cleaned), backup=True, durable=True)
        except Exception:
            pass

    return cleaned

def save_watched_status_movies(filepath: str, watched_status: dict) -> bool:
    """Speichert watched_status_movies.json kompakt + crash-safe."""
    if not filepath or not isinstance(watched_status, dict):
        return False

    cleaned = {}
    try:
        for k, v in watched_status.items():
            if not isinstance(k, str) or not k:
                continue
            if not isinstance(v, dict):
                continue
            try:
                pc = int(v.get("playcount", 0) or 0)
            except Exception:
                pc = 0
            if pc > 0:
                cleaned[k] = {"playcount": max(1, pc)}
    except Exception:
        cleaned = watched_status

    try:
        return atomic_write_bytes(filepath, _compact_json_bytes(cleaned), backup=True, durable=True)
    except Exception:
        return False

def is_movie_watched(watched_status: dict, identifier: str) -> bool:
    if not isinstance(watched_status, dict):
        return False
    wid = movies_watched_id(identifier)
    if not wid:
        return False
    try:
        return int((watched_status.get(wid) or {}).get("playcount", 0) or 0) > 0
    except Exception:
        return False

def set_movie_watched(watched_status: dict, identifier: str, watched: bool = True) -> dict:
    if not isinstance(watched_status, dict):
        watched_status = {}
    wid = movies_watched_id(identifier)
    if not wid:
        return watched_status
    if watched:
        watched_status[wid] = {"playcount": 1}
    else:
        watched_status.pop(wid, None)
    return watched_status

def toggle_movie_watched(watched_status: dict, identifier: str) -> dict:
    if not isinstance(watched_status, dict):
        watched_status = {}
    wid = movies_watched_id(identifier)
    if not wid:
        return watched_status
    if wid in watched_status and int((watched_status.get(wid) or {}).get("playcount", 0) or 0) > 0:
        watched_status.pop(wid, None)
    else:
        watched_status[wid] = {"playcount": 1}
    return watched_status


# -------------------------------------------------------------------
# Auth-Cache (mini speedup)
# -------------------------------------------------------------------
_AUTH_HEADER = None
_AUTH_LAST = (None, None)
_AUTH_CACHE_LOCK = threading.Lock()

def _get_auth_header():
    global _AUTH_HEADER, _AUTH_LAST
    with _AUTH_CACHE_LOCK:
        u = (addon.getSetting("ftp_username") or "").strip()
        p = (addon.getSetting("ftp_password") or "").strip()
        if (u, p) != _AUTH_LAST:
            _AUTH_LAST = (u, p)
            if u and p:
                creds = f"{u}:{p}"
                _AUTH_HEADER = "Basic " + base64.b64encode(creds.encode()).decode()
            else:
                _AUTH_HEADER = None
        return _AUTH_HEADER


# -------------------------------------------------------------------
# Authentifizierte Request-Funktion (+User-Agent)
# -------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Kodi playback URL helpers
# -----------------------------------------------------------------------------

def _kodi_headers_encode(headers):
    """Encode HTTP headers for Kodi's URL header syntax (url|Header=Value&...)."""
    parts = []
    for k, v in (headers or {}).items():
        if v is None:
            continue
        try:
            v_str = str(v)
        except Exception:
            v_str = ""
        if not v_str:
            continue
        # Kodi expects header values URL-encoded (spaces -> %20, etc.)
        parts.append(f"{k}={urllib.parse.quote(v_str, safe='')}")
    return "&".join(parts)


def kodi_prepare_play_url(url, include_user_agent=False, force_basic_header=True):
    """
    Prepare a media URL for Kodi playback:

    - Strips user:pass@ from the URL (some Kodi/libcurl builds can behave oddly with
      percent-encoded passwords in the userinfo part).
    - Adds an Authorization: Basic ... header via Kodi's '|Header=Value' syntax.
      Prefers credentials embedded in the URL; otherwise falls back to addon settings.
    - Optionally adds a simple User-Agent header.

    Returns:
        A URL string suitable for xbmcgui.ListItem(path=...) / setResolvedUrl.
    """
    if not url:
        return url

    # Preserve existing Kodi headers if present
    if "|" in url:
        base, existing_hdr = url.split("|", 1)
    else:
        base, existing_hdr = url, ""

    base = (base or "").strip()
    existing_hdr = (existing_hdr or "").strip()

    # Figure out which headers already exist
    existing_keys = set()
    if existing_hdr:
        for part in existing_hdr.split("&"):
            if "=" in part:
                existing_keys.add(part.split("=", 1)[0].strip())

    auth_val = None

    # Strip userinfo, and if present build auth from it
    try:
        sp = urllib.parse.urlsplit(base)
        user = sp.username
        pwd = sp.password
        # urlsplit() does NOT percent-decode userinfo; decode it for Basic auth.
        if user:
            user = urllib.parse.unquote(user)
        if pwd:
            pwd = urllib.parse.unquote(pwd)

        if user is not None:
            host = sp.hostname or ""
            if sp.port:
                host = f"{host}:{sp.port}"
            base = urllib.parse.urlunsplit((sp.scheme, host, sp.path, sp.query, sp.fragment))

            if force_basic_header and "Authorization" not in existing_keys:
                token = base64.b64encode(f"{user}:{pwd or ''}".encode("utf-8")).decode("ascii")
                auth_val = f"Basic {token}"
        else:
            if force_basic_header and "Authorization" not in existing_keys:
                auth_val = _get_auth_header()
    except Exception:
        if force_basic_header and "Authorization" not in existing_keys:
            auth_val = _get_auth_header()

    headers = {}
    if include_user_agent and "User-Agent" not in existing_keys:
        headers["User-Agent"] = "Mozilla/5.0"
    if auth_val and "Authorization" not in existing_keys:
        headers["Authorization"] = auth_val

    new_hdr = _kodi_headers_encode(headers)

    if existing_hdr and new_hdr:
        combined_hdr = existing_hdr + "&" + new_hdr
    elif existing_hdr:
        combined_hdr = existing_hdr
    else:
        combined_hdr = new_hdr

    return base + ("|" + combined_hdr if combined_hdr else "")


_DEVICE_MODEL_CACHE = None
_DEVICE_MODEL_CACHE_LOCK = threading.Lock()
_PLAYBACK_BLOCK_NOTIFY_TS = 0.0
_PLAYBACK_BLOCK_NOTIFY_LOCK = threading.Lock()
_CODEC_H265_RE = re.compile(r"(?<![a-z0-9])(x265|h265|hevc|h\.265|hev1|hvc1)(?![a-z0-9])", re.IGNORECASE)
_CODEC_H264_RE = re.compile(r"(?<![a-z0-9])(x264|h264|avc|h\.264)(?![a-z0-9])", re.IGNORECASE)


def _read_text_file_best_effort(path: str) -> str:
    try:
        if path and os.path.exists(path):
            with open(path, "r", encoding="utf-8", errors="replace") as fh:
                return fh.read()
    except Exception:
        pass
    return ""


def _normalize_codec_probe_value(value: str) -> str:
    try:
        raw = str(value or "").strip()
    except Exception:
        raw = ""
    if not raw:
        return ""
    if "|" in raw:
        raw = raw.split("|", 1)[0]
    try:
        raw = urllib.parse.unquote(raw)
        raw = urllib.parse.unquote(raw)
    except Exception:
        pass
    return raw.lower()


def get_device_model_name() -> str:
    global _DEVICE_MODEL_CACHE
    if _DEVICE_MODEL_CACHE is not None:
        return _DEVICE_MODEL_CACHE
    with _DEVICE_MODEL_CACHE_LOCK:
        if _DEVICE_MODEL_CACHE is not None:
            return _DEVICE_MODEL_CACHE
        model = ""
        for probe in (
            "/proc/device-tree/model",
            "/sys/firmware/devicetree/base/model",
        ):
            model = _read_text_file_best_effort(probe).replace("\x00", "").strip()
            if model:
                break
        if not model:
            cpuinfo = _read_text_file_best_effort("/proc/cpuinfo")
            for line in cpuinfo.splitlines():
                if ":" not in line:
                    continue
                key, val = line.split(":", 1)
                if key.strip().lower() in ("model", "hardware"):
                    model = val.strip()
                    if model:
                        break
        _DEVICE_MODEL_CACHE = model or ""
        return _DEVICE_MODEL_CACHE


def is_raspberry_pi_3() -> bool:
    model = (get_device_model_name() or "").strip().lower()
    if not model:
        return False
    return (
        "raspberry pi 3" in model
        or "compute module 3" in model
        or "raspberry pi cm3" in model
    )


def _detect_video_codec_from_probe_value(value: str) -> Optional[str]:
    probe = _normalize_codec_probe_value(value)
    if not probe:
        return None
    if _CODEC_H265_RE.search(probe):
        return "h265"
    if _CODEC_H264_RE.search(probe):
        return "h264"
    return None


def detect_video_codec_from_path(path: str) -> Optional[str]:
    return _detect_video_codec_from_probe_value(path)


def detect_video_codec(codec_hint: Any = None, path: str = "") -> Optional[str]:
    codec = _detect_video_codec_from_probe_value(codec_hint)
    if codec:
        return codec
    return detect_video_codec_from_path(path)


def is_playback_blocked_for_current_device(path: str = "", codec_hint: Any = None) -> bool:
    return is_raspberry_pi_3() and detect_video_codec(codec_hint=codec_hint, path=path) == "h265"


def notify_playback_not_supported() -> None:
    global _PLAYBACK_BLOCK_NOTIFY_TS
    try:
        with _PLAYBACK_BLOCK_NOTIFY_LOCK:
            now = time.time()
            if now - _PLAYBACK_BLOCK_NOTIFY_TS < 1.5:
                return
            _PLAYBACK_BLOCK_NOTIFY_TS = now
    except Exception:
        pass
    try:
        xbmcgui.Dialog().notification(addon_name or "GlotzBox", "Nicht abspielbar auf Raspi 3", addon.getAddonInfo("icon"))
    except Exception:
        pass


def prepare_playback_path(path, notify=True, codec_hint=None):
    """Prepare playback paths and block unsupported H.265 on Raspberry Pi 3.

    Detection priority:
    1) explicit codec_hint / JSON metadata
    2) filename / path fallback
    """
    try:
        p = (path or "").strip()
    except Exception:
        p = ""
    if not p:
        return ""

    codec = detect_video_codec(codec_hint=codec_hint, path=p)
    if is_raspberry_pi_3() and codec == "h265":
        try:
            xbmc.log(
                f"[{addon_id}] Playback blockiert (Raspberry Pi 3 / codec={codec}): {mask_url(p)}",
                xbmc.LOGINFO,
            )
        except Exception:
            pass
        if notify:
            notify_playback_not_supported()
        return ""

    if p.startswith(("http://", "https://")):
        try:
            return kodi_prepare_play_url(p)
        except Exception as e:
            # Fail safe: never hand Kodi/logging a raw user:pass@ URL if preparation breaks.
            try:
                xbmc.log(f"[{addon_id}] prepare_playback_path fallback: {mask_url(p)} ({e})", xbmc.LOGDEBUG)
            except Exception:
                pass
            return strip_url_credentials(p)
    return p


def get_authenticated_request(url, headers=None, **_kwargs):
    if not url:
        return None
    try:
        parsed = urllib.parse.urlsplit(url)
    except Exception as e:
        xbmc.log(f"[{addon_id}] Ungueltige URL: {mask_url(url)} ({e})", xbmc.LOGERROR)
        return None

    netloc = parsed.hostname or ""
    if parsed.port:
        netloc += f":{parsed.port}"

    clean_url = urllib.parse.urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
    try:
        req = urllib.request.Request(clean_url)
    except Exception as e_req:
        xbmc.log(f"[{addon_id}] Request-Erstellung fehlgeschlagen: {mask_url(clean_url)} ({e_req})", xbmc.LOGERROR)
        return None

    try:
        kodi_version = xbmc.getInfoLabel('System.BuildVersion') or "Unknown"
        ua = f"Kodi/{kodi_version} Addon/{addon_id}/{addon_version}"
        req.add_header("User-Agent", ua)
    except Exception:
        pass

    auth = None
    try:
        user = parsed.username
        pwd = parsed.password
        if user is not None:
            user = urllib.parse.unquote(user)
            pwd = urllib.parse.unquote(pwd or "")
            token = base64.b64encode(f"{user}:{pwd}".encode("utf-8")).decode("ascii")
            auth = f"Basic {token}"
        else:
            auth = _get_auth_header()
    except Exception:
        auth = _get_auth_header()

    if auth:
        req.add_header("Authorization", auth)

    try:
        if isinstance(headers, dict):
            for k, v in headers.items():
                if k and v is not None:
                    req.add_header(str(k), str(v))
    except Exception:
        pass

    return req


# Alias (Backward Compat)
get_authenticated = get_authenticated_request


# -------------------------------------------------------------------
# Retry-aware URL open (transient network error resilience)
# -------------------------------------------------------------------
_TRANSIENT_HTTP_CODES = frozenset({502, 503, 504, 429})

def _abort_aware_sleep(seconds: float) -> bool:
    """Sleep that breaks early on Kodi abort. Returns True if abort requested."""
    try:
        mon = xbmc.Monitor()
        return bool(mon.waitForAbort(max(0.0, float(seconds))))
    except Exception:
        try:
            time.sleep(max(0.0, float(seconds)))
        except Exception:
            pass
        return False

def retry_urlopen(req, context=None, timeout=20, retries=1, retry_delay=2.0):
    """urlopen with automatic retry for transient network errors.

    Retries on: ConnectionResetError, socket.timeout, HTTP 502/503/504/429,
    and generic ConnectionError / BrokenPipeError.
    All other errors are raised immediately.

    For HTTP 429, honours the Retry-After header (capped at 30s) when present.
    Retry sleeps are abort-aware (break on Kodi shutdown).

    Args:
        req: urllib.request.Request object
        context: SSL context (default: get_ssl_context())
        timeout: connection timeout in seconds
        retries: number of retries after initial failure (default 1)
        retry_delay: seconds to wait between retries (fallback if no Retry-After)
    Returns:
        http.client.HTTPResponse (caller must use as context manager or close)
    """
    import socket as _socket

    if context is None:
        context = get_ssl_context()

    last_exc = None
    for attempt in range(1 + max(0, int(retries))):
        try:
            return urllib.request.urlopen(req, context=context, timeout=max(5, int(timeout)))
        except urllib.error.HTTPError as e:
            code = getattr(e, "code", None)
            if code in _TRANSIENT_HTTP_CODES and attempt < retries:
                last_exc = e
                # Honour Retry-After for 429 (capped at 30s to avoid excessive waits)
                delay = float(retry_delay)
                if code == 429:
                    try:
                        ra = e.headers.get("Retry-After", "") if hasattr(e, "headers") else ""
                        ra = (ra or "").strip()
                        if ra:
                            if ra.isdigit():
                                delay = min(30.0, max(1.0, float(ra)))
                            else:
                                # HTTP-date format (RFC 7231): e.g. "Fri, 31 Dec 2021 23:59:59 GMT"
                                try:
                                    from email.utils import parsedate_to_datetime
                                    from datetime import datetime as _dt, timezone as _tz
                                    retry_dt = parsedate_to_datetime(ra)
                                    # parsedate_to_datetime may return a naive datetime when the
                                    # header omits a timezone. Treat naive as UTC (RFC 7231 says
                                    # HTTP-date is always GMT anyway).
                                    tz = retry_dt.tzinfo or _tz.utc
                                    if retry_dt.tzinfo is None:
                                        retry_dt = retry_dt.replace(tzinfo=_tz.utc)
                                    now_dt = _dt.now(tz)
                                    diff = (retry_dt - now_dt).total_seconds()
                                    if diff > 0:
                                        delay = min(30.0, max(1.0, diff))
                                except Exception:
                                    pass
                    except Exception:
                        pass
                if _abort_aware_sleep(delay):
                    raise  # Kodi shutdown requested
                continue
            raise
        except (_socket.timeout, ConnectionResetError, BrokenPipeError, ConnectionError) as e:
            if attempt < retries:
                last_exc = e
                if _abort_aware_sleep(float(retry_delay)):
                    raise
                continue
            raise
        except urllib.error.URLError as e:
            reason = getattr(e, "reason", None)
            if isinstance(reason, (_socket.timeout, ConnectionResetError, ConnectionError)):
                if attempt < retries:
                    last_exc = e
                    if _abort_aware_sleep(float(retry_delay)):
                        raise
                    continue
            raise

    # should not be reached, but just in case
    if last_exc:
        raise last_exc




# -------------------------------------------------------------------
# Shared Remote Change Detection (ETag / Last-Modified / SHA1)
# -------------------------------------------------------------------

def meta_path_for(filename: str) -> str:
    """Standard-Meta-Datei-Pfad (gemeinsam für Service + UI)."""
    try:
        if not filename:
            return ""
        return os.path.join(addon_data_dir, f"{filename}.meta.json")
    except Exception:
        return ""


def _load_remote_meta(meta_path: str) -> dict:
    try:
        return safe_load_json_file(meta_path, {}) if meta_path else {}
    except Exception:
        return {}


def _save_remote_meta(meta_path: str, meta: dict) -> bool:
    try:
        if not meta_path:
            return False
        if not isinstance(meta, dict):
            meta = {}
        return atomic_write_json(meta_path, meta, indent=2, durable=True, backup=True)
    except Exception:
        return False


def conditional_fetch_bytes(url: str, meta_path: str, timeout_s: int = 20,
                            force_no_cache: bool = True) -> tuple:
    """Conditional GET für Remote-JSONs.

    Returns: (status, data_bytes, info)
      status: "not_modified" | "changed" | "failed"
      data_bytes: bytes (nur bei changed)
      info: bei "changed" ein dict mit neuen Meta-Werten (Caller speichert nach
            Schema-Prüfung via save_conditional_meta). Sonst Debug-String.

    Logik:
      - Wenn meta ETag/Last-Modified vorhanden: If-None-Match / If-Modified-Since senden.
      - Bei HTTP 304 => not_modified.
      - Bei HTTP 200 => SHA1 berechnen, mit meta.sha1 vergleichen. Wenn gleich => not_modified.
        Sonst => changed + meta aktualisieren.

    Robustheit:
      - Setzt (optional) Cache-Control: no-cache / Pragma: no-cache, damit Proxies weniger "stale" antworten.
    """
    try:
        if not url:
            return "failed", None, "no_url"

        meta = _load_remote_meta(meta_path)
        etag = (meta.get("etag") or "").strip()
        last_mod = (meta.get("last_modified") or "").strip()
        old_sha1 = (meta.get("sha1") or "").strip()

        req = get_authenticated_request(url)
        if not req:
            return "failed", None, "bad_request"

        # Conditional headers
        if etag:
            try:
                req.add_header("If-None-Match", etag)
            except Exception:
                pass
        if last_mod:
            try:
                req.add_header("If-Modified-Since", last_mod)
            except Exception:
                pass

        if force_no_cache:
            # hilft bei kaputten Cache/ETag Kombinationen
            try:
                req.add_header("Cache-Control", "no-cache")
                req.add_header("Pragma", "no-cache")
            except Exception:
                pass

        ctx = None
        try:
            ctx = get_ssl_context()
        except Exception:
            ctx = None

        try:
            resp = retry_urlopen(req, context=ctx, timeout=max(5, int(timeout_s or 20)), retries=1, retry_delay=2.0)
        except urllib.error.HTTPError as e:
            # 304 kommt hier manchmal als HTTPError
            if getattr(e, "code", None) == 304:
                return "not_modified", None, "304"
            return "failed", None, f"http_error_{getattr(e,'code',None)}"

        with resp:
            code = getattr(resp, "getcode", lambda: None)()
            if code == 304:
                return "not_modified", None, "304"
            if code != 200:
                return "failed", None, f"http_{code}"

            data = resp.read() or b""
            if not data:
                return "failed", None, "empty_body"

            sha1 = hashlib.sha1(data).hexdigest()
            # SHA1 unchanged => treat as not modified
            if old_sha1 and sha1 == old_sha1:
                # Trotzdem fetched timestamp updaten (optional)
                try:
                    meta["fetched"] = int(time.time())
                    _save_remote_meta(meta_path, meta)
                except Exception:
                    pass
                return "not_modified", None, "sha1"

            # headers
            try:
                headers = resp.headers
            except Exception:
                headers = {}

            etag_new = ""
            last_new = ""
            cl_new = None
            try:
                etag_new = (headers.get("ETag") or headers.get("etag") or "").strip()
            except Exception:
                pass
            try:
                last_new = (headers.get("Last-Modified") or headers.get("last-modified") or "").strip()
            except Exception:
                pass
            try:
                clh = headers.get("Content-Length") or headers.get("content-length")
                if clh:
                    cl_new = int(clh)
            except Exception:
                cl_new = None

            meta_out = {
                "url": strip_url_credentials(url),
                "etag": etag_new or etag,
                "last_modified": last_new or last_mod,
                "content_length": int(cl_new) if isinstance(cl_new, int) and cl_new > 0 else int(len(data)),
                "size": int(len(data)),
                "sha1": sha1,
                "fetched": int(time.time()),
            }
            # Meta wird NICHT hier gespeichert – der Caller soll nach Schema-
            # Prüfung selbst save_conditional_meta() aufrufen.
            return "changed", data, meta_out

    except Exception as e:
        try:
            xbmc.log(f"[{addon_id}] conditional_fetch_bytes failed: {mask_url(url)} -> {e}", xbmc.LOGDEBUG)
        except Exception:
            pass
        return "failed", None, "exception"


def save_conditional_meta(meta_path: str, meta_out) -> bool:
    """Speichert Meta-Daten nach erfolgreicher Schema-Prüfung durch den Caller.

    Wird nach conditional_fetch_bytes(...) aufgerufen, wenn der Caller die
    empfangenen Daten als strukturell gültig bestätigt hat.
    """
    if not meta_path or not isinstance(meta_out, dict):
        return False
    return _save_remote_meta(meta_path, meta_out)


# -------------------------------------------------------------------
# Watched-Status Series V2 (hashed keys, no lastplayed)
# -------------------------------------------------------------------
_SERIES_WATCHED_HASH_DIGEST_BYTES = 12  # 12 bytes -> 16 chars base64url (no padding)

def series_watched_id(identifier: str) -> str:
    """
    Erzeugt einen stabilen, kurzen Key für watched_status_series.json.
    - normalisiert (URL-decode + Credentials entfernen bei http(s))
    - hashed (blake2s, 12 bytes) -> base64url ohne Padding
    """
    ident = normalize_media_key(identifier)
    if not ident:
        return ""
    try:
        d = hashlib.blake2s(ident.encode("utf-8"), digest_size=_SERIES_WATCHED_HASH_DIGEST_BYTES).digest()
        return base64.urlsafe_b64encode(d).decode("ascii").rstrip("=")
    except Exception:
        # Fallback: md5 hex (32 chars)
        try:
            return hashlib.md5(ident.encode("utf-8")).hexdigest()
        except Exception:
            return ""

def _looks_like_url_or_path(k: str) -> bool:
    if not isinstance(k, str) or not k:
        return False
    if "://" in k:
        return True
    if k.startswith(("special://", "plugin://", "smb://", "nfs://", "ftp://", "ftps://")):
        return True
    # lokale pfade enthalten oft slashes oder backslashes
    if "/" in k or "\\" in k:
        return True
    return False

def _compact_json_bytes(obj) -> bytes:
    # klein halten: sort_keys stabilisiert, separators entfernt Spaces
    s = json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
    return s.encode("utf-8", errors="replace")

def migrate_watched_status_series_dict(old_ws: dict) -> dict:
    """
    Migriert watched_status_series.json von:
      {<url>: {"playcount": 1, "lastplayed": "..."}}
    nach:
      {<hash>: {"playcount": 1}}
    Doppelte (mit/ohne Credentials) werden zusammengeführt.
    """
    if not isinstance(old_ws, dict):
        return {}

    new_ws = {}
    for k, v in old_ws.items():
        if not isinstance(v, dict):
            continue
        try:
            pc = int(v.get("playcount", 0) or 0)
        except Exception:
            pc = 0
        if pc <= 0:
            continue

        # Key ist alt (URL/Pfad) -> hash daraus
        if _looks_like_url_or_path(k):
            wid = series_watched_id(k)
        else:
            # Key sieht schon nach Hash aus -> übernehmen
            wid = (k or "").strip()

        if not wid:
            continue

        cur = new_ws.get(wid, {})
        try:
            cur_pc = int(cur.get("playcount", 0) or 0)
        except Exception:
            cur_pc = 0

        new_ws[wid] = {"playcount": max(cur_pc, pc)}

    return new_ws

def load_watched_status_series(filepath: str) -> dict:
    """
    Lädt watched_status_series.json robust (final/.bak/.tmp) und migriert automatisch,
    falls noch alte URL-Keys / lastplayed enthalten sind.
    """
    ws = safe_load_json_file(filepath, {})
    if not isinstance(ws, dict):
        ws = {}

    needs_migration = False
    try:
        for k, v in ws.items():
            if _looks_like_url_or_path(k):
                needs_migration = True
                break
            if isinstance(v, dict) and "lastplayed" in v:
                needs_migration = True
                break
    except Exception:
        needs_migration = False

    if needs_migration:
        new_ws = migrate_watched_status_series_dict(ws)
        # sofort klein + crash-safe zurückschreiben
        try:
            atomic_write_bytes(filepath, _compact_json_bytes(new_ws), backup=True, durable=True)
        except Exception:
            pass
        return new_ws

    # Normalisieren: nur playcount behalten
    cleaned = {}
    try:
        for k, v in ws.items():
            if not isinstance(k, str) or not k:
                continue
            if not isinstance(v, dict):
                continue
            try:
                pc = int(v.get("playcount", 0) or 0)
            except Exception:
                pc = 0
            if pc > 0:
                cleaned[k.strip()] = {"playcount": pc}
    except Exception:
        cleaned = ws if isinstance(ws, dict) else {}

    # Falls cleanup was geändert hat -> kompakt speichern
    if cleaned != ws:
        try:
            atomic_write_bytes(filepath, _compact_json_bytes(cleaned), backup=True, durable=True)
        except Exception:
            pass

    return cleaned

def save_watched_status_series(filepath: str, watched_status: dict) -> bool:
    """Speichert watched_status_series.json kompakt (1 Zeile, keine Spaces) + crash-safe."""
    if not filepath or not isinstance(watched_status, dict):
        return False
    # Nur minimal: playcount
    cleaned = {}
    try:
        for k, v in watched_status.items():
            if not isinstance(k, str) or not k:
                continue
            if not isinstance(v, dict):
                continue
            try:
                pc = int(v.get("playcount", 0) or 0)
            except Exception:
                pc = 0
            if pc > 0:
                cleaned[k.strip()] = {"playcount": pc}
    except Exception:
        cleaned = watched_status

    try:
        return bool(atomic_write_bytes(filepath, _compact_json_bytes(cleaned), backup=True, durable=True))
    except Exception:
        return False

def is_series_watched(watched_status: dict, identifier: str) -> bool:
    """True, wenn identifier (URL/Pfad) als watched markiert ist (hashed lookup)."""
    if not isinstance(watched_status, dict):
        return False
    wid = series_watched_id(identifier)
    if not wid:
        return False
    try:
        return int((watched_status.get(wid) or {}).get("playcount", 0) or 0) > 0
    except Exception:
        return False

def set_series_watched(watched_status: dict, identifier: str, watched: bool = True) -> dict:
    """Setzt watched/unwatched für identifier und gibt das dict zurück."""
    if not isinstance(watched_status, dict):
        watched_status = {}
    wid = series_watched_id(identifier)
    if not wid:
        return watched_status
    if watched:
        watched_status[wid] = {"playcount": 1}
    else:
        watched_status.pop(wid, None)
    return watched_status

def toggle_series_watched(watched_status: dict, identifier: str) -> dict:
    if not isinstance(watched_status, dict):
        watched_status = {}
    wid = series_watched_id(identifier)
    if not wid:
        return watched_status
    if wid in watched_status and int((watched_status.get(wid) or {}).get("playcount", 0) or 0) > 0:
        watched_status.pop(wid, None)
    else:
        watched_status[wid] = {"playcount": 1}
    return watched_status

# ---------------------------------------------------------------------
# Bytecode cache hygiene
# ---------------------------------------------------------------------

def purge_pycache(addon_root=None, remove_pyc=True, log_fn=None):
    """Delete __pycache__ folders and (optionally) *.pyc files under the addon.

    This helps when users update files manually and Kodi/Python still picks up
    cached bytecode. Safe to call on every startup; errors are ignored.
    """
    try:
        import os
        import shutil

        if addon_root is None:
            try:
                addon_root = addon.getAddonInfo('path')
            except Exception:
                addon_root = None

        if not addon_root:
            return

        removed_dirs = 0
        removed_files = 0

        for root, dirs, files in os.walk(addon_root):
            # remove __pycache__ dirs
            for d in list(dirs):
                if d == '__pycache__':
                    p = os.path.join(root, d)
                    try:
                        shutil.rmtree(p, ignore_errors=True)
                        removed_dirs += 1
                    except Exception:
                        pass
            if remove_pyc:
                for fn in files:
                    if fn.endswith('.pyc'):
                        p = os.path.join(root, fn)
                        try:
                            os.remove(p)
                            removed_files += 1
                        except Exception:
                            pass

        # prevent creating new .pyc (best effort)
        try:
            import sys
            sys.dont_write_bytecode = True
        except Exception:
            pass

        if log_fn and (removed_dirs or removed_files):
            log_fn(f"pycache cleanup: removed_dirs={removed_dirs} removed_files={removed_files}")
    except Exception:
        pass


# ---------------------------------------------------------------------
# Shared conditional fetch helper (ETag / Last-Modified + SHA1)
# ---------------------------------------------------------------------

# ---------------------------------------------------------------------
# Shared Container Sort Force Helper
# Wird von movies.py und series.py verwendet.
# Pollt Container.FolderPath bis die richtige Ansicht aktiv ist,
# dann setzt SetSortMethod(40) = DATEADDED descending.
# ---------------------------------------------------------------------
def force_container_sort_dateadded_desc(kind: str) -> None:
    """Erzwingt DATEADDED descending für movies ('show_filme') oder series ('show_serien').

    Läuft in eigenem Thread. Pollt Container.FolderPath max. 5s bis die
    richtige Ansicht aktiv ist, dann setzt Container.SetSortMethod(40).

    Args:
        kind: 'show_filme' oder 'show_serien' – wird in FolderPath geprüft.
    """
    import xbmc

    def _do_force():
        try:
            mon = xbmc.Monitor()
            plugin_base = sys.argv[0] if sys.argv else "plugin://plugin.video.glotzbox"
            deadline = time.time() + 5.0
            ready = False
            while time.time() < deadline:
                if mon.abortRequested():
                    return
                try:
                    import urllib.parse
                    folder_raw = xbmc.getInfoLabel("Container.FolderPath") or ""
                    # Normalisieren: URL-decode + lowercase für robuste Teilstring-Prüfung
                    folder = urllib.parse.unquote(folder_raw).lower()
                    if plugin_base.lower() in folder and kind.lower() in folder:
                        ready = True
                        break
                except Exception:
                    pass
                if mon.waitForAbort(0.1):
                    return
            if not ready:
                xbmc.log(
                    f"[{addon_id}] force_container_sort: Container nicht bereit nach 5s (kind={kind})",
                    xbmc.LOGDEBUG
                )
                return
            # Doppel-Refresh-Guard: Wenn DATEADDED + descending bereits aktiv sind,
            # die Builtins NICHT erneut feuern. Container.SetSortMethod / SetSortDirection
            # lösen bei Plugin-Containern einen Container.Refresh aus und damit einen
            # zweiten Plugin-Aufruf von show_filme/show_serien.
            try:
                already_correct = (
                    xbmc.getCondVisibility("Container.SortMethod(SORT_METHOD_DATEADDED)")
                    and xbmc.getCondVisibility("Container.SortDirection(descending)")
                )
            except Exception:
                already_correct = False
            if already_correct:
                xbmc.log(
                    f"[{addon_id}] force_container_sort: bereits DATEADDED/Descending aktiv – skip (kind={kind}).",
                    xbmc.LOGDEBUG
                )
                return
            xbmc.executebuiltin("Container.SetSortMethod(40)")
            mon.waitForAbort(0.15)
            if not xbmc.getCondVisibility("Container.SortDirection(descending)"):
                xbmc.executebuiltin("Container.SetSortDirection")
                mon.waitForAbort(0.1)
            xbmc.log(
                f"[{addon_id}] force_container_sort: DATEADDED/Descending gesetzt (kind={kind}).",
                xbmc.LOGINFO
            )
        except Exception as e:
            try:
                xbmc.log(f"[{addon_id}] force_container_sort fehlgeschlagen: {e}", xbmc.LOGDEBUG)
            except Exception:
                pass

    try:
        threading.Thread(target=_do_force, daemon=True).start()
    except Exception as e:
        try:
            xbmc.log(f"[{addon_id}] force_container_sort thread fehlgeschlagen: {e}", xbmc.LOGDEBUG)
        except Exception:
            pass

# -------------------------------------------------------------------
# Connectivity-Check + Notification
# -------------------------------------------------------------------
# Zweck: Vor Datei-Fetches (sha1/JSON) schnell prüfen, ob überhaupt
#   eine Verbindung zum konfigurierten Server (bzw. zum Internet) besteht.
#   Bei Fehler: Kodi-Notification anzeigen und ggf. "offline" signalisieren,
#   damit die Caller keine veraltete lokale Liste rendern (Abspielen ginge
#   ja eh nicht).
#
# Design:
#   - Server-Check: TCP-Connect zum konfigurierten Host (Port 443, HTTPS).
#   - Bei Fehler zusätzlich Internet-Check: TCP-Connect zu 1.1.1.1:53 (DNS).
#   - Notification-Cooldown über Window(10000)-Property (5min), damit bei
#     schnellem Wechsel Filme->Serien->Favoriten nur eine Notification kommt.
#   - Timeouts klein halten (3s), damit der Happy-Case keine spürbare
#     Extra-Latenz bekommt.
# -------------------------------------------------------------------

_CONNECTIVITY_PROBE_TIMEOUT_S = 3.0
_CONNECTIVITY_NOTIFY_COOLDOWN_S = 300.0  # 5 Minuten
_CONNECTIVITY_NOTIFY_PROP = f"{addon_id}.connectivity.last_notify_ts"
_CONNECTIVITY_CACHE_OK_TTL_S = 30.0
_CONNECTIVITY_CACHE_FAIL_TTL_S = 5.0
_CONNECTIVITY_CACHE = {"ts": 0.0, "reason": ""}


def _connectivity_probe() -> str:
    """
    Low-level Verbindungscheck ohne Notification.
    Returns: "ok" | "server_unreachable" | "no_internet" | "no_host_configured"
    """
    now = time.time()
    try:
        cached_ts = float(_CONNECTIVITY_CACHE.get("ts") or 0.0)
        cached_reason = str(_CONNECTIVITY_CACHE.get("reason") or "")
        ttl = _CONNECTIVITY_CACHE_OK_TTL_S if cached_reason == "ok" else _CONNECTIVITY_CACHE_FAIL_TTL_S
        if cached_reason and (now - cached_ts) < ttl:
            return cached_reason
    except Exception:
        pass

    def _cache(reason: str) -> str:
        try:
            _CONNECTIVITY_CACHE["ts"] = time.time()
            _CONNECTIVITY_CACHE["reason"] = reason or ""
        except Exception:
            pass
        return reason

    try:
        host_final, _base_path, scheme = normalize_ftp_host_basepath()
    except Exception as e:
        try:
            xbmc.log(f"[{addon_id}] connectivity_probe: normalize failed: {e}", xbmc.LOGDEBUG)
        except Exception:
            pass
        host_final = ""
        scheme = "https"

    if not host_final:
        return _cache("no_host_configured")

    # Inline-Port abtrennen (z.B. "host.tld:8443")
    host = host_final
    port = 80 if str(scheme or "").strip().lower() == "http" else 443
    if ":" in host:
        try:
            host_part, port_str = host.rsplit(":", 1)
            port = int(port_str)
            host = host_part
        except Exception:
            port = 80 if str(scheme or "").strip().lower() == "http" else 443

    # 1) Server-Check (TCP-Connect)
    try:
        with socket.create_connection((host, port), timeout=_CONNECTIVITY_PROBE_TIMEOUT_S):
            return _cache("ok")
    except Exception as e:
        try:
            xbmc.log(f"[{addon_id}] connectivity_probe: server connect failed ({host}:{port}): {e}", xbmc.LOGDEBUG)
        except Exception:
            pass

    # 2) Internet-Check (Cloudflare DNS)
    try:
        with socket.create_connection(("1.1.1.1", 53), timeout=_CONNECTIVITY_PROBE_TIMEOUT_S):
            return _cache("server_unreachable")
    except Exception:
        pass

    # 3) Fallback: Google DNS, falls 1.1.1.1 blockiert ist
    try:
        with socket.create_connection(("8.8.8.8", 53), timeout=_CONNECTIVITY_PROBE_TIMEOUT_S):
            return _cache("server_unreachable")
    except Exception:
        pass

    return _cache("no_internet")


def _notify_connectivity(reason: str) -> None:
    """Zeigt eine Kodi-Notification passend zum Reason – mit Cooldown."""
    if reason == "ok":
        return

    try:
        win = xbmcgui.Window(10000)
    except Exception:
        return

    now = time.time()
    try:
        last_str = (win.getProperty(_CONNECTIVITY_NOTIFY_PROP) or "0").strip()
        last_ts = float(last_str) if last_str else 0.0
    except Exception:
        last_ts = 0.0

    if now - last_ts < _CONNECTIVITY_NOTIFY_COOLDOWN_S:
        return

    try:
        win.setProperty(_CONNECTIVITY_NOTIFY_PROP, str(now))
    except Exception:
        pass

    if reason == "no_internet":
        title = "Keine Internetverbindung"
        msg = "Bitte Internetverbindung prüfen"
    elif reason == "server_unreachable":
        title = "Server nicht erreichbar"
        msg = "Seedbox/Server antwortet nicht"
    elif reason == "no_host_configured":
        title = "Host nicht konfiguriert"
        msg = "FTP-Host in den Einstellungen setzen"
    else:
        return

    try:
        icon = addon.getAddonInfo('icon')
    except Exception:
        icon = ""

    try:
        xbmcgui.Dialog().notification(title, msg, icon, 5000, False)
    except Exception as e:
        try:
            xbmc.log(f"[{addon_id}] notify_connectivity failed: {e}", xbmc.LOGDEBUG)
        except Exception:
            pass


def check_connectivity_or_notify() -> bool:
    """
    Prüft Verbindung und zeigt bei Fehler eine Notification (mit Cooldown).

    Returns True wenn OK, False wenn nicht erreichbar.

    Aufrufer sollten bei False die UI *nicht* mit einer veralteten Cache-Liste
    rendern (Abspielen würde eh fehlschlagen), sondern einen klaren Hinweis
    anzeigen und endOfDirectory(succeeded=True, cacheToDisc=False) aufrufen.
    """
    reason = _connectivity_probe()
    if reason == "ok":
        return True
    _notify_connectivity(reason)
    return False


def probe_connectivity_silent() -> str:
    """
    Wie _connectivity_probe, aber als öffentliche API ohne Notification.

    Gedacht für Service-/Hintergrundkontext, in dem ein Benutzer-Dialog
    stören würde (z.B. während der User gerade einen Film schaut).
    Aufrufer sollen bei "ok" normal weitermachen und bei anderen Werten
    den Prefetch/Refresh überspringen und in LOGWARNING loggen.

    Returns: "ok" | "server_unreachable" | "no_internet" | "no_host_configured"
    """
    return _connectivity_probe()