# 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
import threading
import hashlib
from typing import Optional


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


# -------------------------------------------------------------------
# Helpers (Dateinamen / URL / FTP-Host Normalisierung)
# -------------------------------------------------------------------
def sanitize_filename(filename: str) -> str:
    """Entfernt ungültige Zeichen für Dateinamen."""
    return re.sub(r'[\\/*?:"<>|]', "_", str(filename)).strip()




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


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).
    """
    if not dest_path or data_bytes is None:
        return False

    # 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 = dest_path + ".tmp"
            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
            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)

            # 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:
                    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 = real_dest + ".tmp"
    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
        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


# -------------------------------------------------------------------
# Shared Image Cache (Series + Movies)
# -------------------------------------------------------------------
# URL -> lokaler Pfad (oder Miss) wird pro Cache-Dir im RAM gehalten, um exists() Spam zu vermeiden.
# Wichtig: Misses haben TTL, damit ein temporärer 404 nicht "für immer" gecached bleibt.

_IMAGE_CACHE_LOCK = threading.Lock()
# cache_dir -> {url: {"path": <str|None>, "ts": <float>, "miss": <bool>}}
_IMAGE_PATH_CACHE = {}
_IMAGE_MISS_TTL_S_DEFAULT = 300  # 5 Minuten
_IMAGE_CACHE_MAX_PER_DIR = 5000  # simples Limit gegen unendliches Wachsen

def get_cache_filename(url: str) -> str:
    """Stabiler Dateiname für eine Image-URL (md5 + Original-Extension, fallback .jpg)."""
    try:
        if not isinstance(url, str) or not url:
            return "invalid_url.jpg"
        h = hashlib.md5(url.encode("utf-8")).hexdigest()
        try:
            path = urllib.parse.urlparse(url).path
            ext = os.path.splitext(path)[1] if path else ""
        except Exception:
            ext = ""
        if not ext or len(ext) > 8:
            ext = ".jpg"
        return h + ext
    except Exception as e:
        try:
            xbmc.log(f"[{addon_id} common.py] Fehler in get_cache_filename für URL '{url}': {e}", xbmc.LOGERROR)
        except Exception:
            pass
        return "error.jpg"


def _img_cache_get(cache_dir: str, url: str):
    with _IMAGE_CACHE_LOCK:
        d = _IMAGE_PATH_CACHE.get(cache_dir)
        if not d:
            return None
        return d.get(url)


def _img_cache_set(cache_dir: str, url: str, rec: dict):
    with _IMAGE_CACHE_LOCK:
        d = _IMAGE_PATH_CACHE.get(cache_dir)
        if d is None:
            d = {}
            _IMAGE_PATH_CACHE[cache_dir] = d
        d[url] = rec
        if len(d) > _IMAGE_CACHE_MAX_PER_DIR:
            # aggressiv aber sicher: wenn es zu groß wird -> einmal leer
            d.clear()


def _img_cache_pop(cache_dir: str, url: str):
    with _IMAGE_CACHE_LOCK:
        d = _IMAGE_PATH_CACHE.get(cache_dir)
        if d:
            d.pop(url, None)


def get_cached_image(url: str, cache_dir: str, miss_ttl_s: int = _IMAGE_MISS_TTL_S_DEFAULT) -> Optional[str]:
    """Gibt lokalen Cache-Pfad für URL zurück (oder None). Misses haben TTL."""
    if not cache_dir or not url or not isinstance(url, str):
        return None

    now = time.time()
    rec = _img_cache_get(cache_dir, url)

    if rec:
        try:
            p = rec.get("path")
            if p:
                if fs_exists(p):
                    return p
                # Datei weg -> Cache invalidieren
                _img_cache_pop(cache_dir, url)
            # Miss -> innerhalb TTL direkt None zurück
            if rec.get("miss") and (now - float(rec.get("ts", 0.0))) < float(miss_ttl_s or 0):
                return None
        except Exception:
            _img_cache_pop(cache_dir, url)

    file_path = os.path.join(cache_dir, get_cache_filename(url))

    if fs_exists(file_path):
        _img_cache_set(cache_dir, url, {"path": file_path, "ts": now, "miss": False})
        return file_path

    _img_cache_set(cache_dir, url, {"path": None, "ts": now, "miss": True})
    return None


def download_and_cache_image(url: str, cache_dir: str, timeout_s: int = 15, miss_ttl_s: int = _IMAGE_MISS_TTL_S_DEFAULT) -> Optional[str]:
    """Lädt Bild und legt es im Cache ab (hard-reset safe via atomic bytes write)."""
    if not cache_dir or not url:
        try:
            xbmc.log(f"[{addon_id} common.py] Cache-Verzeichnis oder URL fehlt für download_and_cache_image.", xbmc.LOGWARNING)
        except Exception:
            pass
        return None

    # wenn schon da -> direkt zurück
    cached = get_cached_image(url, cache_dir=cache_dir, miss_ttl_s=miss_ttl_s)
    if cached:
        return cached

    file_path = os.path.join(cache_dir, get_cache_filename(url))

    try:
        req = get_authenticated_request(url)
        if not req:
            try:
                xbmc.log(f"[{addon_id} common.py] Konnte Request-Objekt nicht erstellen für {url}", xbmc.LOGWARNING)
            except Exception:
                pass
            _img_cache_set(cache_dir, url, {"path": None, "ts": time.time(), "miss": True})
            return None

        # Directory sicherstellen
        try:
            fs_mkdirs(cache_dir)
        except Exception:
            pass

        context = ssl.create_default_context()
        with urllib.request.urlopen(req, context=context, timeout=int(timeout_s or 15)) as response:
            code = getattr(response, "getcode", lambda: 200)()
            if code != 200:
                try:
                    xbmc.log(f"[{addon_id} common.py] HTTP {code} beim Download von {url}", xbmc.LOGWARNING)
                except Exception:
                    pass
                _img_cache_set(cache_dir, url, {"path": None, "ts": time.time(), "miss": True})
                return None
            data = response.read()

        ok = atomic_write_bytes(file_path, data, backup=False, durable=False)
        if not ok:
            # Fallback (VFS write)
            try:
                with xbmcvfs.File(file_path, "wb") as fh:
                    fh.write(data)
            except Exception:
                _img_cache_set(cache_dir, url, {"path": None, "ts": time.time(), "miss": True})
                return None

        _img_cache_set(cache_dir, url, {"path": file_path, "ts": time.time(), "miss": False})
        try:
            xbmc.log(f"[{addon_id} common.py] Bild gecached: {url} -> {file_path}", xbmc.LOGDEBUG)
        except Exception:
            pass
        return file_path

    except Exception as e:
        try:
            xbmc.log(f"[{addon_id} common.py] Fehler beim Download/Caching von Bild '{url}': {e}", xbmc.LOGERROR)
        except Exception:
            pass
        try:
            if fs_exists(file_path):
                fs_delete(file_path)
        except Exception:
            pass
        _img_cache_set(cache_dir, url, {"path": None, "ts": time.time(), "miss": True})
        return None


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



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


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://"):
        scheme = "http"

    # 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
# -------------------------------------------------------------------
def build_url(query_dict):
    """
    Erstellt eine Plugin-URL aus einem Dictionary von Parametern.
    Gibt bei leerem Dict einfach base_url zurück (ohne '?').
    """
    if not query_dict:
        return base_url
    return base_url + '?' + urllib.parse.urlencode(query_dict)


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


def _get_auth_header():
    global _AUTH_HEADER, _AUTH_LAST
    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)
# -------------------------------------------------------------------
def get_authenticated_request(url):
    if not url:
        return None
    try:
        parsed = urllib.parse.urlsplit(url)
    except Exception as e:
        xbmc.log(f"[{addon_id}] Ungültige 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: {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)

    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 = ssl.create_default_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": 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: {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 = ssl.create_default_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)
# ---------------------------------------------------------------------