# 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 Optional



# -------------------------------------------------------------------
# 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
# -------------------------------------------------------------------
addon = xbmcaddon.Addon()
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:
    pass


# -------------------------------------------------------------------
# Exceptions
# -------------------------------------------------------------------
class DownloadCancelledError(Exception):
    """Wird geworfen, wenn der User einen Download abbricht."""
    pass


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:
            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)


def mask_url(url: str) -> str:
    """Redacts embedded credentials in URLs for log output."""
    try:
        if not url:
            return ""
        return _URL_CRED_RE.sub(r"\1***:***@", str(url))
    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."""
    try:
        if not url:
            return None
        sha_url = f"{url}.sha1"
        req = get_authenticated_request(sha_url)
        if not req:
            return None
        with urllib.request.urlopen(req, context=get_ssl_context(), timeout=timeout_s) as r:
            if r.getcode() != 200:
                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()
        return None
    except Exception:
        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 Downloader-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 cache helpers (shared by service.py / movies.py / series.py)
# -------------------------------------------------------------------

def _normalize_image_cache_key(url: str) -> str:
    """Stable cache key for image URLs (credentials stripped, whitespace removed)."""
    try:
        u = (url or "").strip()
        if not u:
            return ""
        try:
            u = urllib.parse.unquote(u)
        except Exception:
            pass
        return strip_url_credentials(u)
    except Exception:
        return (url or "").strip()


def _guess_image_extension(url: str, default: str = ".jpg") -> str:
    try:
        p = urllib.parse.urlsplit(url or "")
        ext = os.path.splitext(p.path or "")[1].lower()
        if ext in (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn", ".avif"):
            return ext
    except Exception:
        pass
    return default


def get_cache_filename(url: str, cache_dir: Optional[str] = None, fallback_name: str = "image") -> str:
    """Return deterministic cache path/name for a remote image URL."""
    try:
        key = _normalize_image_cache_key(url)
        if not key:
            return ""
        ext = _guess_image_extension(key, default=".jpg")
        name = f"{hashlib.sha1(key.encode('utf-8', errors='ignore')).hexdigest()}{ext}"
        if cache_dir:
            return os.path.join(cache_dir, name)
        return name or f"{fallback_name}.jpg"
    except Exception:
        try:
            fallback = f"{fallback_name}.jpg"
            return os.path.join(cache_dir, fallback) if cache_dir else fallback
        except Exception:
            return ""


def get_cached_image(url: str, cache_dir: Optional[str] = None) -> str:
    """Return local cached image path if present and non-empty, else empty string."""
    try:
        fp = get_cache_filename(url, cache_dir=cache_dir)
        if not fp:
            return ""
        if not fs_exists(fp):
            return ""
        try:
            local_fp = fs_local(fp)
            if local_fp and os.path.isfile(local_fp):
                # Reject 0-byte files – they block re-downloads and cause missing artwork in Kodi.
                if os.path.getsize(local_fp) == 0:
                    try:
                        os.remove(local_fp)
                        xbmc.log(f"[{addon_id}] get_cached_image: 0-Byte Cache-Datei geloescht: {local_fp}", xbmc.LOGWARNING)
                    except Exception:
                        pass
                    return ""
                return local_fp
        except Exception:
            pass
        return fp
    except Exception:
        return ""


def download_and_cache_image(url: str, cache_dir: Optional[str] = None, timeout_s: int = 20, force: bool = False) -> str:
    """Download a remote image into the local addon cache and return the cached path."""
    try:
        u = (url or "").strip()
        if not u or not (u.startswith("http://") or u.startswith("https://")):
            return ""

        cache_dir = cache_dir or os.path.join(addon_data_dir, "cache_images")
        if not fs_mkdirs(cache_dir):
            return ""

        cached = get_cached_image(u, cache_dir=cache_dir)
        if cached and not force:
            return cached

        dest_path = get_cache_filename(u, cache_dir=cache_dir)
        if not dest_path:
            return ""

        req = get_authenticated_request(u)
        if not req:
            return ""

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

        try:
            with urllib.request.urlopen(req, context=ctx, timeout=max(5, int(timeout_s or 20))) as resp:
                code = getattr(resp, 'getcode', lambda: 200)()
                if code != 200:
                    xbmc.log(f"[{addon_id}] download_and_cache_image: HTTP {code} fuer {mask_url(u)}", xbmc.LOGWARNING)
                    return ""
                data = resp.read() or b""
        except urllib.error.HTTPError as e:
            xbmc.log(f"[{addon_id}] download_and_cache_image HTTPError: {mask_url(u)} -> {getattr(e, 'code', '?')}", xbmc.LOGDEBUG)
            return ""

        if not data:
            xbmc.log(f"[{addon_id}] download_and_cache_image: Leere Antwort fuer {mask_url(u)}", xbmc.LOGWARNING)
            return ""

        if not atomic_write_bytes(dest_path, data, backup=False, durable=True):
            xbmc.log(f"[{addon_id}] download_and_cache_image: Schreiben fehlgeschlagen fuer {mask_url(u)}", xbmc.LOGWARNING)
            return ""

        result = get_cached_image(u, cache_dir=cache_dir)
        # Note: per-image success is intentionally NOT logged here (LOGDEBUG noise).
        # Aggregate summaries are logged by the callers in movies.py / service.py.
        return result
    except Exception as e:
        xbmc.log(f"[{addon_id}] download_and_cache_image failed: {mask_url(url)} -> {e}", xbmc.LOGDEBUG)
        return ""

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 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 "")

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}] Ungültige 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:
        # Kodi uses urllib.request. Some callers pass extra kwargs (e.g. headers)
        # for conditional requests. We accept them for forward/backward compatibility.
        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

    # User-Agent setzen
    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

    # BasicAuth-Header setzen
    auth = _get_auth_header()
    if auth:
        req.add_header("Authorization", auth)

    # Optional extra headers (e.g. If-None-Match / If-Modified-Since)
    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




# -------------------------------------------------------------------
# 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: kurze Debug-Info

    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 = urllib.request.urlopen(req, context=ctx, timeout=max(5, int(timeout_s or 20)))
        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()),
            }
            _save_remote_meta(meta_path, meta_out)
            return "changed", data, "200"

    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"


# -------------------------------------------------------------------
# Gemeinsame Download-Funktion (xbmcvfs write + cleanup + Cancel-Exception)
# -------------------------------------------------------------------
def download_file(url, dest_path, title="Download", is_asset=False, **kwargs):
    """
    Lädt Datei herunter (xbmcvfs kompatibel) + Progress throttling.
    is_asset / kwargs werden nur für Backward-Compatibility akzeptiert.
    """
    progress_dialog = None
    response = None
    out_fh = None

    # Zielpfad in lokale FS-Form übersetzen (wenn möglich)
    dest_real = None
    try:
        dest_real = xbmcvfs.translatePath(dest_path)
    except Exception:
        dest_real = dest_path

    try:
        req = get_authenticated_request(url)
        if not req:
            raise Exception("Ungültige URL/Request")

        progress_dialog = xbmcgui.DialogProgress()
        progress_dialog.create("Download", f"Download: {title}")

        context = get_ssl_context()
        response = urllib.request.urlopen(req, context=context, timeout=60)

        # Content-Length optional
        total_hdr = None
        try:
            total_hdr = response.getheader("Content-Length")
        except Exception:
            total_hdr = None

        try:
            total_size = int(total_hdr) if total_hdr else 0
        except Exception:
            total_size = 0

        block_size = 1024 * 64  # 64KB
        downloaded = 0
        start_time = time.time()
        last_percent = -1
        last_update_t = 0

        # Zielordner sicherstellen
        try:
            dest_dir = os.path.dirname(dest_real) if dest_real else ""
            if dest_dir and not xbmcvfs.exists(dest_dir):
                xbmcvfs.mkdirs(dest_dir)
        except Exception:
            pass

        # Schreiben über xbmcvfs (robuster für Kodi/VFS)
        out_fh = xbmcvfs.File(dest_real, "wb")

        while True:
            buf = response.read(block_size)
            if not buf:
                break

            out_fh.write(buf)
            downloaded += len(buf)

            now = time.time()

            # Progress
            if total_size > 0:
                percent = int(downloaded * 100 / total_size)
            else:
                percent = 0

            if percent != last_percent or (now - last_update_t) > 0.2:
                last_percent = percent
                last_update_t = now

                elapsed = now - start_time
                speed = downloaded / elapsed if elapsed > 0 else 0.0
                speed_mbs = speed / (1024 * 1024)

                if total_size > 0 and speed > 0:
                    eta = (total_size - downloaded) / speed
                    eta_str = time.strftime("%M:%S", time.gmtime(max(0, eta)))
                    msg = f"Speed: {speed_mbs:.2f} MB/s ETA: {eta_str}"
                else:
                    msg = f"Speed: {speed_mbs:.2f} MB/s"

                progress_dialog.update(percent, f"Download: {title}\n{msg}")

            if progress_dialog.iscanceled():
                # wichtig: Text enthält "abgebrochen" (kompatibel zu deinem Wrapper)
                raise DownloadCancelledError("Download abgebrochen")

        # schließen
        try:
            out_fh.close()
        except Exception:
            pass
        out_fh = None

        try:
            response.close()
        except Exception:
            pass
        response = None

        try:
            progress_dialog.close()
        except Exception:
            pass
        progress_dialog = None

        return dest_path

    except DownloadCancelledError:
        # Cleanup partial file
        try:
            if out_fh is not None:
                out_fh.close()
        except Exception:
            pass
        try:
            if response is not None:
                response.close()
        except Exception:
            pass
        try:
            if progress_dialog is not None:
                progress_dialog.close()
        except Exception:
            pass
        try:
            if dest_real and xbmcvfs.exists(dest_real):
                xbmcvfs.delete(dest_real)
        except Exception:
            pass
        xbmc.log(f"[{addon_id}] Download abgebrochen: {title}", xbmc.LOGINFO)
        raise

    except Exception as e:
        # Cleanup partial file
        try:
            if out_fh is not None:
                out_fh.close()
        except Exception:
            pass
        try:
            if response is not None:
                response.close()
        except Exception:
            pass
        try:
            if progress_dialog is not None:
                progress_dialog.close()
        except Exception:
            pass
        try:
            if dest_real and xbmcvfs.exists(dest_real):
                xbmcvfs.delete(dest_real)
        except Exception:
            pass

        xbmc.log(f"[{addon_id}] Download Fehler: {e}", xbmc.LOGERROR)
        xbmc.log(traceback.format_exc(), xbmc.LOGDEBUG)
        raise

# -------------------------------------------------------------------
# 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
                xbmc.sleep(100)
            if not ready:
                xbmc.log(
                    f"[{addon_id}] force_container_sort: Container nicht bereit nach 5s (kind={kind})",
                    xbmc.LOGDEBUG
                )
                return
            xbmc.executebuiltin("Container.SetSortMethod(40)")
            xbmc.sleep(150)
            if not xbmc.getCondVisibility("Container.SortDirection(descending)"):
                xbmc.executebuiltin("Container.SetSortDirection")
                xbmc.sleep(100)
            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