# -*- coding: utf-8 -*-
import json
import os
import re
import sys
import threading
import time
import requests
import urllib.parse
import xml.etree.ElementTree as ET
import datetime
import hashlib
import shutil
import traceback
import zipfile
import xbmcaddon
import xbmcgui
import xbmcplugin
import xbmcvfs

from iptv import IPTV, build_m3u_content
from epg import EPG, _parse_xmltv_time, epg_file_has_data
from profiles import ProfileManager, RefreshTracker
from lang import _t
from downloader import (
    start_download,
    pause_download,
    resume_download,
    cancel_download,
    retry_download,
    delete_download,
    remove_download_from_list,
    downloads_menu,
    get_downloads_list,
    is_url_downloadable,
    mark_zombie_downloads_failed,
    pause_all_downloads,
    start_all_downloads,
    cancel_all_downloads,
    delete_all_downloads,
    clean_missing_downloads,
    count_missing_downloads,
)
from favorites import Favorites
from history import WatchedMovies, WatchedEpisodes, WatchHistory, ResumePoints

_tmdb_routes = None
_tmdb_routes_setup = None
_sports_routes = None
_sports_routes_setup = None
_NO_CREDS_NOTIFICATION_SHOWN = False

addon = xbmcaddon.Addon()
addon_handle = int(sys.argv[1])
base_url = sys.argv[0]
args = urllib.parse.parse_qs(sys.argv[2][1:])
mode = args.get("mode", [None])[0]
pm = ProfileManager(addon)
_ROUTE_START = time.monotonic()

PVR_MAIN_INSTANCE_ID = 31
PVR_FAVS_INSTANCE_ID = 32
PVR_SPORTS_INSTANCE_ID = 33
PVR_SPORTS_FAVS_INSTANCE_ID = 34
PVR_INSTANCE_NAME = "Xstream Pro - Live TV"
PVR_FAVS_INSTANCE_NAME = "Xstream Pro - Favorites"
PVR_SPORTS_INSTANCE_NAME = "Xstream Pro - Sports TV"
PVR_SPORTS_FAVS_INSTANCE_NAME = "Xstream Pro - Sports Favorites"
PVR_LEGACY_MAIN_NAMES = ("Xstream Pro",)
PVR_LEGACY_FAVS_NAMES = ("Xstream Pro Favorites",)
PVR_FILE_SLUG = "xstream_pro"
PVR_MAIN_INSTANCE_SETTING = "pvr_main_instance_id"
PVR_FAVS_INSTANCE_SETTING = "pvr_favs_instance_id"
PVR_SPORTS_INSTANCE_SETTING = "pvr_sports_instance_id"
PVR_SPORTS_FAVS_INSTANCE_SETTING = "pvr_sports_favs_instance_id"
PVR_INSTANCE_SETTINGS = {
    "main": PVR_MAIN_INSTANCE_SETTING,
    "favs": PVR_FAVS_INSTANCE_SETTING,
    "sports": PVR_SPORTS_INSTANCE_SETTING,
    "sports_favs": PVR_SPORTS_FAVS_INSTANCE_SETTING,
}
PVR_INSTANCE_FALLBACKS = {
    "main": PVR_MAIN_INSTANCE_ID,
    "favs": PVR_FAVS_INSTANCE_ID,
    "sports": PVR_SPORTS_INSTANCE_ID,
    "sports_favs": PVR_SPORTS_FAVS_INSTANCE_ID,
}


def _pvr_iptvsimple_profile_dir():
    return xbmcvfs.translatePath("special://profile/addon_data/pvr.iptvsimple")


def _pvr_instance_settings_path(instance_id):
    return os.path.join(
        _pvr_iptvsimple_profile_dir(),
        f"instance-settings-{instance_id}.xml",
    )


def _path_basename(path):
    return os.path.basename((path or "").replace("\\", "/"))


def _normalized_path(path):
    try:
        return os.path.normcase(os.path.abspath(path))
    except Exception:
        return os.path.normcase(str(path or ""))


def _is_own_addon_data_path(path):
    if not path:
        return False
    try:
        target = _normalized_path(path)
        profile = _normalized_path(xbmcvfs.translatePath(addon.getAddonInfo("profile")))
        return os.path.commonpath([target, profile]) == profile
    except Exception:
        return False


def _pvr_instance_role(settings_path):
    try:
        tree = ET.parse(settings_path)
        root = tree.getroot()
        name = root.find(".//setting[@id='kodi_addon_instance_name']")
        m3u = root.find(".//setting[@id='m3uPath']")
        name_text = (name.text or "") if name is not None else ""
        m3u_text = (m3u.text or "") if m3u is not None else ""
        basename = _path_basename(m3u_text)
        own = _is_own_addon_data_path(m3u_text)
        if own and name_text in (PVR_INSTANCE_NAME,) + PVR_LEGACY_MAIN_NAMES:
            return "main"
        if own and name_text in (PVR_FAVS_INSTANCE_NAME,) + PVR_LEGACY_FAVS_NAMES:
            return "favs"
        if own and name_text == PVR_SPORTS_INSTANCE_NAME:
            return "sports"
        if own and name_text == PVR_SPORTS_FAVS_INSTANCE_NAME:
            return "sports_favs"
        if own:
            if basename.startswith(f"pvr_live_{PVR_FILE_SLUG}_p") or basename.startswith("pvr_live_"):
                return "main"
            if basename.startswith(f"pvr_favorites_{PVR_FILE_SLUG}_p") or basename.startswith("pvr_favorites_"):
                return "favs"
            if basename.startswith(f"pvr_sports_{PVR_FILE_SLUG}_p"):
                return "sports"
            if basename.startswith(f"pvr_sports_favorites_{PVR_FILE_SLUG}_p"):
                return "sports_favs"
        return "foreign"
    except Exception:
        return "foreign"


def _scan_pvr_instances():
    profile = _pvr_iptvsimple_profile_dir()
    found = {}
    foreign = set()
    try:
        for fname in os.listdir(profile):
            m = re.match(r"instance-settings-(\d+)\.xml$", fname)
            if not m:
                continue
            iid = int(m.group(1))
            role = _pvr_instance_role(os.path.join(profile, fname))
            if role in PVR_INSTANCE_SETTINGS and role not in found:
                found[role] = iid
            else:
                foreign.add(iid)
    except Exception as e:
        _log(f"PVR instance scan warning: {e}")
    return found, foreign


def _get_pvr_instance_id(role):
    setting = PVR_INSTANCE_SETTINGS.get(role, PVR_MAIN_INSTANCE_SETTING)
    fallback = PVR_INSTANCE_FALLBACKS.get(role, PVR_MAIN_INSTANCE_ID)
    saved = addon.getSetting(setting)
    owned, foreign = _scan_pvr_instances()
    if role in owned:
        addon.setSetting(setting, str(owned[role]))
        return owned[role]
    used = foreign | set(owned.values())
    if saved and saved.isdigit():
        saved_iid = int(saved)
        if saved_iid not in used:
            return saved_iid
    iid = fallback
    while iid in used:
        iid += 1
    addon.setSetting(setting, str(iid))
    return iid


def _set_owned_pvr_instance_enabled(role, enabled, log_prefix="PVR"):
    settings_path = _pvr_instance_settings_path(_get_pvr_instance_id(role))
    if not os.path.exists(settings_path):
        _log(f"{log_prefix}: {role} instance missing, cannot set enabled={enabled}")
        return False
    if _pvr_instance_role(settings_path) != role:
        _log(f"{log_prefix}: refusing to modify non-owned/non-{role} instance {os.path.basename(settings_path)}")
        return False
    try:
        tree = ET.parse(settings_path)
        root = tree.getroot()
        el = root.find(".//setting[@id='kodi_addon_instance_enabled']")
        if el is None:
            el = ET.SubElement(root, "setting", {"id": "kodi_addon_instance_enabled"})
        desired = "true" if enabled else "false"
        if (el.text or "") == desired and "default" not in el.attrib:
            return True
        el.text = desired
        if "default" in el.attrib:
            del el.attrib["default"]
        tmp_path = settings_path + ".tmp"
        tree.write(tmp_path, encoding="utf-8", xml_declaration=True)
        with open(tmp_path, "r+b") as f:
            try:
                _safe_fsync(f)
            except OSError:
                pass
        os.replace(tmp_path, settings_path)
        _log(f"{log_prefix}: set {role} instance enabled={enabled}")
        return True
    except Exception as e:
        _log(f"{log_prefix}: failed to set {role} instance enabled={enabled}: {e}")
        return False


def _role_has_instance(role):
    path = _pvr_instance_settings_path(_get_pvr_instance_id(role))
    return os.path.exists(path) and _pvr_instance_role(path) == role


def _owned_pvr_instance_enabled(role):
    path = _pvr_instance_settings_path(_get_pvr_instance_id(role))
    if not os.path.exists(path) or _pvr_instance_role(path) != role:
        return None
    try:
        tree = ET.parse(path)
        root = tree.getroot()
        el = root.find(".//setting[@id='kodi_addon_instance_enabled']")
        if el is None:
            return True
        return (el.text or "").strip().lower() != "false"
    except Exception as e:
        _log(f"PVR scope: failed reading {role} enabled state: {e}")
        return None


def _current_pvr_instance_scope():
    main_on = bool(_owned_pvr_instance_enabled("main"))
    favs_on = bool(_owned_pvr_instance_enabled("favs"))
    sports_on = bool(_owned_pvr_instance_enabled("sports"))
    sports_favs_on = bool(_owned_pvr_instance_enabled("sports_favs"))

    main_visible = main_on or favs_on
    sports_visible = sports_on or sports_favs_on
    if main_visible and not sports_visible:
        return "main"
    if sports_visible and not main_visible:
        return "sports"
    if main_visible and sports_visible:
        return "mixed"
    return "none"


def _sports_pvr_ready():
    return _role_has_instance("sports") and _m3u_file_has_channels(_sports_pvr_m3u_path())


def _switch_pvr_instance_scope(scope, label=None, force=False, reload_epg=False):
    """Switch visible native PVR groups by enabling only the relevant owned IPTV Simple instances."""
    label = label or ("Sports PVR" if scope == "sports" else "Live TV PVR")
    if scope not in ("main", "sports"):
        _log(f"PVR scope: invalid scope {scope}")
        return False
    if not _is_pvr_iptvsimple_installed_or_disabled():
        prompt_install_pvr()
        return False

    current_scope = _current_pvr_instance_scope()
    if not force and current_scope == scope:
        required_role = "sports" if scope == "sports" else "main"
        if _role_has_instance(required_role) and _pvr_has_data():
            _log(
                f"PVR scope: already in {scope} scope with loaded channels; opening without PVR restart"
            )
            return True
        _log(
            f"PVR scope: already in {scope} scope but no loaded channels detected; forcing PVR restart"
        )
        force = True

    progress = xbmcgui.DialogProgress()
    progress.create("Xstream Pro", f"Switching to {label}")
    ok = False
    try:
        _pvr_progress(progress, 15, "Stopping PVR Manager")
        try:
            xbmc.executeJSONRPC('{"jsonrpc": "2.0", "method": "PVR.Manager.Stop", "id": 1}')
            xbmc.sleep(_tvos_sleep(800))
        except Exception as e:
            _log(f"PVR scope: Manager stop warning: {e}")

        _pvr_progress(progress, 30, "Disabling IPTV Simple")
        try:
            xbmc.executeJSONRPC(json.dumps({
                "jsonrpc": "2.0",
                "id": 1,
                "method": "Addons.SetAddonEnabled",
                "params": {"addonid": "pvr.iptvsimple", "enabled": False},
            }))
            xbmc.sleep(_tvos_sleep(1200))
            if sys.platform.startswith("win"):
                xbmc.sleep(_tvos_sleep(1500))
        except Exception as e:
            _log(f"PVR scope: disable warning: {e}")

        _pvr_progress(progress, 55, "Selecting visible PVR groups")
        if scope == "main":
            if not _role_has_instance("main"):
                xbmcgui.Dialog().notification("Xstream Pro", "Run full PVR sync first.", xbmcgui.NOTIFICATION_WARNING, 5000)
                return False
            _set_owned_pvr_instance_enabled("main", True, "PVR scope")
            if _role_has_instance("favs"):
                _set_owned_pvr_instance_enabled("favs", True, "PVR scope")
            if _role_has_instance("sports"):
                _set_owned_pvr_instance_enabled("sports", False, "PVR scope")
            if _role_has_instance("sports_favs"):
                _set_owned_pvr_instance_enabled("sports_favs", False, "PVR scope")
        else:
            if not _role_has_instance("sports"):
                xbmcgui.Dialog().notification("Xstream Pro", "Reload Sports PVR first.", xbmcgui.NOTIFICATION_WARNING, 5000)
                return False
            if _role_has_instance("main"):
                _set_owned_pvr_instance_enabled("main", False, "PVR scope")
            if _role_has_instance("favs"):
                _set_owned_pvr_instance_enabled("favs", False, "PVR scope")
            _set_owned_pvr_instance_enabled("sports", True, "PVR scope")
            if _role_has_instance("sports_favs"):
                _set_owned_pvr_instance_enabled("sports_favs", True, "PVR scope")

        ok = True
    finally:
        try:
            _pvr_progress(progress, 85, "Re-enabling IPTV Simple")
            xbmc.executeJSONRPC(json.dumps({
                "jsonrpc": "2.0",
                "id": 1,
                "method": "Addons.SetAddonEnabled",
                "params": {"addonid": "pvr.iptvsimple", "enabled": True},
            }))
            xbmc.sleep(_tvos_sleep(1500))
            _pvr_progress(progress, 95, "Starting PVR Manager")
            xbmc.executeJSONRPC('{"jsonrpc": "2.0", "method": "PVR.Manager.Start", "id": 1}')
            if reload_epg:
                _trigger_pvr_epg_reload()
            else:
                _log("PVR scope: skipped EPG reload for scope-only switch")
            _pvr_progress(progress, 100, "Done")
            xbmc.sleep(_tvos_sleep(300))
        except Exception as e:
            _log(f"PVR scope: re-enable warning: {e}")
        try:
            progress.close()
        except Exception:
            pass
    return ok


import xbmc

_ICON_CACHE = {}
_FANART_CACHE = {}

def _session_should_run(key, ttl_seconds=0):
    try:
        win = xbmcgui.Window(10000)
        prop = f"xstream_pro.{key}"
        now = time.time()
        raw = win.getProperty(prop)
        if raw:
            if not ttl_seconds:
                return False
            try:
                if now - float(raw) < ttl_seconds:
                    return False
            except ValueError:
                pass
        win.setProperty(prop, str(now))
    except Exception:
        return True
    return True

def _new_listitem(label=""):
    try:
        li = xbmcgui.ListItem(offscreen=True)
        if label:
            li.setLabel(label)
        return li
    except TypeError:
        return xbmcgui.ListItem(label=label)


def _bootstrap_settings():
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    dest = os.path.join(profile, "settings.xml")
    if os.path.exists(dest):
        return
    src = os.path.join(
        xbmcvfs.translatePath(addon.getAddonInfo("path")),
        "resources",
        "userdata",
        "settings.xml",
    )
    if os.path.exists(src):
        os.makedirs(profile, exist_ok=True)

        shutil.copy2(src, dest)
        xbmc.log(
            "[Xstream Pro] Bootstrapped settings from addon package", xbmc.LOGINFO
        )


_bootstrap_settings()


def _configure_kodi_pvr_osd():
    """Configure Kodi's PVR OSD navigation settings"""
    try:
        if (_get_setting_direct("pvr_osd_navigation", "true") or "").lower() != "true":
            return

        # Enable PVR channel info and guide on arrow keys
        # These are Kodi settings, not addon settings
        settings = {
            "pvrplayback.confirmchannelswitch": "false",  # Don't confirm channel switch
            "pvrplayback.channelentrytimeout": "0",  # Immediate channel switch
        }

        for setting, value in settings.items():
            xbmc.executeJSONRPC(
                json.dumps(
                    {
                        "jsonrpc": "2.0",
                        "method": "Settings.SetSettingValue",
                        "params": {"setting": setting, "value": value},
                        "id": 1,
                    }
                )
            )

        xbmc.log("[Xstream Pro] Configured Kodi PVR OSD settings", xbmc.LOGINFO)
    except Exception as e:
        xbmc.log(f"[Xstream Pro] PVR OSD config failed: {e}", xbmc.LOGWARNING)


def _install_pvr_keymap():
    """Install PVR keymap for left/right navigation respecting user toggles."""
    try:
        keymaps_dir = xbmcvfs.translatePath("special://profile/keymaps/")
        keymap_path = os.path.join(keymaps_dir, "xstream_pvr.xml")

        if (_get_setting_direct("pvr_osd_navigation", "true") or "").lower() != "true":
            if os.path.exists(keymap_path):
                try:
                    os.remove(keymap_path)
                except Exception as e:
                    xbmc.log(
                        f"[Xstream Pro] PVR keymap remove failed: {e}",
                        xbmc.LOGWARNING,
                    )
                if not os.path.exists(keymap_path):
                    try:
                        xbmc.executebuiltin("Action(ReloadKeymaps)")
                    except Exception:
                        pass
            return

        left_enabled = (_get_setting_direct("pvr_left_channels", "true") or "").lower() == "true"
        right_enabled = (_get_setting_direct("pvr_right_guide", "true") or "").lower() == "true"

        if not left_enabled and not right_enabled:
            if os.path.exists(keymap_path):
                try:
                    os.remove(keymap_path)
                except Exception as e:
                    xbmc.log(
                        f"[Xstream Pro] PVR keymap remove failed: {e}",
                        xbmc.LOGWARNING,
                    )
                if not os.path.exists(keymap_path):
                    try:
                        xbmc.executebuiltin("Action(ReloadKeymaps)")
                    except Exception:
                        pass
            return

        lines = ['<?xml version="1.0" encoding="UTF-8"?>', "<keymap>"]
        for window in ("FullscreenLiveTV",):
            lines.append(f"  <{window}>")
            for device in ("keyboard", "remote"):
                lines.append(f"    <{device}>")
                if left_enabled:
                    lines.append("      <left>ActivateWindow(PVROSDChannels)</left>")
                if right_enabled:
                    lines.append("      <right>ActivateWindow(PVROSDGuide)</right>")
                lines.append(f"    </{device}>")
            lines.append(f"  </{window}>")
        lines.append("</keymap>")
        keymap_xml = "\n".join(lines) + "\n"

        # Rewrite only if content changed (or file missing)
        if os.path.exists(keymap_path):
            try:
                with open(keymap_path, "r", encoding="utf-8") as f:
                    if f.read() == keymap_xml:
                        return
            except Exception as e:
                xbmc.log(f"[Xstream Pro] keymap read warning: {e}", xbmc.LOGWARNING)

        os.makedirs(keymaps_dir, exist_ok=True)
        with open(keymap_path, "w", encoding="utf-8") as f:
            f.write(keymap_xml)
        xbmc.log("[Xstream Pro] Installed PVR keymap", xbmc.LOGINFO)
        try:
            xbmc.executebuiltin("Action(ReloadKeymaps)")
        except Exception:
            pass
    except Exception as e:
        xbmc.log(f"[Xstream Pro] PVR keymap install failed: {e}", xbmc.LOGWARNING)


def _is_playing_pvr_video():
    try:
        if xbmc.getCondVisibility("Pvr.IsPlayingTv") or xbmc.getCondVisibility("Pvr.IsPlayingRadio"):
            return True
        playing_path = xbmc.getInfoLabel("Player.Filenameandpath") or ""
        return playing_path.startswith("pvr://")
    except Exception:
        return False


def _pvr_osd_key_action(direction):
    if direction == "left":
        pvr_action = "ActivateWindow(PVROSDChannels)"
        fallback_action = "Action(StepBack)"
    else:
        pvr_action = "ActivateWindow(PVROSDGuide)"
        fallback_action = "Action(StepForward)"
    xbmc.executebuiltin(pvr_action if _is_playing_pvr_video() else fallback_action)


def _bootstrap_pvr():
    """Create empty PVR stub files and point pvr.iptvsimple instance 31 at them."""
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    os.makedirs(profile, exist_ok=True)
    m3u_path = _pvr_m3u_path()
    epg_path = _pvr_epg_path()
    if not os.path.exists(m3u_path):
        _atomic_write_text(m3u_path, "#EXTM3U\n")
    if not os.path.exists(epg_path):
        _atomic_write_text(epg_path, '<?xml version="1.0" encoding="utf-8"?><tv></tv>\n')
    favs_path = _pvr_favs_m3u_path()
    if not os.path.exists(favs_path):
        _atomic_write_text(favs_path, "#EXTM3U\n")
    _configure_kodi_pvr_osd()
    _install_pvr_keymap()


_LOG_REDACT_PATH_RE = re.compile(
    r"(/(?:live|movie|series)/)([^/?\s]+)(/?)([^/?\s]*)", re.IGNORECASE
)
_LOG_REDACT_QUERY_RE = re.compile(r"(?i)\b(password|pwd|pass|username|user)=([^&\s]+)")
_LOG_MAX_LEN = 4000


def _log(msg):
    safe_msg = str(msg)
    # Redact /live/USER/PASS, /movie/USER/PASS, /series/USER/PASS path credentials
    safe_msg = _LOG_REDACT_PATH_RE.sub(
        lambda m: (
            m.group(1)
            + "***REDACTED***"
            + m.group(3)
            + ("***REDACTED***" if m.group(4) else "")
        ),
        safe_msg,
    )
    # Redact password=, pwd=, pass= query params
    safe_msg = _LOG_REDACT_QUERY_RE.sub(r"\1=***REDACTED***", safe_msg)
    if len(safe_msg) > _LOG_MAX_LEN:
        safe_msg = safe_msg[:_LOG_MAX_LEN] + "...[truncated]"
    xbmc.log(f"[Xstream Pro] {safe_msg}", xbmc.LOGINFO)


def _log_route_timing(route_label=None):
    try:
        elapsed_ms = int((time.monotonic() - _ROUTE_START) * 1000)
        label = route_label or mode or "root"
        _log("Route timing: {0} finished in {1} ms".format(label, elapsed_ms))
    except Exception:
        pass


def _is_tvos():
    """Detect tvOS (Apple TV).
    Kodi reports tvOS as System.Platform.OSX (same as macOS).
    We disambiguate by checking the profile path:
    tvOS uses sandboxed Containers/.../Library/Caches/Kodi,
    macOS uses ~/Library/Application Support/Kodi.
    """
    if not xbmc.getCondVisibility("System.Platform.OSX"):
        return False
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    # tvOS paths contain 'Containers' and 'Library/Caches'
    # macOS paths contain 'Application Support'
    return "Containers" in profile or "Library/Caches/Kodi" in profile


def _tvos_sleep(ms):
    """Reduce sleep duration on tvOS to avoid SpringBoard watchdog kills."""
    if _is_tvos() and ms > 500:
        return max(ms // 3, 500)
    return ms


def _safe_fsync(f):
    """Skip fsync on tvOS — can cause EINVAL or hard crashes on APFS sandbox."""
    if _is_tvos():
        return
    try:
        os.fsync(f.fileno())
    except OSError:
        pass


def _atomic_write_text(path, data):
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        f.write(data)
        try:
            f.flush()
            _safe_fsync(f)
        except OSError:
            pass
    os.replace(tmp, path)


def _m3u_file_has_channels(path):
    try:
        with open(path, "r", encoding="utf-8") as f:
            return any(line.startswith("#EXTINF:") for line in f)
    except OSError:
        return False


def _restart_or_prompt():
    """Platform-aware restart. RestartApp is a no-op on Android (Shield, Fire TV);
    on tvOS it crashes Kodi, so we instruct manual restart on both."""
    if xbmc.getCondVisibility("System.Platform.Android") or _is_tvos():
        xbmcgui.Dialog().ok(_t(30042), _t(30569))
    else:
        xbmc.executebuiltin("RestartApp")


def _apply_buffer_fix():
    """Write cache settings to advancedsettings.xml (Kodi standard)."""
    if (_get_setting_direct("buffer_fix_enabled", "false") or "").lower() != "true":
        return
    try:
        size_mb = int(_get_setting_direct("buffer_size_mb", "100") or "100")
    except ValueError:
        size_mb = 100
    try:
        read_factor = int(_get_setting_direct("buffer_read_factor", "20") or "20")
    except ValueError:
        read_factor = 20
    memorysize_bytes = size_mb * 1024 * 1024

    adv_path = xbmcvfs.translatePath("special://home/userdata/advancedsettings.xml")
    adv_existed = os.path.exists(adv_path)
    try:
        if adv_existed:
            tree = ET.parse(adv_path)
            root = tree.getroot()
        else:
            root = ET.Element("advancedsettings")
            tree = ET.ElementTree(root)

        cache_el = root.find("cache")
        if cache_el is None:
            cache_el = ET.SubElement(root, "cache")

        target = {
            "buffermode": "1",
            "memorysize": str(memorysize_bytes),
            "readfactor": str(read_factor),
        }

        unchanged = True
        for tag, val in target.items():
            el = cache_el.find(tag)
            if el is None or (el.text or "") != val:
                unchanged = False
                break

        if unchanged:
            _log("Buffer fix already correct, skipping write and restart prompt")
            return

        for tag, val in target.items():
            el = cache_el.find(tag)
            if el is None:
                el = ET.SubElement(cache_el, tag)
            el.text = val

        tmp = adv_path + ".tmp"
        tree.write(tmp, encoding="utf-8", xml_declaration=True)
        with open(tmp, "r+b") as f:
            try:
                _safe_fsync(f)
            except OSError as e:
                _log(f"Buffer fix fsync warning: {e}")
        os.replace(tmp, adv_path)
    except Exception as e:
        _log(f"Buffer fix advancedsettings.xml write failed: {e}")
        return

    _log(
        f"Buffer fix applied via advancedsettings.xml: {size_mb}MB, read factor {read_factor}x"
    )

    if adv_existed:
        # Defer restart prompt to main_menu() — dialogs during bootstrap crash tvOS
        # Skip on very first addon run: user just installed and shouldn't be
        # nagged before they even see the menu.
        profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
        if os.path.exists(os.path.join(profile, ".bootstrap_signature")):
            addon.setSetting("buffer_fix_needs_restart", "true")
            _log("Buffer fix changed; restart prompt deferred")
        else:
            _log("Buffer fix changed but first run — restart prompt skipped")
    else:
        _log(
            "Buffer fix advancedsettings.xml created for the first time; "
            "restart prompt skipped"
        )


def _run_one_time_bootstrap():
    """Gated startup work. The hot path is plugin reinvocation for menu
    navigation, so we skip bootstrap whenever the relevant inputs are
    unchanged. Sidecar stores `addon_version|buffer_enabled|size_mb|read_factor`;
    a mismatch (new install, addon upgrade, or buffer setting change) re-runs."""
    try:
        addon_version = addon.getAddonInfo("version")
        profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
        os.makedirs(profile, exist_ok=True)
        sidecar = os.path.join(profile, ".bootstrap_signature")
        # Read sidecar before getSetting calls so we can skip settings reads
        # entirely on the rare version-mismatch path (new install / upgrade).
        prev = ""
        if os.path.exists(sidecar):
            try:
                with open(sidecar, "r", encoding="utf-8") as f:
                    prev = f.read().strip()
            except Exception:
                prev = ""
        buf_enabled = _get_setting_direct("buffer_fix_enabled", "false") or "false"
        buf_size = _get_setting_direct("buffer_size_mb", "100") or "100"
        buf_read = _get_setting_direct("buffer_read_factor", "20") or "20"
        pvr_osd = _get_setting_direct("pvr_osd_navigation", "true") or "true"
        pvr_left = _get_setting_direct("pvr_left_channels", "true") or "true"
        pvr_right = _get_setting_direct("pvr_right_guide", "true") or "true"
        signature = f"{addon_version}|{buf_enabled}|{buf_size}|{buf_read}|{pvr_osd}|{pvr_left}|{pvr_right}"
        keymap_path = os.path.join(
            xbmcvfs.translatePath("special://profile/keymaps/"),
            "xstream_pvr.xml",
        )
        keymap_missing = (
            (pvr_osd or "").lower() == "true"
            and (
                (pvr_left or "").lower() == "true"
                or (pvr_right or "").lower() == "true"
            )
            and not os.path.exists(keymap_path)
        )
        if prev == signature and not keymap_missing:
            return
        try:
            _bootstrap_pvr()
        except Exception as e:
            xbmc.log(
                f"[Xstream Pro] PVR bootstrap error: {e}",
                xbmc.LOGWARNING,
            )
        _apply_buffer_fix()
        _cache_cleanup_stale()
        try:
            import cache
            cache.clean_expired()
        except Exception as e:
            xbmc.log(
                f"[Xstream Pro] cache cleanup warning: {e}", xbmc.LOGWARNING
            )
        try:
            with open(sidecar, "w", encoding="utf-8") as f:
                f.write(signature)
        except Exception as e:
            xbmc.log(
                f"[Xstream Pro] bootstrap sidecar write failed: {e}", xbmc.LOGWARNING
            )
    except Exception as e:
        xbmc.log(f"[Xstream Pro] one-time bootstrap failed: {e}", xbmc.LOGERROR)


def build_url(query):
    return base_url + "?" + urllib.parse.urlencode(query, doseq=True)


def _apply_sort(stype):
    """Apply Kodi sort method based on per-type user preference.
    'Newest first' is handled via Python pre-sort before pagination,
    so this only registers Kodi-level sort when 'A-Z' is selected.
    """
    setting_key = f"sort_order_{stype}"
    order = addon.getSetting(setting_key) or "Provider order"
    if order == "A-Z":
        xbmcplugin.addSortMethod(addon_handle, xbmcplugin.SORT_METHOD_LABEL)


def _get_pagination_limit(stype):
    """Return per-page limit from settings. Unlimited returns 0 (show all)."""
    val = addon.getSetting(f"pagination_{stype}") or "Unlimited"
    if val == "Unlimited":
        return 0
    try:
        return int(val)
    except ValueError:
        return 0


def _m3u_has_credentials(m3u_url):
    """Return True if the M3U URL contains embedded credentials in any supported format.

    M3U URLs with credentials are treated like Xtream Codes for UI purposes,
    showing separate Live TV, Movies, and Series categories.
    """
    from profiles import parse_m3u_credentials
    base, user, pwd = parse_m3u_credentials(m3u_url)
    return bool(base and user and pwd)


def _get_credentials():
    """Get credentials for current profile (pm.active).

    ProfileManager already extracts Xtream credentials from M3U URLs,
    so we return its result directly.
    """
    return pm.get_credentials()


class _LazyFavorites:
    """Proxy that defers Favorites(addon, 'global') load until first attribute access."""
    _real = None

    def _instance(self):
        if self._real is None:
            _LazyFavorites._real = Favorites(addon, "global")
        return _LazyFavorites._real

    def __getattr__(self, name):
        return getattr(self._instance(), name)


fav = _LazyFavorites()

# Cache Favorites instances per profile to avoid repeated JSON file reads
_profile_fav_instances = {}


def _get_profile_fav(profile_num):
    key = str(profile_num)
    if key not in _profile_fav_instances:
        _profile_fav_instances[key] = Favorites(addon, key)
    return _profile_fav_instances[key]


def _invalidate_profile_fav(profile_num):
    _profile_fav_instances.pop(str(profile_num), None)


def _build_favorite_ctx(
    item_id,
    name,
    stype,
    icon,
    url,
    epg_id="",
    catchup="",
    catchup_source="",
    catchup_days="",
    profile_num=None,
    year="",
    showname="",
    season="",
    episode="",
    include_download=True,
):
    """Build context-menu tuples for toggling an item in addon favorites.

    Returns a list of (label, action) tuples.
    """
    ctx = []
    if profile_num:
        profile_fav = _get_profile_fav(profile_num)
        if profile_fav.is_favorite(item_id):
            ctx.append(
                (
                    _t(30747),
                    f"RunPlugin({build_url({'mode': 'toggle_profile_fav', 'id': item_id, 'name': name, 'stype': stype, 'icon': icon, 'url': url, 'epg_id': epg_id, 'pnum': profile_num})})",
                )
            )
        else:
            ctx.append(
                (
                    _t(30746),
                    f"RunPlugin({build_url({'mode': 'toggle_profile_fav', 'id': item_id, 'name': name, 'stype': stype, 'icon': icon, 'url': url, 'epg_id': epg_id, 'pnum': profile_num})})",
                )
            )

    protected_folders = {"Favorites", _t(30009) or "Favorites"}
    for gname in fav.get_folders():
        if gname in protected_folders:
            continue
        ctx.append(
            (
                _t(30219, gname),
                f"RunPlugin({build_url({'mode': 'toggle_fav', 'id': item_id, 'name': name, 'stype': stype, 'icon': icon, 'url': url, 'epg_id': epg_id, 'folder': gname, 'catchup': catchup, 'catchup_source': catchup_source, 'catchup_days': catchup_days})})",
            )
        )

    # Download option (gated by URL type and caller flag)
    if include_download and is_url_downloadable(url):
        download_url = build_url({
            "mode": "download",
            "url": url,
            "title": name,
            "stype": stype,
            "profile_num": profile_num or pm.active,
        })
        ctx.append((_t(30941), f"RunPlugin({download_url})"))

    return ctx


def _folder_favorite_ctx(name, url, icon):
    """Build context-menu tuples for a folder-type favorite."""
    return _build_favorite_ctx(url, name, "folder", icon, url, include_download=False)


def _tmdb_content_type(tmdb_type, season="", episode=""):
    if tmdb_type == "tv" and (season or episode):
        return "episodes"
    if tmdb_type == "tv":
        return "tvshows"
    return "movies"


def _set_tmdb_content(tmdb_type, season="", episode=""):
    xbmcplugin.setContent(addon_handle, _tmdb_content_type(tmdb_type, season, episode))


def _tmdb_list(value):
    if not value:
        return []
    if isinstance(value, list):
        return [str(v).strip() for v in value if str(v).strip()]
    return [v.strip() for v in re.split(r"\s*,\s*|\s*/\s*", str(value)) if v.strip()]


def _tmdb_watched_key(tmdb_type, tmdb_id, kind="", season="", episode=""):
    if tmdb_type == "movie":
        return "movie", str(tmdb_id)
    if tmdb_type == "tv" and season and episode:
        return "episode", (str(tmdb_id), str(season), str(episode))
    if tmdb_type == "tv" and kind == "season" and season:
        return "movie", "season:{0}:{1}".format(tmdb_id, season)
    if tmdb_type == "tv":
        return "movie", "tvshow:{0}".format(tmdb_id)
    return "", ""


def _safe_set(callable, field_name=""):
    """Run a metadata setter, logging failures lightly."""
    try:
        callable()
    except Exception:
        if field_name:
            _log(f"TMDB info: failed to set {field_name}")


def _set_tmdb_item_info(li, label, icon, art, tmdb_type, year="", plot="", season="", episode="", playcount=0, runtime="", cast=None, genre=None, director=None, writer=None, studio=None, mpaa="", tagline="", rating="", votes="", premiered="", background_fanart=""):
    media_type = "episode" if tmdb_type == "tv" and (season or episode) else ("tvshow" if tmdb_type == "tv" else "movie")
    li.setArt({
        "icon": icon,
        "thumb": icon,
        "poster": art.get("poster") or icon,
        "fanart": background_fanart or art.get("fanart", ""),
        "banner": art.get("banner", ""),
        "clearlogo": art.get("clearlogo", ""),
        "landscape": art.get("landscape", ""),
    })
    info = li.getVideoInfoTag()
    info.setMediaType(media_type)
    info.setTitle(label)
    if plot:
        info.setPlot(plot)
    if year:
        _safe_set(lambda: info.setYear(int(year)), "year")
    if season:
        _safe_set(lambda: info.setSeason(int(season)), "season")
    if episode:
        _safe_set(lambda: info.setEpisode(int(episode)), "episode")
    _safe_set(lambda: info.setPlaycount(int(playcount or 0)), "playcount")
    if runtime:
        _safe_set(lambda: info.setDuration(int(runtime)), "duration")
    if cast and isinstance(cast, list):
        def _set_cast():
            cast_list = []
            for idx, actor in enumerate(cast[:10]):
                name = actor.get("name", "") if isinstance(actor, dict) else str(actor)
                role = actor.get("role", "") if isinstance(actor, dict) else ""
                thumbnail = actor.get("thumbnail", "") if isinstance(actor, dict) else ""
                if name:
                    cast_list.append(xbmc.Actor(name, role, idx, thumbnail))
            if cast_list:
                info.setCast(cast_list)
        _safe_set(_set_cast, "cast")
    genres = _tmdb_list(genre)
    if genres:
        _safe_set(lambda: info.setGenres(genres), "genres")
    directors = _tmdb_list(director)
    if directors:
        _safe_set(lambda: info.setDirectors(directors), "directors")
    writers = _tmdb_list(writer)
    if writers:
        _safe_set(lambda: info.setWriters(writers), "writers")
    studios = _tmdb_list(studio)
    if studios:
        _safe_set(lambda: info.setStudios(studios), "studios")
    if mpaa:
        _safe_set(lambda: info.setMpaa(str(mpaa)), "mpaa")
    if tagline:
        _safe_set(lambda: info.setTagLine(str(tagline)), "tagline")
    if rating:
        _safe_set(lambda: info.setRating(float(rating), int(votes) if votes else 0), "rating")
    if premiered:
        _safe_set(lambda: info.setPremiered(str(premiered)), "premiered")


def _count_enabled_profiles():
    count = 0
    for n in range(1, 11):
        if addon.getSetting(f"profile_{n}_enabled") == "true":
            count += 1
    return count


def _profile_setting_direct(profile_num, key, default=""):
    return _get_setting_direct(
        f"profile_{profile_num}_{key}",
        addon.getSetting(f"profile_{profile_num}_{key}") or default,
    )

def _profile_has_configured_credentials(profile_num):
    if (_profile_setting_direct(profile_num, "enabled", "false") or "").lower() != "true":
        return False
    m3u_url = (_profile_setting_direct(profile_num, "m3u") or "").strip()
    xtream_url = (_profile_setting_direct(profile_num, "xtream_url") or "").strip()
    xtream_user = (_profile_setting_direct(profile_num, "xtream_username") or "").strip()
    xtream_pwd = (_profile_setting_direct(profile_num, "xtream_password") or "").strip()
    return bool(m3u_url or (xtream_url and xtream_user and xtream_pwd))

def _any_profile_has_credentials():
    for n in range(1, 11):
        if _profile_has_configured_credentials(n):
            return True
    return False


def _get_credentials_for_profile(profile_num):
    """Get credentials for a specific profile number (1-10).

    Temporarily switches pm.active to get correct credentials,
    then restores original active profile.
    """
    original_active = pm.active
    try:
        pm.active = str(profile_num)
        return _get_credentials()
    finally:
        pm.active = original_active


def _call_with_active_profile(profile_num, fn, *args, **kwargs):
    """Run a callable with pm.active temporarily set to profile_num.

    Sports uses explicit profile credentials, but the shared raw cache helpers
    key their files by pm.active. This wrapper lets Sports use the same cache
    system without cache churn between profiles.
    """
    original_active = pm.active
    try:
        pm.active = str(profile_num)
        return fn(*args, **kwargs)
    finally:
        pm.active = original_active


def _prompt_profile_credentials(profile_num):
    """Show a dialog if profile is enabled but has no credentials.
    Returns True if credentials exist, False if user cancelled.
    """
    creds = _get_credentials_for_profile(profile_num)
    if creds.get("xtream_url") or creds.get("m3u_url"):
        return True
    profile_name = (
        creds.get("name")
        or addon.getSetting(f"profile_{profile_num}_name")
        or _t(30390, profile_num)
    )
    choice = xbmcgui.Dialog().yesno(
        profile_name,
        _t(31145),
        yeslabel="Settings",
        nolabel="Cancel",
    )
    if choice:
        xbmc.executebuiltin("RunPlugin(plugin://plugin.video.xstream-pro/?mode=settings)")
    return False


def _get_pvr_credentials():
    """Get credentials for the PVR profile (active_pvr_profile setting)."""
    pvr_profile = addon.getSetting("active_pvr_profile") or "Profile 1"
    match = re.search(r"(\d+)$", pvr_profile)
    pnum = match.group(1) if match else "1"

    # Temporarily set pm.active to get credentials for PVR profile
    original = pm.active
    try:
        pm.active = pnum
        return _get_credentials()
    finally:
        pm.active = original


def _select_profile_or_all(allow_all=True):
    """Show a dialog to select an enabled profile. Returns profile number string,
    'all' if allow_all is True and user picks All Profiles, or None on cancel."""
    profiles = []
    for i in range(1, 11):
        if addon.getSetting(f"profile_{i}_enabled") == "true":
            name = addon.getSetting(f"profile_{i}_name") or _t(30390, i)
            label = _t(31400, i, name) if name != _t(30390, i) else _t(30390, i)
            profiles.append((str(i), label))
    if not profiles:
        xbmcgui.Dialog().notification("Xstream Pro", _t(30056))
        return None
    labels = [p[1] for p in profiles]
    if allow_all:
        labels.insert(0, _t(30018))  # All Profiles
    idx = xbmcgui.Dialog().select(_t(30017), labels)
    if idx < 0:
        return None
    if allow_all:
        if idx == 0:
            return "all"
        return profiles[idx - 1][0]
    return profiles[idx][0]


def _prefetch_vod_info_batch(streams, base_url, user, pwd):
    """Fetch vod_info for a batch of movies in parallel threads."""
    profile_snapshot = pm.active

    def _fetch_one(s):
        original_active = pm.active
        try:
            pm.active = profile_snapshot
            sid = str(s.get("stream_id", ""))
            if not sid:
                return
            cname = f"vod_info_{sid}"
            cached = _cache_load(cname)
            if cached:
                return
            try:
                info = IPTV.get_vod_info(base_url, user, pwd, sid)
                if info:
                    _cache_save(cname, info)
            except Exception as e:
                _log(f"VOD info prefetch error for {sid}: {e}")
        finally:
            pm.active = original_active

    # Throttled prefetch: max 4 concurrent threads with 200ms gap between batches
    # to avoid overwhelming the Xtream provider.
    batch_size = 4
    delay_ms = 200
    all_threads = []
    for i in range(0, len(streams), batch_size):
        batch = streams[i:i + batch_size]
        threads = []
        for s in batch:
            t = threading.Thread(target=_fetch_one, args=(s,))
            t.daemon = True
            threads.append(t)
            t.start()
        all_threads.extend(threads)
        if i + batch_size < len(streams):
            xbmc.sleep(delay_ms)
    # Single 15s deadline for the whole batch
    deadline = time.monotonic() + 15
    for t in all_threads:
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            break
        t.join(timeout=remaining)


def _enrich_movie_info(s, base_url=None, user=None, pwd=None):
    # Provider metadata toggles for movies
    use_prov = addon.getSetting("provider_movie_enabled").lower() == "true"
    use_prov_plot = use_prov and (addon.getSetting("provider_movie_plot").lower() == "true")
    use_prov_posters = use_prov and (addon.getSetting("provider_movie_posters").lower() == "true")
    use_prov_ratings = use_prov and (addon.getSetting("provider_movie_ratings").lower() == "true")
    use_prov_cast = use_prov and (addon.getSetting("provider_movie_cast").lower() == "true")
    use_prov_genre = use_prov and (addon.getSetting("provider_movie_genre").lower() == "true")
    use_prov_duration = use_prov and (addon.getSetting("provider_movie_duration").lower() == "true")

    result = {
        "plot": "",
        "poster_url": "",
        "rating": "",
        "year": "",
        "cast": [],
        "genre": "",
        "duration": 0,
        "tmdb_id": "",
        "imdb_id": "",
    }

    # Apply provider data from stream list item based on toggles
    if use_prov_plot:
        result["plot"] = (
            s.get("plot") or s.get("description") or s.get("info", {}).get("plot") or ""
        )
    if use_prov_posters:
        result["poster_url"] = s.get("stream_icon") or ""
    if use_prov_ratings:
        result["rating"] = str(s.get("rating", "") or s.get("rating_5based", "") or "")

    # Provider vod_info enrichment (only if provider metadata is enabled)
    if use_prov and s.get("stream_id"):
        sid = str(s.get("stream_id"))
        cname = f"vod_info_{sid}"
        info = _cache_load(cname)
        if not info and base_url and user and pwd:
            # On-demand fallback: prefetch missed this movie or cache is empty, fetch it now
            try:
                info = IPTV.get_vod_info(base_url, user, pwd, sid)
                if info:
                    _cache_save(cname, info)
                else:
                    info = {}
            except Exception as e:
                _log(f"VOD info on-demand fetch error for {sid}: {e}")
                info = {}
        if info:
            if use_prov_plot:
                result["plot"] = (
                    info.get("info", {}).get("plot") or info.get("plot") or result["plot"]
                )
            if use_prov_posters:
                result["poster_url"] = (
                    info.get("info", {}).get("movie_image")
                    or info.get("stream_icon")
                    or result["poster_url"]
                )
            if use_prov_ratings:
                result["year"] = str(
                    info.get("info", {}).get("releasedate", "")
                    or info.get("info", {}).get("release_date", "")
                    or ""
                )[:4]
            if use_prov_duration:
                result["duration"] = int(info.get("info", {}).get("duration_secs", 0) or 0)
            if use_prov_cast and info.get("info", {}).get("cast"):
                cast_list = []
                for idx, actor_name in enumerate(info["info"]["cast"].split(",")[:10]):
                    name = actor_name.strip()
                    if name:
                        cast_list.append({"name": name, "role": "", "thumbnail": ""})
                result["cast"] = cast_list
            if use_prov_genre and info.get("info", {}).get("genre"):
                result["genre"] = info["info"]["genre"]
            tmdb_from_info = info.get("info", {}).get("tmdb_id") or info.get("info", {}).get("tmdb") or ""
            imdb_from_info = info.get("info", {}).get("imdb_id") or info.get("info", {}).get("imdb") or ""
            if tmdb_from_info:
                result["tmdb_id"] = str(tmdb_from_info)
            if imdb_from_info:
                result["imdb_id"] = str(imdb_from_info)

    # Playback-discovered duration fallback
    if use_prov_duration and not result["duration"] and s.get("stream_id"):
        pb_dur = _load_playback_duration(str(s.get("stream_id")))
        if pb_dur:
            result["duration"] = pb_dur

    return result

def _enrich_series_info(s, base_url=None, user=None, pwd=None):
    # Provider metadata toggles for series
    use_prov = addon.getSetting("provider_series_enabled").lower() == "true"
    use_prov_plot = use_prov and (addon.getSetting("provider_series_plot").lower() == "true")
    use_prov_posters = use_prov and (addon.getSetting("provider_series_posters").lower() == "true")
    use_prov_ratings = use_prov and (addon.getSetting("provider_series_ratings").lower() == "true")
    use_prov_cast = use_prov and (addon.getSetting("provider_series_cast").lower() == "true")
    use_prov_genre = use_prov and (addon.getSetting("provider_series_genre").lower() == "true")
    use_prov_duration = use_prov and (addon.getSetting("provider_series_duration").lower() == "true")

    result = {
        "plot": "",
        "poster_url": "",
        "rating": "",
        "year": "",
        "cast": [],
        "genre": "",
        "duration": 0,
    }

    # Apply provider data from stream list item based on toggles
    if use_prov_plot:
        result["plot"] = s.get("plot", "")
    if use_prov_posters:
        result["poster_url"] = s.get("cover", "")
    if use_prov_ratings:
        result["rating"] = str(s.get("rating", "") or s.get("rating_5based", "") or "")
    if use_prov_genre and s.get("genre"):
        result["genre"] = s.get("genre")
    if use_prov_cast and s.get("cast"):
        cast_list = []
        for idx, actor_name in enumerate(s.get("cast").split(",")[:10]):
            name = actor_name.strip()
            if name:
                cast_list.append({"name": name, "role": "", "thumbnail": ""})
        result["cast"] = cast_list
    if use_prov_duration and s.get("episode_run_time"):
        try:
            ep_run_time = int(s.get("episode_run_time"))
            result["duration"] = ep_run_time * 60
        except (ValueError, TypeError):
            pass

    return result


def _cache_path(name):
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    # Sanitize name to prevent path traversal - only allow alphanumeric, hyphen, underscore
    safe_name = re.sub(r"[^a-zA-Z0-9_-]", "", str(name))
    return os.path.join(profile, f"data_cache_p{pm.active}_{safe_name}.json")


def _playback_duration_path(stream_id):
    """Path for playback-discovered duration cache (global, keyed by stream_id)."""
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    safe_id = re.sub(r"[^a-zA-Z0-9_-]", "", str(stream_id))
    return os.path.join(profile, f"playback_duration_{safe_id}.json")


def _save_playback_duration(stream_id, duration):
    """Cache duration discovered during playback for reuse in list views."""
    if not stream_id or not duration:
        return
    path = _playback_duration_path(stream_id)
    tmp = path + ".tmp"
    try:
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump({"duration": int(duration), "timestamp": time.time()}, f)
            try:
                f.flush()
                _safe_fsync(f)
            except OSError:
                pass
        os.replace(tmp, path)
    except Exception as e:
        _log(f"Playback duration save error: {e}")


def _load_playback_duration(stream_id):
    """Return cached playback-discovered duration in seconds, or 0."""
    if not stream_id:
        return 0
    try:
        path = _playback_duration_path(stream_id)
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
            return int(data.get("duration", 0))
    except Exception:
        return 0


def _cache_load(name):
    try:
        with open(_cache_path(name), "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return None


def _cache_save(name, data):
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    os.makedirs(profile, exist_ok=True)
    cache_file = _cache_path(name)
    tmp_file = cache_file + ".tmp"
    with open(tmp_file, "w", encoding="utf-8") as f:
        json.dump(data, f)
        # Durability: atomic rename is not enough on Android — without fsync
        # the rename can land before the data, leaving a zero-byte cache file
        # after a crash / power loss.
        try:
            f.flush()
            _safe_fsync(f)
        except OSError:
            pass
    os.replace(tmp_file, cache_file)


def _cache_valid(name, hours=None):
    if hours is not None:
        pass  # caller-specified
    elif name.startswith("vod_info_"):
        hours = 30 * 24  # 30 days for static metadata
    else:
        try:
            hours = float(addon.getSetting("auto_refresh_interval") or "24")
        except ValueError:
            hours = 24
    path = _cache_path(name)
    if not os.path.exists(path):
        return False
    age = time.time() - os.path.getmtime(path)
    return age < (hours * 3600)


def _cache_clear_all():
    """Clear cache for all profiles."""
    count = 0
    for pnum in range(1, 11):
        count += _cache_clear_profile(pnum)
    return count


def _cache_clear_profile(pnum):
    """Clear cache for a specific profile number (navigation + EPG only)."""
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    prefix = f"data_cache_p{pnum}_"
    count = 0
    try:
        for fname in os.listdir(profile):
            if fname.startswith(prefix):
                # Boundary check: p1 must not match p10, p11, etc.
                rest = fname[len(prefix) :]
                if rest and rest[0].isdigit():
                    continue
                os.remove(os.path.join(profile, fname))
                count += 1
            elif fname == f"epg_cache_profile_{pnum}.json":
                os.remove(os.path.join(profile, fname))
                count += 1
    except Exception as e:
        _log(f"Cache clear error: {e}")
    return count


def _purge_profile_data(pnum):
    """Clear ALL profile data except PVR and favorites (for profile unload/refresh)."""
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    count = _cache_clear_profile(pnum)
    try:
        for fname in os.listdir(profile):
            if fname in (
                f"watch_history_p{pnum}.json",
                f"resume_points_p{pnum}.json",
                f"watched_movies_p{pnum}.json",
                f"watched_episodes_p{pnum}.json",
                f"search_history_p{pnum}.json",
                f"main_menu_order_p{pnum}.json",
            ):
                os.remove(os.path.join(profile, fname))
                count += 1
            elif fname.startswith("hidden_subcats_") and fname.endswith(f"_p{pnum}.json"):
                os.remove(os.path.join(profile, fname))
                count += 1
            elif fname.startswith("hidden_items_") and fname.endswith(f"_p{pnum}.json"):
                os.remove(os.path.join(profile, fname))
                count += 1
    except Exception as e:
        _log(f"Purge profile error: {e}")
    return count


def _cache_clear_type(pnum, stype):
    """Clear cache files for a specific content type on a profile."""
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    targets = [
        f"data_cache_p{pnum}_xtream_cats_{stype}.json",
        f"data_cache_p{pnum}_xtream_streams_{stype}.json",
    ]
    if stype == "live":
        targets.append(f"epg_cache_profile_{pnum}.json")
    count = 0
    try:
        for fname in targets:
            path = os.path.join(profile, fname)
            if os.path.exists(path):
                os.remove(path)
                count += 1
    except Exception as e:
        _log(f"Cache clear type error: {e}")
    return count


def _cache_clear_all_profiles():
    """Clear cache for all profiles."""
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    count = 0
    try:
        for fname in os.listdir(profile):
            if (
                fname.startswith("data_cache_")
                or fname.startswith("epg_cache_profile_")
                or fname.startswith("vod_info_")

                or fname.startswith("playback_duration_")
            ):
                os.remove(os.path.join(profile, fname))
                count += 1
    except Exception as e:
        _log(f"Cache clear error: {e}")
    return count


def _cache_cleanup_stale():
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    if not os.path.exists(profile):
        return
    now = time.time()
    removed = 0
    short_prefixes = ("data_cache_", "epg_cache_profile_", "playback_duration_")
    long_prefixes = ("vod_info_",)
    short_max_age = 7 * 24 * 60 * 60
    long_max_age = 30 * 24 * 60 * 60
    try:
        for fname in os.listdir(profile):
            if not fname.endswith(".json"):
                continue
            path = os.path.join(profile, fname)
            try:
                age = now - os.path.getmtime(path)
            except OSError:
                continue
            if fname.startswith(short_prefixes) and age > short_max_age:
                os.remove(path)
                removed += 1
            elif fname.startswith(long_prefixes) and age > long_max_age:
                os.remove(path)
                removed += 1
    except Exception as e:
        _log(f"Cache cleanup error: {e}")
    if removed:
        _log(f"Cache cleanup removed {removed} stale file(s)")


def _get_cached_m3u_channels(m3u_url):
    if not m3u_url:
        return []
    cached = _cache_load("m3u")
    if isinstance(cached, dict) and cached.get("_url") == m3u_url and _cache_valid("m3u"):
        return cached.get("_data") or []
    data = IPTV.get_m3u_channels(m3u_url)
    _cache_save("m3u", {"_url": m3u_url, "_data": data})
    return data


def _get_cached_xtream_categories(url, user, pwd, stype):
    if not url or not user or not pwd:
        return []
    key = f"xtream_cats_{stype}"
    cached = _cache_load(key)
    source = f"{url}|{user}|{pwd}"
    if isinstance(cached, dict) and cached.get("_src") == source and _cache_valid(key):
        return cached.get("_data") or []
    data = IPTV.get_xtream_categories(url, user, pwd, stype)
    _cache_save(key, {"_src": source, "_data": data})
    return data


def _get_cached_xtream_streams(url, user, pwd, stype, category_id=None):
    if not url or not user or not pwd:
        return []
    key = f"xtream_streams_{stype}"
    cached = _cache_load(key)
    source = f"{url}|{user}|{pwd}"
    if isinstance(cached, dict) and cached.get("_src") == source and _cache_valid(key):
        data = cached.get("_data") or []
    else:
        data = IPTV.get_xtream_streams(url, user, pwd, stype, None)
        _cache_save(key, {"_src": source, "_data": data})
        # Sidecar: cache category counts so xtream_categories() never loads all streams just for counts
        counts = {}
        for s in data:
            cid = str(s.get("category_id", ""))
            counts[cid] = counts.get(cid, 0) + 1
        _cache_save(f"xtream_counts_{stype}", counts)
    if category_id:
        return [s for s in data if str(s.get("category_id", "")) == str(category_id)]
    return data


def _enabled_profiles_with_credentials():
    profiles = []
    for n in range(1, 11):
        if not _profile_has_configured_credentials(n):
            continue
        name = _profile_setting_direct(n, "name") or _t(30390, n)
        profiles.append((n, name))
    return profiles


def _provider_only_tmdb_enabled():
    return addon.getSetting("tmdb_provider_only_available").lower() == "true"


def _provider_availability_path():
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    return os.path.join(profile, "tmdb_provider_availability.json")


def _provider_title_key(value):
    value = str(value or "").lower()
    value = re.sub(r"\b(19\d{2}|20\d{2})\b", " ", value)
    value = re.sub(
        r"\b(4k|uhd|2160p|1080p|720p|480p|x264|x265|h264|h265|hevc|hdr|webdl|web-dl|webrip|bluray|brrip|hdrip|dvdrip|cam|ts)\b",
        " ",
        value,
    )
    value = re.sub(r"[^a-z0-9]+", " ", value)
    return " ".join(value.split())


def _provider_year(*values):
    for value in values:
        match = re.search(r"\b(19\d{2}|20\d{2})\b", str(value or ""))
        if match:
            return match.group(1)
    return ""


def _provider_availability_add(index, media_type, name, item=None):
    key = _provider_title_key(name)
    if not key:
        return
    item = item or {}
    bucket = index.setdefault(media_type, {"titles": {}, "tmdb_ids": []})
    titles = bucket.setdefault("titles", {})
    years = titles.setdefault(key, [])
    year = _provider_year(
        name,
        item.get("year"),
        item.get("release_date"),
        item.get("first_air_date"),
    )
    if year not in years:
        years.append(year)
    for id_key in ("tmdb_id", "tmdb", "tmdbid"):
        tmdb_id = str(item.get(id_key) or "").strip()
        if tmdb_id and tmdb_id not in bucket["tmdb_ids"]:
            bucket["tmdb_ids"].append(tmdb_id)


_PROVIDER_AVAILABILITY_MEM = {
    "path": "",
    "mtime": None,
    "data": None,
    "sets": {},
}


def _load_provider_availability_cache():
    path = _provider_availability_path()
    try:
        mtime = os.path.getmtime(path)
    except OSError:
        _PROVIDER_AVAILABILITY_MEM.update({"path": path, "mtime": None, "data": None, "sets": {}})
        return None
    if (
        _PROVIDER_AVAILABILITY_MEM.get("path") == path
        and _PROVIDER_AVAILABILITY_MEM.get("mtime") == mtime
        and _PROVIDER_AVAILABILITY_MEM.get("data") is not None
    ):
        return _PROVIDER_AVAILABILITY_MEM["data"]
    try:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        _PROVIDER_AVAILABILITY_MEM.update({"path": path, "mtime": mtime, "data": data, "sets": {}})
        return data
    except Exception:
        return None


def _save_provider_availability_cache(index):
    path = _provider_availability_path()
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(index, f, ensure_ascii=False)
        try:
            f.flush()
            _safe_fsync(f)
        except OSError:
            pass
    os.replace(tmp, path)
    try:
        _PROVIDER_AVAILABILITY_MEM.update({
            "path": path,
            "mtime": os.path.getmtime(path),
            "data": index,
            "sets": {},
        })
    except Exception:
        pass


def _clear_provider_availability_cache():
    try:
        path = _provider_availability_path()
        if os.path.exists(path):
            os.remove(path)
            _log("Provider availability cache deleted")
    except Exception as e:
        _log(f"Provider availability cache delete warning: {e}")
    _PROVIDER_AVAILABILITY_MEM.update({"path": "", "mtime": None, "data": None, "sets": {}})


def _provider_tmdb_id_set(cache, tmdb_type):
    sets = _PROVIDER_AVAILABILITY_MEM.setdefault("sets", {})
    key = (id(cache), tmdb_type)
    if key in sets:
        return sets[key]
    bucket = (cache.get(tmdb_type) or {})
    values = set(str(v) for v in (bucket.get("tmdb_ids") or []) if v)
    sets[key] = values
    return values


def _provider_availability_item_available(item, cache, strict_title_year=False):
    tmdb_type = "tv" if item.get("tmdb_type") == "tv" else "movie"
    bucket = cache.get(tmdb_type) or {}
    tmdb_id = str(item.get("tmdb_id") or "").strip()
    if tmdb_id and tmdb_id in _provider_tmdb_id_set(cache, tmdb_type):
        return True
    title = (
        item.get("showtitle")
        or item.get("tvshowtitle")
        or item.get("title")
        or item.get("name")
        or ""
    )
    key = _provider_title_key(title)
    if not key:
        return False
    titles = bucket.get("titles") or {}
    provider_years = titles.get(key)
    if not provider_years:
        return False
    provider_years = [str(y or "") for y in provider_years]
    item_year = _provider_year(
        item.get("year"),
        item.get("release_date"),
        item.get("first_air_date"),
        item.get("premiered"),
    )
    if not item_year:
        return not strict_title_year
    if strict_title_year:
        return item_year in provider_years
    return "" in provider_years or item_year in provider_years


def _filter_provider_available_items(items):
    if not _provider_only_tmdb_enabled():
        return items
    cache = _load_provider_availability_cache()
    if not cache:
        cache = _refresh_provider_availability_cache_after_profile_load(show_progress=False)
    if not cache:
        return []
    return [item for item in (items or []) if _provider_availability_item_available(item, cache)]


def _filter_provider_available_items_strict(items):
    if not _provider_only_tmdb_enabled():
        return items
    cache = _load_provider_availability_cache()
    if not cache:
        cache = _refresh_provider_availability_cache_after_profile_load(show_progress=False)
    if not cache:
        return []
    return [
        item
        for item in (items or [])
        if _provider_availability_item_available(item, cache, strict_title_year=True)
    ]


def _find_provider_series_id_for_show(tmdb_id, title):
    """Search enabled profiles for a series matching tmdb_id or title.
    Returns (url, user, pwd, series_id) or (None, None, None, None).
    """
    for pnum, pname in _enabled_profiles_with_credentials():
        creds = _get_credentials_for_profile(pnum)
        xt_url = creds.get("xtream_url", "")
        xt_user = creds.get("xtream_username", "")
        xt_pwd = creds.get("xtream_password", "")
        if not (xt_url and xt_user and xt_pwd):
            continue
        for item in _get_cached_xtream_streams(xt_url, xt_user, xt_pwd, "series"):
            if str(item.get("tmdb_id") or "").strip() == str(tmdb_id):
                return xt_url, xt_user, xt_pwd, item.get("series_id")
        # Fallback: title match
        key = _provider_title_key(title)
        if key and _provider_title_key(item.get("name", "")) == key:
            return xt_url, xt_user, xt_pwd, item.get("series_id")
    return None, None, None, None


def _xtream_series_seasons_available(url, user, pwd, series_id):
    """Return set of season numbers that have episodes from Xtream series info."""
    try:
        info = IPTV.get_xtream_series_info(url, user, pwd, series_id)
        seasons = set()
        for ep_list in info.get("episodes", {}).values():
            for item in ep_list:
                try:
                    seasons.add(int(item.get("season", "1")))
                except (ValueError, TypeError):
                    pass
        return seasons
    except Exception as e:
        _log(f"Xtream seasons lookup error: {e}")
        return set()


def _xtream_episodes_available(url, user, pwd, series_id, season_num):
    """Return set of episode numbers available for a specific season."""
    try:
        info = IPTV.get_xtream_series_info(url, user, pwd, series_id)
        episodes = set()
        for ep_list in info.get("episodes", {}).values():
            for item in ep_list:
                try:
                    if int(item.get("season", "1")) == season_num:
                        episodes.add(int(item.get("episode_num", item.get("num", ""))))
                except (ValueError, TypeError):
                    pass
        return episodes
    except Exception as e:
        _log(f"Xtream episodes lookup error: {e}")
        return set()


def _filter_provider_seasons(tmdb_id, showtitle, seasons):
    if not _provider_only_tmdb_enabled():
        return seasons
    url, user, pwd, series_id = _find_provider_series_id_for_show(tmdb_id, showtitle)
    if not series_id:
        return seasons
    provider_seasons = _xtream_series_seasons_available(url, user, pwd, series_id)
    if not provider_seasons:
        return seasons
    return [s for s in (seasons or []) if int(s.get("season", 0)) in provider_seasons]


def _filter_provider_episodes(tmdb_id, showtitle, season, episodes):
    if not _provider_only_tmdb_enabled():
        return episodes
    url, user, pwd, series_id = _find_provider_series_id_for_show(tmdb_id, showtitle)
    if not series_id:
        return episodes
    try:
        season_num = int(season)
    except (ValueError, TypeError):
        return episodes
    provider_eps = _xtream_episodes_available(url, user, pwd, series_id, season_num)
    if not provider_eps:
        return episodes
    return [ep for ep in (episodes or []) if int(ep.get("episode", 0)) in provider_eps]


def _pvr_m3u_path():
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    pvr_profile = _get_pvr_profile_num()
    return os.path.join(profile, f"pvr_live_{PVR_FILE_SLUG}_p{pvr_profile}.m3u8")


def _pvr_epg_path():
    return _pvr_epg_path_for_profile(_get_pvr_profile_num())


def _validate_pvr_epg_m3u_match(m3u_path, epg_path, log_prefix="PVR", notify=True):
    """Warn if M3U tvg-ids and EPG channel IDs have no overlap.

    Stream the XMLTV file until an overlap is found. Providers can put sports
    channel IDs far past the first chunk of large XMLTV files.
    """
    try:
        m3u_ids = set()
        with open(m3u_path, "r", encoding="utf-8") as f:
            for line in f:
                if line.startswith("#EXTINF"):
                    match = re.search(r'tvg-id="([^"]*)"', line)
                    if match and match.group(1):
                        m3u_ids.add(match.group(1))
        if not m3u_ids or not os.path.exists(epg_path):
            return False

        saw_epg_channel = False
        carry = ""
        with open(epg_path, "r", encoding="utf-8", errors="ignore") as f:
            while True:
                chunk = f.read(1024 * 1024)
                if not chunk:
                    break
                scan = carry + chunk
                carry = scan[-512:]
                for match in re.finditer(r'<channel[^>]*id="([^"]*)"', scan):
                    saw_epg_channel = True
                    if match.group(1) in m3u_ids:
                        return True

        if saw_epg_channel:
            _log(f"{log_prefix} WARNING: M3U tvg-ids and EPG channel IDs have zero overlap. EPG guide may be empty.")
            if notify:
                try:
                    xbmcgui.Dialog().notification("Xstream Pro", _t(30892), xbmcgui.NOTIFICATION_WARNING, 5000)
                except Exception:
                    pass
        return False
    except Exception as e:
        _log(f"{log_prefix} EPG/M3U validation warning: {e}")
        return False


def _export_pvr_m3u():
    _log("PVR: Starting M3U export")
    creds = _get_pvr_credentials()
    m3u_url = creds.get("m3u_url", "")
    xt_url = creds.get("xtream_url", "")
    xt_user = creds.get("xtream_username", "")
    xt_pwd = creds.get("xtream_password", "")
    if not m3u_url and not (xt_url and xt_user and xt_pwd):
        _log("PVR: No credentials configured for PVR profile")
        return False
    _log(f"PVR: m3u_url={bool(m3u_url)}, xt_url={bool(xt_url)}")

    channels = []
    total_streams = 0

    # Get PVR profile number for loading correct hidden categories
    pvr_profile = addon.getSetting("active_pvr_profile") or "Profile 1"
    pvr_pnum = (
        re.search(r"(\d+)$", pvr_profile).group(1)
        if re.search(r"(\d+)$", pvr_profile)
        else "1"
    )

    original_active = pm.active
    try:
        pm.active = pvr_pnum

        hidden_live = _get_hidden_subcats("live", pvr_pnum)
        hidden_live_items = _get_hidden_items("live", pvr_pnum)
        hide_adult = addon.getSetting("hide_adult_categories").lower() == "true"
        epg_programs = {}
        if _get_pvr_catchup_correction_mode() == "Provider metadata":
            try:
                epg = EPG(addon, profile_num=pvr_pnum)
                epg.load()
                epg_programs = epg.programs or {}
            except Exception as e:
                _log(f"PVR: Catchup correction EPG load warning: {e}")

        if m3u_url and not _m3u_has_credentials(m3u_url):
            for ch in _get_cached_m3u_channels(m3u_url):
                group = ch.get("group", "General")
                if group in hidden_live:
                    continue
                item_id = str(ch.get("tvg_id") or ch.get("url") or ch.get("name", ""))
                if item_id in hidden_live_items:
                    continue
                if hide_adult and (
                    _is_adult_category(group) or _is_adult_category(ch.get("name", ""))
                ):
                    continue
                channels.append(
                    {
                        "name": ch.get("name", "Unknown"),
                        "url": ch.get("url", ""),
                        "tvg_id": ch.get("tvg_id", ""),
                        "logo": ch.get("logo", ""),
                        "group": group,
                        "catchup": ch.get("catchup", ""),
                        "catchup_source": ch.get("catchup_source", ""),
                        "catchup_days": ch.get("catchup_days", ""),
                        "catchup_correction": _catchup_correction_for_channel(
                            epg_programs, ch.get("tvg_id", ""), ch.get("catchup_correction", "")
                        ),
                    }
                )
        streams = []
        if xt_url and xt_user and xt_pwd:
            # Build category ID → name lookup
            cats = _get_cached_xtream_categories(xt_url, xt_user, xt_pwd, "live")
            cat_map = {}
            for c in cats or []:
                cat_map[str(c.get("category_id", ""))] = c.get(
                    "category_name", "Live TV"
                )

            _log(
                f"PVR M3U export: {len(hidden_live)} hidden categories: {hidden_live}, {len(hidden_live_items)} hidden items"
            )
            streams = _get_cached_xtream_streams(xt_url, xt_user, xt_pwd, "live")
        total_streams = len(streams) if xt_url and xt_user and xt_pwd else 0
        skipped = 0
        if xt_url and xt_user and xt_pwd:
            for s in streams:
                cat_id = str(s.get("category_id", ""))
                if cat_id in hidden_live:
                    skipped += 1
                    continue
                if str(s.get("stream_id", "")) in hidden_live_items:
                    skipped += 1
                    continue
                group = cat_map.get(cat_id, "Live TV")
                if hide_adult and (
                    _is_adult_category(group) or _is_adult_category(s.get("name", ""))
                ):
                    skipped += 1
                    continue
                sid = str(s.get("stream_id", ""))
                epg_id = s.get("epg_channel_id") or sid
                url = IPTV.build_xtream_stream_url(xt_url, xt_user, xt_pwd, s, "live")
                # Treat string "0" as falsy for tv_archive/catchup flags
                tv_arch = str(s.get("tv_archive", "")).lower()
                catchup_flag = str(s.get("catchup", "")).lower()
                has_catchup = tv_arch in ("1", "yes", "true") or catchup_flag in (
                    "1",
                    "yes",
                    "true",
                    "default",
                )
                channels.append(
                    {
                        "name": s.get("name", "Unknown"),
                        "url": url,
                        "tvg_id": epg_id,
                        "logo": s.get("stream_icon", ""),
                        "group": group,
                        "stream_type": s.get("stream_type", ""),
                        "catchup": "default" if has_catchup else "",
                        "catchup_source": f"{xt_url}/live/{xt_user}/{xt_pwd}/{sid}.ts?utc={{utc}}&lutc=${{end}}"
                        if has_catchup
                        else "",
                        "catchup_days": str(s.get("tv_archive_duration", "") or "7")
                        if has_catchup
                        else "",
                        "catchup_correction": _catchup_correction_for_channel(
                            epg_programs, epg_id
                        ) if has_catchup else "",
                    }
                )

        _log(
            f"PVR M3U export: {total_streams} total streams, {skipped} skipped, {len(channels)} exported"
        )

        if not channels:
            _log("PVR M3U export: no channels after filtering, writing empty M3U")
            _atomic_write_text(_pvr_m3u_path(), "#EXTM3U\n")
            return True
        m3u_path = _pvr_m3u_path()
        m3u_data = build_m3u_content(channels)
        tmp = m3u_path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            f.write(m3u_data)
            try:
                f.flush()
                _safe_fsync(f)
            except OSError:
                pass
        os.replace(tmp, m3u_path)
        # Quick validation: every EXTINF must contain a comma
        bad_lines = [
            ln
            for ln in m3u_data.splitlines()
            if ln.startswith("#EXTINF:") and "," not in ln
        ]
        if bad_lines:
            _log(f"M3U validation failed on {len(bad_lines)} lines")
            return False
        _log(f"Exported PVR M3U with {len(channels)} channels")
        return True
    finally:
        pm.active = original_active


def _export_pvr_epg_for_profile(profile_num, force_fetch=False):
    epg_path = _pvr_epg_path_for_profile(profile_num)
    pvr_pnum = str(profile_num)
    try:
        epg = EPG(addon, profile_num=pvr_pnum)
        if force_fetch:
            epg.fetch()
        else:
            epg.load()
            if not epg.programs:
                epg.fetch()
        if epg.programs and epg.export_xmltv(epg_path):
            _log(f"Exported real EPG for PVR ({len(epg.programs)} channels)")
            return True
        _atomic_write_text(epg_path, '<?xml version="1.0" encoding="utf-8"?><tv></tv>\n')
        _log("Exported stub EPG for PVR")
        return False
    except Exception as e:
        _log(f"EPG export failed: {e}")
        return False


def _export_pvr_epg(force_fetch=False):
    return _export_pvr_epg_for_profile(_get_pvr_profile_num(), force_fetch=force_fetch)


def _trigger_pvr_epg_reload():
    try:
        xbmc.executeJSONRPC('{"jsonrpc":"2.0","method":"PVR.EPGLoad","id":1}')
        _log("PVR: EPG reload triggered")
    except Exception as e:
        _log(f"PVR: EPG reload warning: {e}")


def _write_pvr_instance_settings(role, instance_name, m3u_path, epg_path, log_prefix="PVR"):
    try:
        pvr_profile = _pvr_iptvsimple_profile_dir()
        os.makedirs(pvr_profile, exist_ok=True)
        settings_path = _pvr_instance_settings_path(_get_pvr_instance_id(role))
        try:
            pvr_epg_hours = int(addon.getSetting("pvr_epg_refresh") or "12")
        except ValueError:
            pvr_epg_hours = 12
        pvr_epg_seconds = pvr_epg_hours * 3600
        catchup_enabled = addon.getSetting("pvr_catchup_enabled").lower() == "true"
        updates = {
            "kodi_addon_instance_name": instance_name,
            "kodi_addon_instance_enabled": "true",
            "m3uPathType": "0",
            "m3uPath": m3u_path,
            "m3uUrl": "",
            "epgPathType": "0",
            "epgPath": epg_path,
            "epgUrl": "",
            "epgRefreshInterval": str(pvr_epg_seconds),
            "m3uRefreshMode": "1",
            "logoPathType": "0",
            "logoPath": "",
            "logoBaseUrl": "",
            "catchupEnabled": "true" if catchup_enabled else "false",
            "catchupPlayEpgAsLive": "false",
            "allChannelsCatchupMode": "0",
            "catchupOverrideMode": "0",
            "catchupDays": addon.getSetting("pvr_catchup_days") or "7",
            "catchupOnlyOnFinishedProgrammes": "false",
            "catchupWatchEpgBeginBufferMins": "0",
            "catchupWatchEpgEndBufferMins": "5",
        }
        if os.path.exists(settings_path):
            tree = ET.parse(settings_path)
            root = tree.getroot()
            changed = False
            stale_keys = {"defaultInputstream", "defaultMimeType"}
            for stale in stale_keys:
                el = root.find(f".//setting[@id='{stale}']")
                if el is not None:
                    root.remove(el)
                    changed = True
                    _log(f"{log_prefix}: Removed stale setting '{stale}'")
            for key, val in updates.items():
                el = root.find(f".//setting[@id='{key}']")
                if el is not None:
                    if (el.text or "") != val:
                        el.text = val
                        if "default" in el.attrib:
                            del el.attrib["default"]
                        changed = True
                else:
                    new_el = ET.SubElement(root, "setting", {"id": key})
                    new_el.text = val
                    changed = True
            if changed:
                tmp_path = settings_path + ".tmp"
                tree.write(tmp_path, encoding="utf-8", xml_declaration=True)
                with open(tmp_path, "r+b") as f:
                    try:
                        _safe_fsync(f)
                    except OSError:
                        pass
                os.replace(tmp_path, settings_path)
        else:
            root = ET.Element("settings", {"version": "2"})
            for key, val in updates.items():
                el = ET.SubElement(root, "setting", {"id": key})
                el.text = val
            tmp_path = settings_path + ".tmp"
            ET.ElementTree(root).write(tmp_path, encoding="utf-8", xml_declaration=True)
            with open(tmp_path, "r+b") as f:
                try:
                    _safe_fsync(f)
                except OSError:
                    pass
            os.replace(tmp_path, settings_path)
        _log(f"{log_prefix}: Configured IPTV Simple instance ({os.path.basename(settings_path)})")
        return True
    except Exception as e:
        _log(f"{log_prefix}: Configure IPTV Simple instance failed: {e}")
        _log(f"Traceback: {traceback.format_exc()}")
        return False


def _configure_pvr_iptvsimple():
    try:
        pvr_profile = xbmcvfs.translatePath(
            "special://profile/addon_data/pvr.iptvsimple"
        )
        os.makedirs(pvr_profile, exist_ok=True)
        settings_path = _pvr_instance_settings_path(_get_pvr_instance_id("main"))
        m3u_path = _pvr_m3u_path()
        epg_path = _pvr_epg_path()

        _log("PVR: Configuring IPTV Simple Client")
        catchup_enabled = addon.getSetting("pvr_catchup_enabled").lower() == "true"
        catchup_days = addon.getSetting("pvr_catchup_days") or "7"
        # PVR EPG refresh interval (convert hours to seconds for IPTV Simple)
        try:
            pvr_epg_hours = int(addon.getSetting("pvr_epg_refresh") or "12")
        except ValueError:
            pvr_epg_hours = 12
        pvr_epg_seconds = pvr_epg_hours * 3600
        _log(
            f"PVR: Configuring EPG refresh interval to {pvr_epg_hours} hours ({pvr_epg_seconds} seconds)"
        )
        updates = {
            "kodi_addon_instance_name": PVR_INSTANCE_NAME,
            "kodi_addon_instance_enabled": "true",
            "m3uPathType": "0",
            "m3uPath": m3u_path,
            "m3uUrl": "",
            "epgPathType": "0",
            "epgPath": epg_path,
            "epgUrl": "",
            "epgRefreshInterval": str(pvr_epg_seconds),
            "m3uRefreshMode": "1",
            "logoPathType": "0",
            "logoPath": "",
            "logoBaseUrl": "",
            "catchupEnabled": "true" if catchup_enabled else "false",
            "catchupPlayEpgAsLive": "false",
            "allChannelsCatchupMode": "0",
            "catchupOverrideMode": "0",
            "catchupDays": catchup_days,
            "catchupOnlyOnFinishedProgrammes": "false",
            "catchupWatchEpgBeginBufferMins": "0",
            "catchupWatchEpgEndBufferMins": "5",
        }
        if os.path.exists(settings_path):
            tree = ET.parse(settings_path)
            root = tree.getroot()
            changed = False
            stale_keys = {"defaultInputstream", "defaultMimeType"}
            for stale in stale_keys:
                el = root.find(f".//setting[@id='{stale}']")
                if el is not None:
                    root.remove(el)
                    changed = True
                    _log(f"PVR: Removed stale setting '{stale}'")
            for key, val in updates.items():
                el = root.find(f".//setting[@id='{key}']")
                if el is not None:
                    if (el.text or "") != val:
                        el.text = val
                        if "default" in el.attrib:
                            del el.attrib["default"]
                        changed = True
                else:
                    new_el = ET.SubElement(root, "setting", {"id": key})
                    new_el.text = val
                    changed = True
            if changed:
                tmp_path = settings_path + ".tmp"
                tree.write(tmp_path, encoding="utf-8", xml_declaration=True)
                with open(tmp_path, "r+b") as f:
                    try:
                        _safe_fsync(f)
                    except OSError:
                        pass
                os.replace(tmp_path, settings_path)
        else:
            root = ET.Element("settings", {"version": "2"})
            for key, val in updates.items():
                el = ET.SubElement(root, "setting", {"id": key})
                el.text = val
            tmp_path = settings_path + ".tmp"
            ET.ElementTree(root).write(tmp_path, encoding="utf-8", xml_declaration=True)
            with open(tmp_path, "r+b") as f:
                try:
                    _safe_fsync(f)
                except OSError:
                    pass
            os.replace(tmp_path, settings_path)
        _log("Configured PVR IPTV Simple Client")

        xbmcgui.Dialog().notification(
            "Xstream Pro", _t(30099), xbmcgui.NOTIFICATION_INFO, 5000
        )
        return True
    except Exception as e:
        _log(f"Configure PVR failed: {e}")
        _log(f"Traceback: {traceback.format_exc()}")
        return False


def is_pvr_iptvsimple_installed():
    try:
        addon = xbmcaddon.Addon("pvr.iptvsimple")
        _log(f"PVR: IPTV Simple found, version={addon.getAddonInfo('version')}")
        return True
    except Exception as e:
        _log(f"PVR: IPTV Simple not installed: {e}")
        return False


def _is_pvr_iptvsimple_installed_or_disabled():
    """Check if pvr.iptvsimple is installed (enabled or disabled) via JSONRPC.

    xbmcaddon.Addon() throws 'Unknown addon id' when the addon is disabled,
    so we use JSONRPC which returns addon details regardless of enabled state.
    """
    try:
        resp = json.loads(
            xbmc.executeJSONRPC(
                '{"jsonrpc":"2.0","method":"Addons.GetAddonDetails","params":{"addonid":"pvr.iptvsimple","properties":["enabled"]},"id":1}'
            )
        )
        if "error" in resp:
            _log(f"PVR: IPTV Simple not installed (JSONRPC error)")
            return False
        enabled = resp.get("result", {}).get("addon", {}).get("enabled", False)
        _log(f"PVR: IPTV Simple found via JSONRPC, enabled={enabled}")
        return True
    except Exception as e:
        _log(f"PVR: IPTV Simple check failed: {e}")
        return False


def prompt_install_pvr():
    dlg = xbmcgui.Dialog()
    choice = dlg.yesno(_t(30560), _t(30561), yeslabel=_t(30182), nolabel=_t(30161))
    if choice:
        xbmc.executebuiltin("InstallAddon(pvr.iptvsimple)")
        # Wait for installation and notify
        for _ in range(30):
            xbmc.sleep(_tvos_sleep(1000))
            if is_pvr_iptvsimple_installed():
                xbmcgui.Dialog().notification(
                    "Xstream Pro", _t(30100), xbmcgui.NOTIFICATION_INFO, 5000
                )
                return True
    return choice


def _sync_pvr():
    if addon.getSetting("auto_sync_pvr").lower() != "true":
        return False
    return _sync_pvr_force()


def _pvr_sync_lock_path():
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    return os.path.join(profile, ".pvr_sync_lock")


def _acquire_pvr_sync_lock():
    lock_path = _pvr_sync_lock_path()
    if os.path.exists(lock_path):
        try:
            age = time.time() - os.path.getmtime(lock_path)
            if age < 60:
                _log("PVR: Sync already in progress, skipping")
                xbmcgui.Dialog().notification(
                    "Xstream Pro", _t(30794), xbmcgui.NOTIFICATION_WARNING, 3000
                )
                return False
            else:
                _log("PVR: Stale sync lock found, removing")
                os.remove(lock_path)
        except Exception as e:
            _log(f"PVR: Lock check warning: {e}")
    try:
        with open(lock_path, "w", encoding="utf-8") as f:
            f.write(str(time.time()))
    except Exception as e:
        _log(f"PVR: Lock create warning: {e}")
    return True


def _release_pvr_sync_lock():
    try:
        lock_path = _pvr_sync_lock_path()
        if os.path.exists(lock_path):
            os.remove(lock_path)
    except Exception as e:
        _log(f"PVR: Lock release warning: {e}")


def _truthy_catchup(value):
    return str(value or "").strip().lower() in ("1", "yes", "true", "default")


def _get_pvr_catchup_correction_mode():
    mode = (
        _get_setting_direct("pvr_catchup_correction_mode", "Auto (device timezone)")
        or "Auto (device timezone)"
    )
    legacy_modes = {
        "Auto from EPG timezone": "Provider metadata",
        "Manual": "Manual replay timezone",
    }
    return legacy_modes.get(mode, mode)


def _parse_utc_timezone_label(value):
    value = str(value or "").strip().upper().replace(" ", "")
    if value in ("UTC", "UTC+0", "UTC-0", "GMT", "GMT+0", "GMT-0"):
        return 0.0
    m = re.match(r"^(?:UTC|GMT)([+-])(\d{1,2})(?::?(\d{2}))?$", value)
    if not m:
        return None
    sign = 1 if m.group(1) == "+" else -1
    hours = int(m.group(2))
    minutes = int(m.group(3) or "0")
    if hours > 14 or minutes >= 60:
        return None
    return sign * (hours + (minutes / 60.0))


def _device_utc_offset_hours():
    try:
        if time.daylight and time.localtime().tm_isdst > 0:
            offset_seconds = -time.altzone
        else:
            offset_seconds = -time.timezone
        return round(offset_seconds / 60.0) / 60.0
    except Exception:
        try:
            delta = datetime.datetime.now() - datetime.datetime.utcnow()
            return round(delta.total_seconds() / 60.0) / 60.0
        except Exception:
            return 0.0


def _get_manual_pvr_catchup_correction():
    value = _get_setting_direct("pvr_catchup_correction", "UTC") or "UTC"
    offset = _parse_utc_timezone_label(value)
    if offset is not None:
        return _format_catchup_correction(-offset)
    # Legacy raw correction value, for existing users with values like -2.
    try:
        return _format_catchup_correction(float(value))
    except ValueError:
        return "0"


def _xtream_timeshift_catchup_source(base_url, username, password, stream_id):
    base = (base_url or "").rstrip("/")
    return (
        f"{base}/live/{urllib.parse.quote(str(username))}/"
        f"{urllib.parse.quote(str(password))}/"
        f"{urllib.parse.quote(str(stream_id))}.ts?"
        "utc={utc}&lutc=${end}"
    )


def _xmltv_timezone_hours(ts):
    if not ts or " " not in ts:
        return None
    tz = ts.rsplit(" ", 1)[1].strip()
    if len(tz) != 5 or tz[0] not in "+-":
        return None
    try:
        sign = 1 if tz[0] == "+" else -1
        return sign * (int(tz[1:3]) + (int(tz[3:5]) / 60.0))
    except ValueError:
        return None


def _format_catchup_correction(value):
    if value is None:
        return ""
    if float(value).is_integer():
        return str(int(value))
    return str(value)


def _provider_catchup_correction_for_channel(epg_programs, epg_id):
    programs = epg_programs.get(epg_id) or []
    for prog in programs:
        offset = _xmltv_timezone_hours(prog.get("start", ""))
        if offset is not None:
            return _format_catchup_correction(-offset)
    return ""


def _catchup_correction_for_channel(epg_programs, epg_id, provider_correction=""):
    mode = _get_pvr_catchup_correction_mode()
    if mode == "Off":
        return ""
    if mode == "Manual replay timezone":
        return _get_manual_pvr_catchup_correction()
    if mode == "Provider metadata":
        return provider_correction or _provider_catchup_correction_for_channel(
            epg_programs, epg_id
        )
    # Auto mode uses the Kodi device timezone.
    return _format_catchup_correction(-_device_utc_offset_hours())


def _sync_pvr_force():
    """Sync PVR files and reload pvr.iptvsimple."""
    ok = False
    if not _acquire_pvr_sync_lock():
        return False
    progress = xbmcgui.DialogProgress()
    progress.create("Xstream Pro", _t(30868))
    try:
        _log("PVR: Starting sync")
        if not _is_pvr_iptvsimple_installed_or_disabled():
            _log("PVR: IPTV Simple not installed, prompting")
            prompt_install_pvr()
            return False

        # Stop PVR Manager and disable the addon to release SQLite handles
        # (Android requirement — see AGENTS.md §Android gotchas)
        progress.update(5, _t(30869))
        try:
            xbmc.executeJSONRPC(
                '{"jsonrpc": "2.0", "method": "PVR.Manager.Stop", "id": 1}'
            )
            _log("PVR: Manager stopped")
            xbmc.sleep(_tvos_sleep(2000))
        except Exception as e:
            _log(f"PVR: Manager stop warning: {e}")

        progress.update(15, _t(30870))
        try:
            xbmc.executeJSONRPC(
                json.dumps(
                    {
                        "jsonrpc": "2.0",
                        "id": 1,
                        "method": "Addons.SetAddonEnabled",
                        "params": {"addonid": "pvr.iptvsimple", "enabled": False},
                    }
                )
            )
            _log("PVR: pvr.iptvsimple disabled")
            xbmc.sleep(_tvos_sleep(1500))
            if sys.platform.startswith("win"):
                xbmc.sleep(_tvos_sleep(2000))
        except Exception as e:
            _log(f"PVR: disable warning: {e}")

        ok = False
        try:
            try:
                # Kodi owns Epg16.db. Do not delete or truncate it while Kodi is running;
                # pvr.iptvsimple will reload from the generated XMLTV file.
                progress.update(25, _t(30871))
                _log("PVR: skipping Kodi Epg16.db reset during sync")

                # Export M3U and EPG while addon is DISABLED
                progress.update(40, _t(30872))
                if not _export_pvr_m3u():
                    _log("PVR: M3U export failed, aborting sync")
                    xbmcgui.Dialog().notification("Xstream Pro", _t(30893))
                    return False

                # Only export EPG if file is missing, stale (>4 hours old), or empty
                progress.update(55, _t(30873))
                epg_path = _pvr_epg_path()
                epg_stale = (
                    not os.path.exists(epg_path)
                    or os.path.getsize(epg_path) == 0
                    or not epg_file_has_data(epg_path)
                    or (
                        os.path.exists(epg_path)
                        and time.time() - os.path.getmtime(epg_path) > 4 * 3600
                    )
                )
                if epg_stale:
                    _log("PVR: Existing EPG file is empty or stale, will re-export")
                    _export_pvr_epg(force_fetch=True)
                else:
                    _log("PVR: Reusing existing EPG file (fresh)")
                _validate_pvr_epg_m3u_match(_pvr_m3u_path(), epg_path)

                # Configure PVR (write settings while addon is still DISABLED)
                progress.update(70, _t(30874))
                if not _configure_pvr_iptvsimple():
                    _log("PVR: IPTV Simple configuration failed, aborting sync")
                    return False

                # Configure favorites instance (also before re-enable)
                if addon.getSetting("pvr_favorites_enabled").lower() == "true":
                    _export_pvr_favs_m3u()
                    if _m3u_file_has_channels(_pvr_favs_m3u_path()):
                        _configure_pvr_favs_instance()
                    else:
                        _log("PVR Favorites: no channels during PVR sync, removing favorites instance")
                        _remove_pvr_favs_instance_config()
                else:
                    _remove_pvr_favs_instance_config(force=True)

                # Configure optional Sports-only PVR instances. These reuse the
                # selected profile XMLTV file and stay separate from main PVR.
                try:
                    _sync_sports_pvr_during_pvr_sync()
                except Exception as e:
                    _log(f"Sports PVR: sync warning: {e}")

                # Default native PVR scope after a full sync:
                # Live TV should be loaded immediately. Sports PVR can be
                # configured, but it stays hidden until the user opens it.
                try:
                    if _role_has_instance("main"):
                        _set_owned_pvr_instance_enabled("main", True, "PVR default scope")
                    if _role_has_instance("favs"):
                        _set_owned_pvr_instance_enabled("favs", True, "PVR default scope")
                    if _role_has_instance("sports"):
                        _set_owned_pvr_instance_enabled("sports", False, "PVR default scope")
                    if _role_has_instance("sports_favs"):
                        _set_owned_pvr_instance_enabled("sports_favs", False, "PVR default scope")
                except Exception as e:
                    _log(f"PVR default scope warning: {e}")

                progress.update(100, _t(30877))
                ok = True
                _maybe_show_pvr_first_run()
                xbmcgui.Dialog().notification("Xstream Pro", _t(30878))
            finally:
                # Re-enable pvr.iptvsimple — runs even if export/configure failed
                progress.update(80, _t(30875))
                try:
                    xbmc.sleep(_tvos_sleep(2000))
                    xbmc.executeJSONRPC(
                        json.dumps(
                            {
                                "jsonrpc": "2.0",
                                "id": 1,
                                "method": "Addons.SetAddonEnabled",
                                "params": {"addonid": "pvr.iptvsimple", "enabled": True},
                            }
                        )
                    )
                    _log("PVR: pvr.iptvsimple re-enabled")
                    xbmc.sleep(_tvos_sleep(2000))
                except Exception as e:
                    _log(f"PVR: re-enable warning: {e}")

                # Start PVR Manager — config is already in place
                progress.update(90, _t(30876))
                try:
                    xbmc.sleep(_tvos_sleep(2000))
                    xbmc.executeJSONRPC(
                        '{"jsonrpc": "2.0", "method": "PVR.Manager.Start", "id": 1}'
                    )
                    _log("PVR: Manager started with fresh EPG")
                    _trigger_pvr_epg_reload()
                except Exception as e:
                    _log(f"PVR: Manager start warning: {e}")
        except Exception as e:
            _log(f"PVR sync error: {e}")
    finally:
        try:
            progress.close()
        except Exception:
            pass
        _release_pvr_sync_lock()
    return ok


def _hard_reset_pvr_epg_cache(stop_manager=True, start_manager=True, enable_addon=True):
    """Deprecated: Kodi's Epg16.db cannot be safely modified while Kodi is running."""
    _log("PVR: Epg16.db reset skipped; unsafe while Kodi is running")
    return False


def _remove_owned_pvr_instances():
    """Remove IPTV Simple Client instances created by XStream Player only."""
    pvr_profile = xbmcvfs.translatePath("special://profile/addon_data/pvr.iptvsimple")
    if not os.path.exists(pvr_profile):
        return
    removed = 0
    for fname in os.listdir(pvr_profile):
        if not re.match(r"instance-settings-\d+\.xml$", fname):
            continue
        path = os.path.join(pvr_profile, fname)
        role = _pvr_instance_role(path)
        if role in ("main", "favs", "sports", "sports_favs"):
            try:
                os.remove(path)
                removed += 1
                _log(f"PVR: Removed IPTV Simple instance {fname}")
            except Exception as e:
                _log(f"PVR: IPTV Simple instance remove warning ({fname}): {e}")
    if removed:
        for setting in PVR_INSTANCE_SETTINGS.values():
            addon.setSetting(setting, "")


def _unload_pvr(progress=None):
    """Clear PVR M3U/EPG files and reset EPG database."""
    own_progress = progress is None
    if own_progress:
        progress = xbmcgui.DialogProgress()
        progress.create("Xstream Pro", _t(30868))

    progress.update(5, _t(30869))
    try:
        xbmc.executeJSONRPC(
            '{"jsonrpc": "2.0", "method": "PVR.Manager.Stop", "id": 1}'
        )
        _log("PVR: Manager stopped (unload)")
        xbmc.sleep(_tvos_sleep(2000))
    except Exception as e:
        _log(f"PVR: Manager stop warning (unload): {e}")

    progress.update(15, _t(30870))
    try:
        xbmc.executeJSONRPC(
            json.dumps(
                {
                    "jsonrpc": "2.0",
                    "id": 1,
                    "method": "Addons.SetAddonEnabled",
                    "params": {"addonid": "pvr.iptvsimple", "enabled": False},
                }
            )
        )
        _log("PVR: pvr.iptvsimple disabled (unload)")
        xbmc.sleep(_tvos_sleep(2500))
        if sys.platform.startswith("win"):
            xbmc.sleep(_tvos_sleep(2000))
    except Exception as e:
        _log(f"PVR: disable warning (unload): {e}")

    progress.update(30, _t(31436))
    try:
        _remove_owned_pvr_instances()
    except Exception as e:
        _log(f"PVR: IPTV Simple settings cleanup warning (unload): {e}")

    progress.update(45, _t(31437))
    try:
        m3u_path = _pvr_m3u_path()
        if os.path.exists(m3u_path):
            os.remove(m3u_path)
            _log(f"PVR: Removed M3U {m3u_path}")
    except Exception as e:
        _log(f"PVR: M3U remove warning (unload): {e}")

    progress.update(60, _t(31438))
    try:
        epg_path = _pvr_epg_path()
        if os.path.exists(epg_path):
            os.remove(epg_path)
            _log(f"PVR: Removed EPG {epg_path}")
    except Exception as e:
        _log(f"PVR: EPG remove warning (unload): {e}")
    _log("PVR: skipped Kodi Epg16.db reset during unload")

    progress.update(75, _t(30875))
    try:
        xbmc.executeJSONRPC(
            json.dumps(
                {
                    "jsonrpc": "2.0",
                    "id": 1,
                    "method": "Addons.SetAddonEnabled",
                    "params": {"addonid": "pvr.iptvsimple", "enabled": True},
                }
            )
        )
        _log("PVR: pvr.iptvsimple re-enabled (unload)")
        xbmc.sleep(_tvos_sleep(2000))
    except Exception as e:
        _log(f"PVR: re-enable warning (unload): {e}")

    progress.update(90, _t(30876))
    try:
        xbmc.executeJSONRPC(
            '{"jsonrpc": "2.0", "method": "PVR.Manager.Start", "id": 1}'
        )
        _log("PVR: Manager started (unload)")
        xbmc.sleep(_tvos_sleep(1000))
    except Exception as e:
        _log(f"PVR: Manager start warning (unload): {e}")

    progress.update(100, _t(31439))
    xbmc.sleep(_tvos_sleep(500))
    if own_progress:
        progress.close()


def reset_pvr_epg_db_action():
    """Reset EPG database: stop PVR, delete Epg16.db, clean caches, leave pvr.iptvsimple disabled until next Kodi restart."""
    if not xbmcgui.Dialog().yesno(_t(31434), _t(31431)):
        _log("PVR: EPG DB reset cancelled by user")
        return

    progress = xbmcgui.DialogProgress()
    progress.create(_t(31434), _t(30868))

    try:
        progress.update(5, "Stopping playback...")
        xbmc.executebuiltin("PlayerControl(Stop)")
        xbmc.sleep(500)

        progress.update(10, _t(30869))
        try:
            xbmc.executeJSONRPC(
                '{"jsonrpc": "2.0", "method": "PVR.Manager.Stop", "id": 1}'
            )
            _log("PVR EPG reset: PVR Manager stopped")
            xbmc.sleep(_tvos_sleep(2000))
        except Exception as e:
            _log(f"PVR EPG reset: PVR Manager stop warning: {e}")

        progress.update(20, _t(30870))
        try:
            xbmc.executeJSONRPC(
                json.dumps(
                    {
                        "jsonrpc": "2.0",
                        "id": 1,
                        "method": "Addons.SetAddonEnabled",
                        "params": {"addonid": "pvr.iptvsimple", "enabled": False},
                    }
                )
            )
            _log("PVR EPG reset: pvr.iptvsimple disabled")
            xbmc.sleep(_tvos_sleep(3000))
            if sys.platform.startswith("win"):
                xbmc.sleep(_tvos_sleep(3000))
        except Exception as e:
            _log(f"PVR EPG reset: pvr.iptvsimple disable warning: {e}")

        progress.update(30, "Releasing database handles...")
        xbmc.sleep(_tvos_sleep(3000))
        if sys.platform.startswith("win") or xbmc.getCondVisibility("System.Platform.Android"):
            xbmc.sleep(_tvos_sleep(3000))

        progress.update(40, "Deleting EPG database...")
        db_base = xbmcvfs.translatePath("special://profile/Database/Epg16.db")
        db_deleted = False

        for attempt in range(1, 6):
            try:
                if os.path.exists(db_base):
                    os.remove(db_base)
                    _log(f"PVR EPG reset: delete attempt {attempt} executed")
            except OSError as e:
                _log(f"PVR EPG reset: Epg16.db delete attempt {attempt} failed: {e}")

            db_deleted = not os.path.exists(db_base)

            if db_deleted:
                _log(f"PVR EPG reset: Epg16.db confirmed deleted on attempt {attempt}")
                break

            _log(f"PVR EPG reset: Epg16.db still exists after attempt {attempt}")

            if attempt < 5:
                xbmc.sleep(_tvos_sleep(2000))

        if not db_deleted:
            progress.close()
            progress = None
            _log("PVR EPG reset: could not delete Epg16.db after 5 attempts, aborting")

            profile_path = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
            os.makedirs(profile_path, exist_ok=True)
            retry_flag = os.path.join(profile_path, "pvr_sync_retry_needed.flag")

            try:
                with open(retry_flag, "w", encoding="utf-8") as f:
                    f.write("1")
                _log("PVR EPG reset: set retry flag after failed deletion")
            except Exception as e:
                _log(f"PVR EPG reset: retry flag write warning: {e}")

            xbmcgui.Dialog().ok(_t(31434), _t(31435).format(db_base))
            return

        for suffix in ("-wal", "-shm", "-journal"):
            sibling = db_base + suffix
            if os.path.exists(sibling):
                try:
                    os.remove(sibling)
                    _log(f"PVR EPG reset: deleted Epg16.db{suffix}")
                except Exception as e:
                    _log(f"PVR EPG reset: could not delete Epg16.db{suffix}: {e}")

        progress.update(50, "Cleaning EPG caches...")
        profile_path = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
        os.makedirs(profile_path, exist_ok=True)

        for fname in os.listdir(profile_path):
            if fname == "epg_cache.json" or fname.startswith("epg_cache_profile_"):
                fpath = os.path.join(profile_path, fname)
                try:
                    os.remove(fpath)
                    _log(f"PVR EPG reset: deleted addon cache {fname}")
                except Exception as e:
                    _log(f"PVR EPG reset: cache delete warning ({fname}): {e}")

        progress.update(60, "Cleaning PVR data files...")

        for fname in os.listdir(profile_path):
            is_pvr_file = (
                (fname.startswith(f"pvr_live_{PVR_FILE_SLUG}_p") and fname.endswith(".m3u8"))
                or (fname.startswith(f"pvr_epg_{PVR_FILE_SLUG}_p") and fname.endswith(".xml"))
                or (fname.startswith(f"pvr_favorites_{PVR_FILE_SLUG}_p") and fname.endswith(".m3u8"))
                or (fname.startswith(f"pvr_sports_{PVR_FILE_SLUG}_p") and fname.endswith(".m3u8"))
                or (fname.startswith(f"pvr_sports_favorites_{PVR_FILE_SLUG}_p") and fname.endswith(".m3u8"))
            )

            if is_pvr_file:
                fpath = os.path.join(profile_path, fname)
                try:
                    os.remove(fpath)
                    _log(f"PVR EPG reset: deleted PVR file {fname}")
                except Exception as e:
                    _log(f"PVR EPG reset: PVR file delete warning ({fname}): {e}")

        progress.update(70, "Regenerating PVR files...")

        try:
            m3u_ok = _export_pvr_m3u()
            _log(f"PVR EPG reset: regenerated M3U result={m3u_ok}")
        except Exception as e:
            _log(f"PVR EPG reset: M3U regeneration failed: {e}")

        try:
            epg_ok = _export_pvr_epg(force_fetch=True)
            _log(f"PVR EPG reset: regenerated XMLTV result={epg_ok}")
        except Exception as e:
            _log(f"PVR EPG reset: XMLTV regeneration failed: {e}")

        m3u_path = _pvr_m3u_path()
        epg_path = _pvr_epg_path()

        if not os.path.exists(m3u_path):
            _log("PVR EPG reset: M3U missing after regeneration, writing empty fallback")
            _atomic_write_text(m3u_path, "#EXTM3U\n")

        if not os.path.exists(epg_path):
            _log("PVR EPG reset: XMLTV missing after regeneration, writing empty fallback")
            _atomic_write_text(epg_path, '<?xml version="1.0" encoding="utf-8"?><tv></tv>\n')

        progress.update(85, "Preparing restart...")

        retry_flag = os.path.join(profile_path, "pvr_sync_retry_needed.flag")

        try:
            with open(retry_flag, "w", encoding="utf-8") as f:
                f.write("1")
            _log("PVR EPG reset: set pvr_sync_retry_needed flag")
        except Exception as e:
            _log(f"PVR EPG reset: retry flag write warning: {e}")

        progress.update(100, _t(30868))

        xbmc.sleep(500)
        progress.close()
        progress = None

        _log("PVR EPG reset: complete, pvr.iptvsimple left disabled until next Kodi restart")

        xbmcgui.Dialog().ok(_t(31434), _t(31433))

    except Exception as e:
        _log(f"PVR EPG reset error: {e}")
        xbmcgui.Dialog().notification("Xstream Pro", f"EPG reset error: {e}")
    finally:
        try:
            if progress:
                progress.close()
        except Exception:
            pass


def _maybe_show_pvr_first_run():
    flag = os.path.join(
        xbmcvfs.translatePath(addon.getAddonInfo("profile")), "pvr_first_run_shown"
    )
    if os.path.exists(flag):
        return
    try:
        with open(flag, "w", encoding="utf-8") as f:
            f.write("1")
        xbmcgui.Dialog().ok("Xstream Pro", _t(30057) + "\n" + _t(30058))
    except Exception as e:
        _log(f"PVR first run flag write error: {e}")


def _build_fetch_steps(xt_url, xt_user, xt_pwd, m3u_url):
    steps = []
    load_live = False
    if m3u_url and not _m3u_has_credentials(m3u_url):
        steps.append((_t(30350), lambda: _get_cached_m3u_channels(m3u_url)))
    if xt_url and xt_user and xt_pwd:
        load_live = pm.get_profile_setting("load_live") != "false"
        load_movies = pm.get_profile_setting("load_movies") != "false"
        load_series = pm.get_profile_setting("load_series") != "false"
        if load_live:
            steps.extend([
                (_t(30351), lambda: _get_cached_xtream_categories(xt_url, xt_user, xt_pwd, "live")),
                (_t(30352), lambda: _get_cached_xtream_streams(xt_url, xt_user, xt_pwd, "live")),
            ])
        if load_movies:
            steps.extend([
                (_t(30353), lambda: _get_cached_xtream_categories(xt_url, xt_user, xt_pwd, "movie")),
                (_t(30354), lambda: _get_cached_xtream_streams(xt_url, xt_user, xt_pwd, "movie")),
            ])
        if load_series:
            steps.extend([
                (_t(30355), lambda: _get_cached_xtream_categories(xt_url, xt_user, xt_pwd, "series")),
                (_t(30356), lambda: _get_cached_xtream_streams(xt_url, xt_user, xt_pwd, "series")),
            ])
    return steps, load_live


def _prefetch_all_data():
    creds = _get_credentials()
    xt_url = creds.get("xtream_url", "")
    xt_user = creds.get("xtream_username", "")
    xt_pwd = creds.get("xtream_password", "")
    m3u_url = creds.get("m3u_url", "")

    pd = xbmcgui.DialogProgress()
    pd.create("Xstream Pro", _t(30284))

    steps, load_live = _build_fetch_steps(xt_url, xt_user, xt_pwd, m3u_url)
    if load_live:
        steps.append((_t(30357), lambda: EPG(addon).fetch()))

    total = len(steps)
    for idx, (label, fn) in enumerate(steps):
        percent = int((idx / total) * 100) if total else 0
        pd.update(percent, _t(30359, label))
        try:
            fn()
        except Exception as e:
            _log(f"Prefetch error {label}: {e}")

    pd.close()

    if load_live and addon.getSetting("auto_sync_pvr").lower() == "true":
        try:
            _sync_pvr_force()
        except Exception as e:
            _log(f"Prefetch PVR sync error: {e}")

    xbmcgui.Dialog().notification("Xstream Pro", _t(30080))


def _epg_enabled():
    return addon.getSetting("show_epg_live").lower() == "true"


def _set_live_props(li):
    li.setContentLookup(False)


def play_stream(
    play_url,
    name,
    title="",
    plot="",
    icon="",
    stype="live",
    series_id="",
    season_num="",
    ep_id="",
    profile_num=None,
    kodi_props=None,
    stream_ua="",
    stream_ref="",
    resume_pos=0,
    resume_total=0,
    tmdb_id="",
):
    pnum = profile_num if profile_num is not None else pm.active
    li = xbmcgui.ListItem(path=play_url)
    li.setProperty("IsPlayable", "true")
    if resume_pos:
        li.setProperty("ResumeTime", str(resume_pos))
        li.setProperty("TotalTime", str(resume_total or 0))
    info_tag = li.getVideoInfoTag()
    info_tag.setMediaType("video")
    info_tag.setTitle(title or name)
    info_tag.setPlot(plot or "")
    if icon:
        li.setArt({"icon": icon, "thumb": icon})
    _prepare_playback_item(li)
    # Per-channel properties from #KODIPROP / #EXTVLCOPT
    if kodi_props:
        for k, v in kodi_props.items():
            li.setProperty(k, v)
    if stream_ua:
        li.setProperty("http-header", f"User-Agent={urllib.parse.quote(stream_ua)}")
    if stream_ref:
        li.setProperty("http-header", f"Referer={urllib.parse.quote(stream_ref)}")
    if stype == "live":
        _set_live_props(li)
    xbmcplugin.setResolvedUrl(addon_handle, True, listitem=li)
    upnext_data = None
    # Prepare next episode data for autoplay and/or Up Next
    autoplay_next = addon.getSetting("series_autoplay_next").lower() == "true"
    upnext_enabled = addon.getSetting("series_upnext_enabled").lower() == "true"
    if (
        stype == "series"
        and series_id
        and season_num
        and ep_id
        and (autoplay_next or upnext_enabled)
    ):
        try:
            creds = _get_credentials_for_profile(pnum)
            xt_url = creds.get("xtream_url", "")
            xt_user = creds.get("xtream_username", "")
            xt_pwd = creds.get("xtream_password", "")
            info = IPTV.get_xtream_series_info(xt_url, xt_user, xt_pwd, series_id)
            eps = info.get("episodes", {}).get(season_num, [])
            for i, ep in enumerate(eps):
                if str(ep.get("id", "")) == str(ep_id):
                    current_ep_num = ep.get("episode_num", "")
                    if i + 1 < len(eps):
                        next_ep = eps[i + 1]
                        next_ep_id = str(next_ep.get("id", ""))
                        next_title = next_ep.get("title") or _t(
                            30542, next_ep.get("episode_num", "?")
                        )
                        next_icon = next_ep.get("info", {}).get("movie_image") or icon
                        next_play_url = IPTV.build_xtream_stream_url(
                            xt_url, xt_user, xt_pwd, next_ep, "series"
                        )
                        next_li = xbmcgui.ListItem(path=next_play_url)
                        next_li.setProperty("IsPlayable", "true")
                        next_info = next_li.getVideoInfoTag()
                        next_info.setMediaType("episode")
                        next_info.setTitle(next_title)
                        next_info.setPlot(next_ep.get("info", {}).get("plot", ""))
                        if next_icon:
                            next_li.setArt({"icon": next_icon, "thumb": next_icon})
                        _prepare_playback_item(next_li)
                        next_url = build_url(
                            {
                                "mode": "play_stream",
                                "url": next_play_url,
                                "name": next_title,
                                "icon": next_icon,
                                "stype": "series",
                                "series_id": series_id,
                                "season_num": season_num,
                                "ep_id": next_ep_id,
                                "profile_num": pnum,
                            }
                        )
                        if autoplay_next:
                            playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
                            playlist.add(next_url, next_li)
                            _log(f"Queued next episode: {next_title}")
                        if upnext_enabled:
                            upnext_data = {
                                "current_episode": {
                                    "episodeid": ep_id,
                                    "tvshowid": series_id,
                                    "title": title or name,
                                    "art": {"thumb": icon},
                                    "season": season_num,
                                    "episode": current_ep_num,
                                    "showtitle": title or name,
                                    "plot": plot or "",
                                    "playcount": 0,
                                },
                                "next_episode": {
                                    "episodeid": next_ep_id,
                                    "tvshowid": series_id,
                                    "title": next_title,
                                    "art": {"thumb": next_icon},
                                    "season": season_num,
                                    "episode": next_ep.get("episode_num", ""),
                                    "showtitle": next_title,
                                    "plot": next_ep.get("info", {}).get("plot", ""),
                                    "playcount": 0,
                                },
                                "play_url": next_url,
                            }
                    break
        except Exception as e:
            _log(f"Next episode queue error: {e}")
    # Store series metadata in history for proper playback from Recently Watched
    extra = None
    if stype == "series" and series_id and season_num and ep_id:
        extra = {"series_id": series_id, "season_num": season_num, "ep_id": ep_id}
    # Extract stream_id for playback duration caching
    stream_id = ""
    if stype == "movie":
        m = re.search(r"/(\d+)\.[a-zA-Z0-9]+$", play_url)
        if m:
            stream_id = m.group(1)
    elif stype == "series" and ep_id:
        stream_id = str(ep_id)

    # Monitor playback for resume saving and error recovery
    _monitor_playback(name, play_url, stype, profile_num=pnum, upnext_data=upnext_data, stream_id=stream_id, icon=icon, extra=extra, tmdb_id=tmdb_id, title=title, plot=plot)


_PLAYBACK_MAX_RETRIES = 5
_PLAYBACK_RETRY_DELAY = 3000


def _monitor_playback(name, url, stype="live", profile_num=None, upnext_data=None, stream_id="", icon="", extra=None, tmdb_id="", title="", plot=""):
    """Background thread to save resume position, detect stream failures, and notify Up Next."""
    pnum = profile_num if profile_num is not None else pm.active

    def _worker():
        player = xbmc.Player()
        xbmc.sleep(_tvos_sleep(3000))
        if not player.isPlaying():
            if stype in ("movie", "series"):
                _log(f"Playback failed to start for: {name}, retrying up to {_PLAYBACK_MAX_RETRIES} times")
                for attempt in range(1, _PLAYBACK_MAX_RETRIES + 1):
                    xbmcgui.Dialog().notification(
                        "Xstream Pro",
                        _t(31446).format(attempt, _PLAYBACK_MAX_RETRIES),
                        xbmcgui.NOTIFICATION_INFO,
                        2000,
                    )
                    li = xbmcgui.ListItem(path=url)
                    li.setProperty("IsPlayable", "true")
                    _prepare_playback_item(li)
                    player.play(url, li)
                    xbmc.sleep(_tvos_sleep(_PLAYBACK_RETRY_DELAY))
                    if player.isPlaying():
                        break
                else:
                    _log(f"All {_PLAYBACK_MAX_RETRIES} playback attempts failed for: {name}")
                    xbmcgui.Dialog().notification(
                        "Xstream Pro",
                        _t(31447),
                        xbmcgui.NOTIFICATION_ERROR,
                        3000,
                    )
            elif stype == "live":
                _log(f"Live stream failed to start: {name}")
                xbmcgui.Dialog().notification(
                    "Xstream Pro",
                    _t(31447),
                    xbmcgui.NOTIFICATION_ERROR,
                    3000,
                )
            return
        if stype in ("movie", "series"):
            wh_extra = dict(extra) if extra else {}
            if tmdb_id:
                wh_extra["tmdb_id"] = str(tmdb_id)
            if title:
                wh_extra["title"] = str(title)
            if plot:
                wh_extra["plot"] = str(plot)
            WatchHistory(addon, profile_num=pnum).add(name, url, icon, stype, wh_extra)
        upnext_sent = False
        cached_dur = 0
        upnext_dur_warned = False
        while player.isPlaying():
            try:
                pos = player.getTime()
                dur = player.getTotalTime()
                if stype in ("movie", "series") and dur > 0:
                    resume_extra = {"stype": stype, "icon": icon}
                    if extra:
                        resume_extra.update(extra)
                    if title:
                        resume_extra["title"] = str(title)
                    if plot:
                        resume_extra["plot"] = str(plot)
                    ResumePoints(addon, profile_num=pnum).save_position(name, url, pos, dur, resume_extra)
                if dur > 0:
                    cached_dur = dur
                    if stream_id:
                        _save_playback_duration(stream_id, dur)
                if (
                    upnext_data
                    and addon.getSetting("series_upnext_enabled").lower() == "true"
                    and xbmc.getCondVisibility("System.HasAddon(service.upnext)")
                    and not upnext_sent
                ):
                    if cached_dur > 0 and (cached_dur - pos) <= 60:
                        try:
                            import base64

                            encoded = base64.b64encode(
                                json.dumps(upnext_data).encode("utf-8")
                            ).decode("ascii")
                            rpc_payload = {
                                "jsonrpc": "2.0",
                                "method": "JSONRPC.NotifyAll",
                                "params": {
                                    "sender": "plugin.video.xstream-pro.SIGNAL",
                                    "message": "upnext_data",
                                    "data": [encoded],
                                },
                                "id": 1,
                            }
                            xbmc.executeJSONRPC(json.dumps(rpc_payload))
                            upnext_sent = True
                            _log("Sent Up Next notification")
                        except Exception as e:
                            _log(f"Up Next notification error: {e}")
                    elif cached_dur == 0 and pos > 120 and not upnext_dur_warned:
                        _log("Up Next: stream duration unavailable, skipping upnext notification")
                        upnext_dur_warned = True
            except Exception as e:
                _log(f"Playback monitoring error: {e}")
            xbmc.sleep(_tvos_sleep(5000))

    t = threading.Thread(target=_worker)
    t.daemon = True
    t.start()


def _prepare_playback_item(li):
    if addon.getSetting("use_inputstream_adaptive").lower() == "true":
        li.setProperty("inputstream", "inputstream.adaptive")
        li.setProperty("inputstream.adaptive.manifest_type", "hls")
    custom_ua = addon.getSetting("custom_user_agent")
    if custom_ua:
        li.setProperty("http-header", f"User-Agent={urllib.parse.quote(custom_ua)}")


def _pvr_fav_ctx(stream_id, name, icon=""):
    """Return context menu item for adding/removing PVR favorites."""
    sid = str(stream_id)
    if _pvr_favs_is_fav(sid):
        return (
            _t(30217),
            f"RunPlugin({build_url({'mode': 'pvr_fav_remove', 'stream_id': sid})})",
        )
    else:
        return (
            _t(30218),
            f"RunPlugin({build_url({'mode': 'pvr_fav_add', 'stream_id': sid, 'name': name, 'icon': icon})})",
        )


def _is_adult_category(name):
    # Adult keywords with word boundaries to reduce false positives
    adult_patterns = [
        r"\bxxx\b",
        r"\badult\b",
        r"\bmature\b",
        r"\bporn\b",
        r"\berotic\b",
        r"\berotica\b",
        r"\bnsfw\b",
        r"\bx-rated\b",
        r"\bxrated\b",
        r"\bplayboy\b",
        r"\bhustler\b",
        r"\bpenthouse\b",
        r"\bbrazzers\b",
        r"\bnaughty\b",
        r"\bsexy\b",
        r"\bsex\b",
        r"\bhentai\b",
        r"\bstriptease\b",
        r"\bfetish\b",
        r"\bnude\b",
        r"\bnaked\b",
        r"\borgasm\b",
        r"\bkamasutra\b",
        r"\bmilf\b",
        r"\blesbian\b",
        r"\bgay\b",
        r"\bhardcore\b",
        r"\bsoftcore\b",
        r"\btopless\b",
        r"\buncensored\b",
        r"\buncut\b",
        r"\bvoyeur\b",
        r"\bswinger\b",
        r"\bredlight\b",
        r"\bred light\b",
        r"\bblue film\b",
        r"\bhot club\b",
        r"\bnight show\b",
        r"\bfor adults\b",
        r"\b18\+",
        r"\(\s*18\+\s*\)",
        r"\[\s*18\+\s*\]",
    ]
    lower = name.lower()
    return any(re.search(p, lower) for p in adult_patterns)


def _hash_pin(pin):
    """Hash PIN with SHA-256 and addon-id salt for secure storage."""

    salt = addon.getAddonInfo("id") or "plugin.video.xstream-pro"
    return hashlib.sha256((pin + salt).encode("utf-8")).hexdigest()


_PIN_HASH_RE = re.compile(r"^[a-fA-F0-9]{64}$")


def _normalize_parental_pin():
    pin = addon.getSetting("parental_pin") or "0000"
    if not _PIN_HASH_RE.match(pin):
        hashed = _hash_pin(pin)
        addon.setSetting("parental_pin", hashed)
        return hashed
    return pin


def _check_pin(required_for=""):
    if addon.getSetting("enable_parental_control").lower() != "true":
        return True
    # Check per-area toggles
    area_map = {
        "Settings": "parental_lock_settings",
        "Tools": "parental_lock_tools",
    }
    setting_key = area_map.get(required_for)
    if setting_key and addon.getSetting(setting_key).lower() != "true":
        return True
    stored_pin = _normalize_parental_pin()
    kb = xbmcgui.Dialog().input(
        _t(30032).format(required_for),
        type=xbmcgui.INPUT_ALPHANUM,
        option=xbmcgui.ALPHANUM_HIDE_INPUT,
    )
    if _hash_pin(kb) != stored_pin:
        xbmcgui.Dialog().notification("Xstream Pro", _t(30061))
        return False
    return True


def _pvr_has_data():
    """Check if PVR already has channels loaded via JSON-RPC."""
    try:
        resp = xbmc.executeJSONRPC(
            json.dumps(
                {
                    "jsonrpc": "2.0",
                    "method": "PVR.GetChannels",
                    "params": {
                        "channelgroupid": "alltv",
                        "properties": ["channelnumber"],
                    },
                    "id": 1,
                }
            )
        )
        result = json.loads(resp).get("result", {})
        channels = result.get("channels", [])
        has_channels = len(channels) > 0
        _log(f"PVR data check: {len(channels)} channels loaded")
        return has_channels
    except Exception as e:
        _log(f"PVR data check error: {e}")
        return False


def _profiles_refresh_stamp_path():
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    return os.path.join(profile, "profiles_auto_refresh.txt")


def _get_profiles_last_refresh():
    try:
        with open(_profiles_refresh_stamp_path(), "r", encoding="utf-8") as f:
            return float(f.read().strip())
    except Exception:
        return 0


def _set_profiles_last_refresh():
    path = _profiles_refresh_stamp_path()
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        f.write(str(time.time()))
        try:
            f.flush()
            _safe_fsync(f)
        except OSError:
            pass
    os.replace(tmp, path)


def _profiles_auto_refresh_due():
    if addon.getSetting("auto_refresh_enabled").lower() != "true":
        return False
    interval = addon.getSetting("auto_refresh_interval") or "24"
    if interval.lower() == "never":
        return False
    try:
        hours = float(interval)
    except ValueError:
        hours = 24
    return (time.time() - _get_profiles_last_refresh()) > (hours * 3600)


def _check_profiles_auto_refresh():
    if _is_tvos():
        return
    if not _profiles_auto_refresh_due():
        return
    profiles = _enabled_profiles_with_credentials()
    if not profiles:
        _set_profiles_last_refresh()
        return
    if xbmcgui.Dialog().yesno(
        "Update profiles content?",
        "Your enabled provider profiles are due for a playlist refresh.\n\nUpdate all enabled profiles now?"
    ):
        _refresh_all_profiles_with_progress(profiles)
    _set_profiles_last_refresh()


def _check_credentials_refresh_prompt():
    return False


def _check_pvr_startup_retry():
    """Offer PVR sync at startup if user previously declined it.

    The deferred restore flag (pvr_sync_after_profile_load.flag) is NOT handled
    here — it is processed by _prompt_sync_pvr_if_needed() after the PVR-linked
    profile is actually loaded. This prevents a premature "Sync PVR?" dialog
    during the addon reload that follows restore.
    """
    profile_path = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    retry_flag = os.path.join(profile_path, "pvr_sync_retry_needed.flag")
    defer_flag = os.path.join(profile_path, "pvr_sync_after_profile_load.flag")

    # Deferred restore sync: do NOT show dialog here. The prompt will appear
    # after the PVR-linked profile is loaded via _prompt_sync_pvr_if_needed().
    if os.path.exists(defer_flag):
        _log("PVR sync deferred - will prompt after PVR-linked profile is loaded")
        return

    if not os.path.exists(retry_flag):
        return

    # Check if PVR addon is disabled — if so, the M3U/XMLTV files we regenerated
    # during EPG reset won't be loaded until pvr.iptvsimple is re-enabled via
    # _sync_pvr_force(). Do NOT clear the retry flag in this case.
    try:
        resp = json.loads(
            xbmc.executeJSONRPC(
                '{"jsonrpc":"2.0","method":"Addons.GetAddonDetails","params":{"addonid":"pvr.iptvsimple","properties":["enabled"]},"id":1}'
            )
        )
        pvr_enabled = resp.get("result", {}).get("addon", {}).get("enabled", False)
    except Exception:
        pvr_enabled = False

    if pvr_enabled:
        # pvr.iptvsimple is enabled — check if PVR data already exists
        try:
            pvr_path = _pvr_m3u_path()
            if os.path.exists(pvr_path) and os.path.getsize(pvr_path) > 0:
                os.remove(retry_flag)
                _log("PVR retry flag cleared — PVR already has data and addon is enabled")
                return
        except Exception as e:
            _log(f"PVR retry check warning: {e}")
    else:
        _log("PVR: pvr.iptvsimple is disabled — retry sync needed to re-enable it")

    if not _is_pvr_iptvsimple_installed_or_disabled():
        return

    try:
        creds = _get_pvr_credentials()
        if not (creds.get("xtream_url") or creds.get("m3u_url")):
            return

        dialog = xbmcgui.Dialog()
        if dialog.yesno("Xstream Pro", _t(30866)):
            try:
                _sync_pvr_force()
                os.remove(retry_flag)
                _log("PVR sync completed via startup retry")
            except Exception as e:
                _log(f"PVR startup retry sync warning: {e}")
        else:
            # User declined again — keep retry flag for next startup
            _log("PVR startup retry declined by user")
    except Exception as e:
        _log(f"PVR startup retry warning: {e}")


def _is_first_refresh():
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    return not os.path.exists(os.path.join(profile, "first_refresh_done"))


def _mark_first_refresh_done():
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    with open(os.path.join(profile, "first_refresh_done"), "w", encoding="utf-8") as f:
        f.write("1")


def refresh_profile_menu():
    """Show menu to select which profile(s) to refresh with progress dialog."""
    try:
        dialog = xbmcgui.Dialog()

        # Build list of enabled profiles
        profiles = []
        for i in range(1, 11):
            if addon.getSetting(f"profile_{i}_enabled") == "true":
                name = addon.getSetting(f"profile_{i}_name") or _t(30390, i)
                profiles.append((i, name))

        if not profiles:
            dialog.notification(_t(30770), _t(30074))
            xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
            return

        # Build menu items
        items = [(_t(30736), "all")]  # Refresh All Enabled Profiles
        for num, name in profiles:
            label = (
                _t(31400, num, name)
                if name != _t(30390, num)
                else _t(30390, num)
            )
            items.append((label, num))

        idx = dialog.select(_t(30012), [i[0] for i in items])
        if idx < 0:
            xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
            return

        selected = items[idx][1]

        if selected == "all":
            # Refresh all enabled profiles with progress
            _refresh_all_profiles_with_progress(profiles)
        else:
            # Refresh single profile with progress
            _refresh_profile_data(selected)

        xbmc.executebuiltin("Container.Refresh")
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
    except Exception as e:
        _log(f"refresh_profile_menu error: {e}")
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)


def _refresh_all_profiles_with_progress(profiles):
    """Refresh all profiles with overall progress dialog."""
    if not _acquire_pvr_sync_lock():
        return
    total_profiles = len(profiles)
    if total_profiles == 0:
        _release_pvr_sync_lock()
        return

    progress = xbmcgui.DialogProgress()
    progress.create(_t(30012), _t(30736))  # Refreshing All Profiles

    try:
        for idx, (num, name) in enumerate(profiles):
            if progress.iscanceled():
                break

            # Calculate overall progress
            overall_pct = int((idx / total_profiles) * 100)
            progress.update(
                overall_pct, f"{_t(30749, name)}  {_t(30758, idx + 1, total_profiles)}"
            )

            # Refresh this profile (without showing its own progress dialog)
            _refresh_single_profile_silent(num)

        progress.update(100, _t(30080))
        xbmc.sleep(_tvos_sleep(500))
        progress.close()
        if _provider_only_tmdb_enabled():
            _refresh_provider_availability_cache_after_profile_load(show_progress=True)
        xbmcgui.Dialog().notification("Xstream Pro", _t(30080))

    except Exception as e:
        progress.close()
        _log(f"Refresh all error: {e}")
        xbmcgui.Dialog().notification(
            "Xstream Pro", _t(30074), xbmcgui.NOTIFICATION_ERROR
        )
    finally:
        _release_pvr_sync_lock()


def _refresh_single_profile_silent(profile_num):
    """Refresh data for a specific profile without progress dialog (for batch operations)."""
    original_active = pm.active
    pm.active = str(profile_num)

    try:
        _purge_profile_data(profile_num)
        creds = pm.get_credentials()
        xt_url = creds.get("xtream_url", "")
        xt_user = creds.get("xtream_username", "")
        xt_pwd = creds.get("xtream_password", "")
        m3u_url = creds.get("m3u_url", "")
        steps, load_live = _build_fetch_steps(xt_url, xt_user, xt_pwd, m3u_url)
        if load_live:
            steps.append((_t(30357), lambda: EPG(addon).fetch()))
        for label, fn in steps:
            try:
                fn()
            except Exception as e:
                _log(f"Silent refresh error {label}: {e}")
        _log(f"Silently refreshed profile {profile_num}")
    finally:
        pm.active = original_active


def _refresh_profile_data(profile_num):
    """Refresh data for a specific profile with progress bar."""
    profile_name = addon.getSetting(f"profile_{profile_num}_name") or _t(
        30390, profile_num
    )
    progress = xbmcgui.DialogProgress()
    progress.create(_t(30012), _t(30749, profile_name))
    original_active = pm.active
    pm.active = str(profile_num)

    try:
        progress.update(10, _t(30750))
        _purge_profile_data(profile_num)

        creds = pm.get_credentials()
        xt_url = creds.get("xtream_url", "")
        xt_user = creds.get("xtream_username", "")
        xt_pwd = creds.get("xtream_password", "")
        m3u_url = creds.get("m3u_url", "")
        steps, load_live = _build_fetch_steps(xt_url, xt_user, xt_pwd, m3u_url)
        if load_live:
            steps.append((_t(30357), lambda: EPG(addon).fetch()))

        total = len(steps)
        for idx, (label, fn) in enumerate(steps):
            if progress.iscanceled():
                break
            percent = 25 + int(((idx + 1) / total) * 70) if total else 50
            progress.update(percent, label)
            try:
                fn()
            except Exception as e:
                _log(f"Refresh error {label}: {e}")

        if _provider_only_tmdb_enabled():
            _refresh_provider_availability_cache_after_profile_load(show_progress=False)

        progress.update(100, _t(30080))
        xbmc.sleep(_tvos_sleep(500))
        xbmcgui.Dialog().notification("Xstream Pro", _t(30080))
    except Exception as e:
        _log(f"Refresh profile data error: {e}")

        _log(f"Traceback: {traceback.format_exc()}")
        xbmcgui.Dialog().notification(
            "Xstream Pro", _t(30081), xbmcgui.NOTIFICATION_ERROR
        )
    finally:
        try:
            progress.close()
        except Exception as e:
            _log(f"PVR favorites progress close warning: {e}")
        pm.active = original_active


def _refresh_profiles_batch(profile_nums):
    """Load multiple profiles under a single progress dialog.

    Used after restore to avoid showing multiple 'Refresh' dialogs.
    """
    if not profile_nums:
        return []

    total_profiles = len(profile_nums)
    progress = xbmcgui.DialogProgress()
    progress.create("Xstream Pro", _t(31083, total_profiles))
    loaded = []

    try:
        for p_idx, profile_num in enumerate(profile_nums):
            profile_name = addon.getSetting(f"profile_{profile_num}_name") or _t(
                30390, profile_num
            )
            overall_pct = int((p_idx / total_profiles) * 100)
            progress.update(
                overall_pct,
                _t(31084, p_idx + 1, total_profiles, profile_name),
            )

            try:
                original_active = pm.active
                pm.active = str(profile_num)
                try:
                    _purge_profile_data(profile_num)
                    creds = pm.get_credentials()
                    xt_url = creds.get("xtream_url", "")
                    xt_user = creds.get("xtream_username", "")
                    xt_pwd = creds.get("xtream_password", "")
                    m3u_url = creds.get("m3u_url", "")
                    steps, load_live = _build_fetch_steps(
                        xt_url, xt_user, xt_pwd, m3u_url
                    )
                    if load_live:
                        steps.append((_t(30357), lambda: EPG(addon).fetch()))

                    for label, fn in steps:
                        if progress.iscanceled():
                            break
                        try:
                            fn()
                        except Exception as e:
                            _log(f"Batch load error {label}: {e}")

                    loaded.append(profile_num)
                    _log(f"Batch loaded profile {profile_num}")
                finally:
                    pm.active = original_active
            except Exception as e:
                _log(f"Batch profile {profile_num} error: {e}")

            if progress.iscanceled():
                break

        progress.update(100, _t(31085))
        xbmc.sleep(_tvos_sleep(300))
    finally:
        try:
            progress.close()
        except Exception:
            pass

    if loaded and _provider_only_tmdb_enabled():
        _refresh_provider_availability_cache_after_profile_load(show_progress=True)

    return loaded


def refresh_data():
    choice = xbmcgui.Dialog().yesno(
        "Xstream Pro",
        _t(30050) + "\n\n" + _t(30051),
        yeslabel=_t(30167),
        nolabel=_t(30161),
    )
    if not choice:
        return False

    if not _acquire_pvr_sync_lock():
        return False
    try:
        first_time = _is_first_refresh()
        pd = xbmcgui.DialogProgress()
        pd.create("Xstream Pro", _t(30286))
        try:
            pd.update(10, _t(31440))
            count = _cache_clear_profile(int(pm.active))
            _log(f"Cleared {count} profile cache files")
            rt = RefreshTracker(addon)
            rt.set_last_refresh()
            pd.update(25, _t(31441))
            _prefetch_all_data()
            if _provider_only_tmdb_enabled():
                _refresh_provider_availability_cache_after_profile_load(show_progress=False)
            pd.update(100, _t(30563))
        finally:
            pd.close()
        if first_time:
            _mark_first_refresh_done()
        choice = xbmcgui.Dialog().yesno(
            "Xstream Pro", _t(30052), yeslabel=_t(30165), nolabel=_t(30164)
        )
        if choice:
            _restart_or_prompt()
            return
        xbmc.executebuiltin("Container.Refresh")
    finally:
        _release_pvr_sync_lock()


def sync_pvr():
    if not _is_pvr_iptvsimple_installed_or_disabled():
        prompt_install_pvr()
        return
    ok = False
    try:
        ok = _sync_pvr_force()
    except Exception as e:
        _log(f"PVR sync error: {e}")
    if ok:
        xbmcgui.Dialog().notification("Xstream Pro", _t(30081))
    else:
        xbmcgui.Dialog().notification("Xstream Pro", _t(30082))


def sync_sports_pvr():
    addon.setSetting("sports_pvr_enabled", "true")
    ok = _sync_sports_pvr_safe()
    xbmcgui.Dialog().notification(
        "Xstream Pro",
        "Sports PVR reloaded" if ok else "Sports PVR reload failed",
    )


def switch_sports_pvr_profile():
    current = addon.getSetting("sports_pvr_profile") or "Active PVR Profile"
    options = [("Active PVR Profile", "Active PVR Profile")]
    for i in range(1, 11):
        if addon.getSetting(f"profile_{i}_enabled") != "true":
            continue
        has_xtream = bool(addon.getSetting(f"profile_{i}_xtream_url"))
        has_m3u = bool(addon.getSetting(f"profile_{i}_m3u"))
        if not has_xtream and not has_m3u:
            continue
        name = addon.getSetting(f"profile_{i}_name") or _t(30390, i)
        label = _t(31400, i, name) if name != _t(30390, i) else _t(30390, i)
        options.append((f"Profile {i}", label))
    labels = [label + (" [COLOR green]*[/COLOR]" if value == current else "") for value, label in options]
    idx = xbmcgui.Dialog().select("Sports PVR Profile", labels)
    if idx < 0:
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
        return
    selected_value = options[idx][0]
    if selected_value == current:
        xbmcgui.Dialog().notification("Xstream Pro", _t(30121))
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
        return
    addon.setSetting("sports_pvr_profile", selected_value)
    addon.setSetting("sports_pvr_enabled", "true")
    if xbmcgui.Dialog().yesno("Sports PVR", "Reload Sports PVR now?"):
        sync_sports_pvr()
    xbmcplugin.endOfDirectory(addon_handle, succeeded=False)


def open_pvr():
    xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
    pvr_pnum = _get_pvr_profile_num()
    if not _prompt_profile_credentials(pvr_pnum):
        return
    if not _switch_pvr_instance_scope("main", "Live TV PVR"):
        return
    xbmc.executebuiltin("ActivateWindow(TVChannels,pvr://channels/tv/All%20channels@-1/)")


def open_pvr_guide():
    xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
    pvr_pnum = _get_pvr_profile_num()
    if not _prompt_profile_credentials(pvr_pnum):
        return
    if not _switch_pvr_instance_scope("main", "Live TV PVR Guide"):
        return
    xbmc.executebuiltin("ActivateWindow(TVGuide,pvr://channels/tv/All%20channels@-1/)")


def _get_pvr_profile_num():
    """Get the profile number (1-10) of the active PVR profile."""
    raw = addon.getSetting("active_pvr_profile") or "Profile 1"
    match = re.search(r"(\d+)$", raw)
    return match.group(1) if match else "1"


def _get_sports_pvr_profile_num():
    """Get the profile number (1-10) used by the Sports PVR instance."""
    raw = addon.getSetting("sports_pvr_profile") or "Active PVR Profile"
    if raw == "Active PVR Profile":
        return _get_pvr_profile_num()
    match = re.search(r"(\d+)$", raw)
    return match.group(1) if match else _get_pvr_profile_num()


def _pvr_numbered_label(label, profile_num=None):
    profile_num = profile_num or _get_pvr_profile_num()
    return "{0} ({1})".format(label, profile_num)


def _sports_pvr_numbered_label(label, profile_num=None):
    profile_num = profile_num or _get_sports_pvr_profile_num()
    return "{0} ({1})".format(label, profile_num)


def _pvr_epg_path_for_profile(profile_num):
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    return os.path.join(profile, f"pvr_epg_{PVR_FILE_SLUG}_p{profile_num}.xml")


def _pvr_favs_path():
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    pvr_profile = _get_pvr_profile_num()
    return os.path.join(profile, f"pvr_favorites_{pvr_profile}.json")


def _sports_pvr_m3u_path():
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    sports_profile = _get_sports_pvr_profile_num()
    return os.path.join(profile, f"pvr_sports_{PVR_FILE_SLUG}_p{sports_profile}.m3u8")


def _sports_pvr_epg_path():
    return _pvr_epg_path_for_profile(_get_sports_pvr_profile_num())


def _sports_pvr_favs_path():
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    sports_profile = _get_sports_pvr_profile_num()
    return os.path.join(profile, f"pvr_sports_favorites_{sports_profile}.json")


def _sports_pvr_favs_m3u_path():
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    sports_profile = _get_sports_pvr_profile_num()
    return os.path.join(profile, f"pvr_sports_favorites_{PVR_FILE_SLUG}_p{sports_profile}.m3u8")


def _pvr_favs_load_all():
    """Load all PVR favorite groups. Returns dict of {group_name: [channels]}."""
    try:
        with open(_pvr_favs_path(), "r", encoding="utf-8") as f:
            data = json.load(f)
    except (OSError, json.JSONDecodeError):
        return {}
    # Migration: old format was a flat list
    if isinstance(data, list):
        if data:
            return {"Favorites": data}
        return {}
    return data


def _pvr_favs_save_all(groups):
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    os.makedirs(profile, exist_ok=True)
    favs_path = _pvr_favs_path()
    tmp_file = favs_path + ".tmp"
    with open(tmp_file, "w", encoding="utf-8") as f:
        json.dump(groups, f, ensure_ascii=False)
        try:
            f.flush()
            _safe_fsync(f)
        except OSError:
            pass
    os.replace(tmp_file, favs_path)


def _pvr_favs_load(group=None):
    """Load channels from a specific group, or all channels flat."""
    groups = _pvr_favs_load_all()
    if group:
        return groups.get(group, [])
    # All channels flat
    all_items = []
    for items in groups.values():
        all_items.extend(items)
    return all_items


def _pvr_favs_save(items, group=None):
    """Save channels to a specific group."""
    if group is None:
        group = _t(30703) or "Favorites"
    groups = _pvr_favs_load_all()
    groups[group] = items
    _pvr_favs_save_all(groups)


def _pvr_favs_add(channel, group=None):
    if group is None:
        group = _t(30703) or "Favorites"
    items = _pvr_favs_load(group)
    sid = str(channel.get("stream_id", "") or channel.get("id", ""))
    if any(str(i.get("stream_id", "")) == sid for i in items):
        return False
    items.append(channel)
    _pvr_favs_save(items, group)
    return True


def _pvr_favs_remove(stream_id, group=None):
    if group is None:
        group = _t(30703) or "Favorites"
    items = _pvr_favs_load(group)
    sid = str(stream_id)
    new_items = [i for i in items if str(i.get("stream_id", "")) != sid]
    if len(new_items) != len(items):
        _pvr_favs_save(new_items, group)
        return True
    return False


def _pvr_favs_is_fav(stream_id):
    sid = str(stream_id)
    return any(str(i.get("stream_id", "")) == sid for i in _pvr_favs_load())


def _pvr_favs_m3u_path():
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    pvr_profile = _get_pvr_profile_num()
    return os.path.join(profile, f"pvr_favorites_{PVR_FILE_SLUG}_p{pvr_profile}.m3u8")


def _export_pvr_favs_m3u():
    """Export all PVR favorite groups to M3U for the second PVR instance."""
    groups = _pvr_favs_load_all()
    creds = _get_pvr_credentials()
    xt_url = creds.get("xtream_url", "")
    xt_user = creds.get("xtream_username", "")
    xt_pwd = creds.get("xtream_password", "")
    hide_adult = addon.getSetting("hide_adult_categories").lower() == "true"
    pvr_pnum = _get_pvr_profile_num()
    epg_programs = {}
    if _get_pvr_catchup_correction_mode() == "Provider metadata":
        try:
            epg = EPG(addon, profile_num=pvr_pnum)
            epg.load()
            epg_programs = epg.programs or {}
        except Exception as e:
            _log(f"PVR Favorites: Catchup correction EPG load warning: {e}")
    channels = []
    for gname, items in groups.items():
        for ch in items:
            name = ch.get("name", "Unknown")
            if hide_adult and _is_adult_category(name):
                continue
            icon = ch.get("stream_icon", "") or ch.get("icon", "")
            sid = str(ch.get("stream_id", ""))
            epg_id = ch.get("epg_channel_id") or sid
            catchup = ""
            catchup_source = ""
            catchup_days = ""
            url = ""
            if xt_url and xt_user and xt_pwd and sid:
                url = f"{xt_url}/live/{xt_user}/{xt_pwd}/{sid}.ts"
                # Only apply catchup if the original channel data indicated support
                has_catchup = (
                    _truthy_catchup(ch.get("tv_archive"))
                    or _truthy_catchup(ch.get("catchup"))
                    or ch.get("catchup_source")
                    or ch.get("catchup_days")
                )
                if has_catchup:
                    catchup = "default"
                    catchup_source = f"{xt_url}/live/{xt_user}/{xt_pwd}/{sid}.ts?utc={{utc}}&lutc=${{end}}"
                    catchup_days = str(ch.get("tv_archive_duration") or ch.get("catchup_days") or "7")
            elif ch.get("url"):
                url = ch.get("url").split("|")[0]  # strip any existing pipe params
                catchup = ch.get("catchup", "")
                catchup_source = ch.get("catchup_source", "")
                catchup_days = ch.get("catchup_days", "")
            else:
                continue
            channels.append(
                {
                    "name": name,
                    "url": url,
                    "tvg_id": epg_id,
                    "logo": icon,
                    "group": f"★ Favorites - {gname}",
                    "stream_type": ch.get("stream_type", ""),
                    "radio": ch.get("radio", ""),
                    "catchup": catchup,
                    "catchup_source": catchup_source,
                    "catchup_days": catchup_days,
                    "catchup_correction": _catchup_correction_for_channel(
                        epg_programs, epg_id, ch.get("catchup_correction", "")
                    ) if catchup else "",
                }
            )
    m3u_path = _pvr_favs_m3u_path()
    if not channels:
        tmp = m3u_path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            f.write("#EXTM3U\n")
            try:
                f.flush()
                _safe_fsync(f)
            except OSError:
                pass
        os.replace(tmp, m3u_path)
        return m3u_path
    m3u_data = build_m3u_content(channels)
    tmp = m3u_path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        f.write(m3u_data)
        try:
            f.flush()
            _safe_fsync(f)
        except OSError:
            pass
    os.replace(tmp, m3u_path)
    return m3u_path


def _sports_pvr_group_mode():
    mode = addon.getSetting("sports_pvr_group_mode") or "Countries / Regions"
    legacy = {
        "By Country": "Countries / Regions",
        "By Sport": "Countries / Regions",
        "By Provider Group": "Provider Groups",
    }
    mapped = legacy.get(mode, mode)
    if mapped != mode:
        try:
            addon.setSetting("sports_pvr_group_mode", mapped)
        except Exception:
            pass
    return mapped


def _sports_pvr_source_channels():
    if not _setup_sports_routes():
        return []
    pnum = _get_sports_pvr_profile_num()
    group_mode = _sports_pvr_group_mode()
    try:
        return _sports_routes.sports_pvr_channels_for_profile(pnum, group_mode) or []
    except Exception as e:
        _log(f"Sports PVR: source channel build failed: {e}")
        return []


def _apply_pvr_catchup_corrections(channels, profile_num, log_prefix):
    if not channels:
        return channels
    epg_programs = {}
    if _get_pvr_catchup_correction_mode() == "Provider metadata":
        try:
            epg = EPG(addon, profile_num=profile_num)
            epg.load()
            epg_programs = epg.programs or {}
        except Exception as e:
            _log(f"{log_prefix}: Catchup correction EPG load warning: {e}")
    for ch in channels:
        if ch.get("catchup"):
            ch["catchup_correction"] = _catchup_correction_for_channel(
                epg_programs, ch.get("tvg_id", ""), ch.get("catchup_correction", "")
            )
    return channels


def _export_sports_pvr_m3u():
    pnum = _get_sports_pvr_profile_num()
    channels = _sports_pvr_source_channels()
    channels = _apply_pvr_catchup_corrections(channels, pnum, "Sports PVR")
    m3u_path = _sports_pvr_m3u_path()
    if not channels:
        _log("Sports PVR: no sports channels, writing empty M3U")
        _atomic_write_text(m3u_path, "#EXTM3U\n")
        return False
    m3u_data = build_m3u_content(channels)
    tmp = m3u_path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        f.write(m3u_data)
        try:
            f.flush()
            _safe_fsync(f)
        except OSError:
            pass
    os.replace(tmp, m3u_path)
    _log(f"Sports PVR: Exported M3U with {len(channels)} channels")
    return True


def _sports_pvr_favs_load_all():
    try:
        with open(_sports_pvr_favs_path(), "r", encoding="utf-8") as f:
            data = json.load(f)
    except (OSError, json.JSONDecodeError):
        return {}
    if isinstance(data, list):
        return {"Favorites": data} if data else {}
    return data if isinstance(data, dict) else {}


def _sports_pvr_favs_save_all(groups):
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    os.makedirs(profile, exist_ok=True)
    favs_path = _sports_pvr_favs_path()
    tmp_file = favs_path + ".tmp"
    with open(tmp_file, "w", encoding="utf-8") as f:
        json.dump(groups, f, ensure_ascii=False)
        try:
            f.flush()
            _safe_fsync(f)
        except OSError:
            pass
    os.replace(tmp_file, favs_path)


def _sports_pvr_favs_load(group=None):
    groups = _sports_pvr_favs_load_all()
    if group:
        return groups.get(group, [])
    out = []
    for items in groups.values():
        out.extend(items)
    return out


def _sports_pvr_favs_save(items, group=None):
    group = group or "Favorites"
    groups = _sports_pvr_favs_load_all()
    groups[group] = items
    _sports_pvr_favs_save_all(groups)


def _export_sports_pvr_favs_m3u():
    groups = _sports_pvr_favs_load_all()
    pnum = _get_sports_pvr_profile_num()
    channels = []
    for gname, items in groups.items():
        for ch in items:
            item = dict(ch)
            item["group"] = f"★ Sports Favorites - {gname}"
            channels.append(item)
    channels = _apply_pvr_catchup_corrections(channels, pnum, "Sports PVR Favorites")
    m3u_path = _sports_pvr_favs_m3u_path()
    if not channels:
        _atomic_write_text(m3u_path, "#EXTM3U\n")
        return m3u_path
    m3u_data = build_m3u_content(channels)
    tmp = m3u_path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        f.write(m3u_data)
        try:
            f.flush()
            _safe_fsync(f)
        except OSError:
            pass
    os.replace(tmp, m3u_path)
    _log(f"Sports PVR Favorites: Exported M3U with {len(channels)} channels")
    return m3u_path


def _remove_pvr_instance_config(role, log_prefix, force=False, m3u_path=None):
    if not force and m3u_path and _m3u_file_has_channels(m3u_path):
        _log(f"{log_prefix}: keeping instance because M3U has channels")
        return False
    settings_path = _pvr_instance_settings_path(_get_pvr_instance_id(role))
    try:
        if os.path.exists(settings_path):
            os.remove(settings_path)
            _log(f"{log_prefix}: Removed empty instance {os.path.basename(settings_path)}")
    except Exception as e:
        _log(f"{log_prefix}: instance remove warning: {e}")
        return False
    return True


def _remove_duplicate_pvr_instances(role, keep_id, log_prefix):
    profile = _pvr_iptvsimple_profile_dir()
    keep_name = f"instance-settings-{keep_id}.xml"
    try:
        if not os.path.exists(profile):
            return
        for fname in os.listdir(profile):
            if not fname.startswith("instance-settings-") or not fname.endswith(".xml"):
                continue
            if fname == keep_name:
                continue
            path = os.path.join(profile, fname)
            if _pvr_instance_role(path) == role:
                try:
                    os.remove(path)
                    _log(f"{log_prefix}: Removed duplicate owned instance {fname}")
                except Exception as e:
                    _log(f"{log_prefix}: duplicate instance remove warning ({fname}): {e}")
    except Exception as e:
        _log(f"{log_prefix}: duplicate instance scan warning: {e}")


def _configure_sports_pvr_instance():
    m3u_path = _sports_pvr_m3u_path()
    if not _m3u_file_has_channels(m3u_path):
        _log("Sports PVR: no channels, removing Sports PVR instance")
        _remove_pvr_instance_config("sports", "Sports PVR", force=True)
        return False
    return _write_pvr_instance_settings(
        "sports",
        PVR_SPORTS_INSTANCE_NAME,
        m3u_path,
        _sports_pvr_epg_path(),
        "Sports PVR",
    )


def _configure_sports_pvr_favs_instance():
    m3u_path = _sports_pvr_favs_m3u_path()
    if not _m3u_file_has_channels(m3u_path):
        _log("Sports PVR Favorites: no channels, removing instance")
        _remove_pvr_instance_config("sports_favs", "Sports PVR Favorites", force=True)
        return False
    return _write_pvr_instance_settings(
        "sports_favs",
        PVR_SPORTS_FAVS_INSTANCE_NAME,
        m3u_path,
        _sports_pvr_epg_path(),
        "Sports PVR Favorites",
    )


def _pvr_progress(progress, percent, message):
    if progress is None:
        return
    try:
        progress.update(percent, message)
    except Exception:
        pass


def _sync_sports_pvr_during_pvr_sync(progress=None):
    if addon.getSetting("sports_pvr_enabled").lower() != "true":
        _remove_pvr_instance_config("sports", "Sports PVR", force=True)
        _remove_pvr_instance_config("sports_favs", "Sports PVR Favorites", force=True)
        return True
    pnum = _get_sports_pvr_profile_num()
    _pvr_progress(progress, 35, "Exporting Sports playlist")
    if not _export_sports_pvr_m3u():
        _remove_pvr_instance_config("sports", "Sports PVR", force=True)
        return False
    epg_path = _sports_pvr_epg_path()
    _pvr_progress(progress, 50, "Checking Sports EPG")
    epg_stale = (
        not os.path.exists(epg_path)
        or os.path.getsize(epg_path) == 0
        or not epg_file_has_data(epg_path)
        or time.time() - os.path.getmtime(epg_path) > 4 * 3600
    )
    if epg_stale:
        _log("Sports PVR: Existing EPG file is empty or stale, will re-export")
        _pvr_progress(progress, 60, "Refreshing Sports EPG")
        _export_pvr_epg_for_profile(pnum, force_fetch=True)
    _pvr_progress(progress, 70, "Validating Sports EPG links")
    _validate_pvr_epg_m3u_match(_sports_pvr_m3u_path(), epg_path, "Sports PVR", notify=False)
    _pvr_progress(progress, 78, "Configuring Sports PVR instance")
    _configure_sports_pvr_instance()
    _pvr_progress(progress, 84, "Cleaning duplicate Sports PVR instances")
    _remove_duplicate_pvr_instances("sports", _get_pvr_instance_id("sports"), "Sports PVR")
    _pvr_progress(progress, 88, "Exporting Sports favorites")
    _export_sports_pvr_favs_m3u()
    if _m3u_file_has_channels(_sports_pvr_favs_m3u_path()):
        _configure_sports_pvr_favs_instance()
        _remove_duplicate_pvr_instances(
            "sports_favs",
            _get_pvr_instance_id("sports_favs"),
            "Sports PVR Favorites",
        )
    else:
        _remove_pvr_instance_config("sports_favs", "Sports PVR Favorites", force=True)
    return True


def _sync_sports_pvr_safe():
    """Sync only Sports PVR instances using the same safe disable/re-enable pattern."""
    if not _is_pvr_iptvsimple_installed_or_disabled():
        prompt_install_pvr()
        return False
    progress = xbmcgui.DialogProgress()
    progress.create("Xstream Pro", "Preparing Sports PVR reload")
    ok = False
    try:
        _pvr_progress(progress, 10, "Stopping PVR Manager")
        try:
            xbmc.executeJSONRPC('{"jsonrpc": "2.0", "method": "PVR.Manager.Stop", "id": 1}')
            xbmc.sleep(_tvos_sleep(1000))
        except Exception as e:
            _log(f"Sports PVR safe: Manager stop warning: {e}")

        _pvr_progress(progress, 20, "Disabling IPTV Simple")
        try:
            xbmc.executeJSONRPC(json.dumps({
                "jsonrpc": "2.0",
                "id": 1,
                "method": "Addons.SetAddonEnabled",
                "params": {"addonid": "pvr.iptvsimple", "enabled": False},
            }))
            xbmc.sleep(_tvos_sleep(1500))
            if sys.platform.startswith("win"):
                xbmc.sleep(_tvos_sleep(2000))
        except Exception as e:
            _log(f"Sports PVR safe: disable warning: {e}")

        ok = _sync_sports_pvr_during_pvr_sync(progress)
        if ok:
            _pvr_progress(progress, 90, "Selecting Sports PVR groups")
            if _role_has_instance("main"):
                _set_owned_pvr_instance_enabled("main", False, "Sports PVR safe")
            if _role_has_instance("favs"):
                _set_owned_pvr_instance_enabled("favs", False, "Sports PVR safe")
            if _role_has_instance("sports"):
                _set_owned_pvr_instance_enabled("sports", True, "Sports PVR safe")
            if _role_has_instance("sports_favs"):
                _set_owned_pvr_instance_enabled("sports_favs", True, "Sports PVR safe")
    finally:
        try:
            _pvr_progress(progress, 92, "Re-enabling IPTV Simple")
            xbmc.executeJSONRPC(json.dumps({
                "jsonrpc": "2.0",
                "id": 1,
                "method": "Addons.SetAddonEnabled",
                "params": {"addonid": "pvr.iptvsimple", "enabled": True},
            }))
            _log("Sports PVR safe: Re-enabled pvr.iptvsimple")
            xbmc.sleep(_tvos_sleep(2000))

            _pvr_progress(progress, 96, "Starting PVR Manager")
            xbmc.executeJSONRPC('{"jsonrpc": "2.0", "method": "PVR.Manager.Start", "id": 1}')
            _log("Sports PVR safe: skipped immediate EPGLoad after manual Sports PVR reload")
            _pvr_progress(progress, 100, "Sports PVR reload complete")
            xbmc.sleep(_tvos_sleep(500))
        except Exception as e:
            _log(f"Sports PVR safe: re-enable warning: {e}")
        try:
            progress.close()
        except Exception:
            pass
    return ok


def _remove_pvr_favs_instance_config(force=False):
    if not force and _m3u_file_has_channels(_pvr_favs_m3u_path()):
        _log("PVR Favorites: keeping instance because favorites M3U has channels")
        return False
    settings_path = _pvr_instance_settings_path(_get_pvr_instance_id("favs"))
    try:
        if os.path.exists(settings_path):
            os.remove(settings_path)
            _log(f"PVR Favorites: Removed empty instance {os.path.basename(settings_path)}")
    except Exception as e:
        _log(f"PVR Favorites: instance remove warning: {e}")
    if addon.getSetting(PVR_FAVS_INSTANCE_SETTING):
        addon.setSetting(PVR_FAVS_INSTANCE_SETTING, "")
    try:
        sidecar = os.path.join(
            xbmcvfs.translatePath(addon.getAddonInfo("profile")),
            ".pvr_fav_verified",
        )
        if os.path.exists(sidecar):
            os.remove(sidecar)
    except Exception:
        pass
    return True


def _configure_pvr_favs_instance():
    """Create/update PVR IPTV Simple Client instance 12 for favorites only."""
    try:
        pvr_profile = _pvr_iptvsimple_profile_dir()
        os.makedirs(pvr_profile, exist_ok=True)
        settings_path = _pvr_instance_settings_path(_get_pvr_instance_id("favs"))
        m3u_path = _pvr_favs_m3u_path()
        epg_path = _pvr_epg_path()

        if not _m3u_file_has_channels(m3u_path):
            _log("PVR Favorites: no favorite channels, removing PVR Favorites instance")
            _remove_pvr_favs_instance_config()
            return False

        _log("PVR Favorites: Configuring instance")
        # PVR EPG refresh interval (convert hours to seconds for IPTV Simple)
        try:
            pvr_epg_hours = int(addon.getSetting("pvr_epg_refresh") or "12")
        except ValueError:
            pvr_epg_hours = 12
        pvr_epg_seconds = pvr_epg_hours * 3600
        _log(
            f"PVR Favorites: Configuring EPG refresh interval to {pvr_epg_hours} hours"
        )
        updates = {
            "kodi_addon_instance_name": PVR_FAVS_INSTANCE_NAME,
            "kodi_addon_instance_enabled": "true",
            "m3uPathType": "0",
            "m3uPath": m3u_path,
            "m3uUrl": "",
            "epgPathType": "0",
            "epgPath": epg_path,
            "epgUrl": "",
            "epgRefreshInterval": str(pvr_epg_seconds),
            "m3uRefreshMode": "1",
            "logoPathType": "0",
            "logoPath": "",
            "logoBaseUrl": "",
            "catchupEnabled": (
                "true"
                if addon.getSetting("pvr_catchup_enabled").lower() == "true"
                else "false"
            ),
            "catchupPlayEpgAsLive": "false",
            "allChannelsCatchupMode": "0",
            "catchupOverrideMode": "0",
            "catchupDays": addon.getSetting("pvr_catchup_days") or "7",
            "catchupOnlyOnFinishedProgrammes": "false",
            "catchupWatchEpgBeginBufferMins": "0",
            "catchupWatchEpgEndBufferMins": "5",
        }
        if os.path.exists(settings_path):
            tree = ET.parse(settings_path)
            root = tree.getroot()
            changed = False
            stale_keys = {"defaultInputstream", "defaultMimeType"}
            for stale in stale_keys:
                el = root.find(f".//setting[@id='{stale}']")
                if el is not None:
                    root.remove(el)
                    changed = True
                    _log(f"PVR Favorites: Removed stale setting '{stale}'")
            for key, val in updates.items():
                el = root.find(f".//setting[@id='{key}']")
                if el is not None:
                    if (el.text or "") != val:
                        el.text = val
                        if "default" in el.attrib:
                            del el.attrib["default"]
                        changed = True
                else:
                    new_el = ET.SubElement(root, "setting", {"id": key})
                    new_el.text = val
                    changed = True
            if changed:
                tmp_path = settings_path + ".tmp"
                tree.write(tmp_path, encoding="utf-8", xml_declaration=True)
                with open(tmp_path, "r+b") as f:
                    try:
                        _safe_fsync(f)
                    except OSError:
                        pass
                os.replace(tmp_path, settings_path)
        else:
            root = ET.Element("settings", {"version": "2"})
            for key, val in updates.items():
                el = ET.SubElement(root, "setting", {"id": key})
                el.text = val
            tmp_path = settings_path + ".tmp"
            ET.ElementTree(root).write(tmp_path, encoding="utf-8", xml_declaration=True)
            with open(tmp_path, "r+b") as f:
                try:
                    _safe_fsync(f)
                except OSError:
                    pass
            os.replace(tmp_path, settings_path)
        _log(f"Configured PVR Favorites instance ({os.path.basename(settings_path)})")

        return True
    except Exception as e:
        _log(f"Configure PVR Favorites instance failed: {e}")
        _log(f"Traceback: {traceback.format_exc()}")
        return False


def _is_pvr_iptvsimple_enabled():
    """Check if pvr.iptvsimple is currently enabled via JSON-RPC."""
    try:
        resp = json.loads(
            xbmc.executeJSONRPC(
                '{"jsonrpc":"2.0","method":"Addons.GetAddonDetails","params":{"addonid":"pvr.iptvsimple","properties":["enabled"]},"id":1}'
            )
        )
        return resp.get("result", {}).get("addon", {}).get("enabled", False)
    except Exception as e:
        _log(f"PVR Favorites: Could not check addon state: {e}")
        return True  # assume enabled to stay safe


def _sync_pvr_favorites():
    """Sync PVR favorites M3U and configure the second PVR instance."""
    if addon.getSetting("pvr_favorites_enabled").lower() != "true":
        _log("PVR Favorites: Disabled by user setting, removing PVR instance")
        try:
            pvr_profile = _pvr_iptvsimple_profile_dir()
            settings_path = _pvr_instance_settings_path(_get_pvr_instance_id("favs"))
            if os.path.exists(settings_path):
                # Safely disable pvr.iptvsimple before removing instance-12 config
                # to avoid Epg16.db corruption while the binary addon holds handles.
                try:
                    xbmc.executeJSONRPC(
                        '{"jsonrpc": "2.0", "method": "PVR.Manager.Stop", "id": 1}'
                    )
                    xbmc.sleep(_tvos_sleep(1000))
                except Exception as e:
                    _log(f"PVR Favorites: Manager stop warning: {e}")
                try:
                    xbmc.executeJSONRPC(
                        json.dumps(
                            {
                                "jsonrpc": "2.0",
                                "id": 1,
                                "method": "Addons.SetAddonEnabled",
                                "params": {
                                    "addonid": "pvr.iptvsimple",
                                    "enabled": False,
                                },
                            }
                        )
                    )
                    xbmc.sleep(_tvos_sleep(1500))
                except Exception as e:
                    _log(f"PVR Favorites: disable warning: {e}")
                try:
                    os.remove(settings_path)
                    _log(f"PVR Favorites: Removed {os.path.basename(settings_path)}")
                except Exception as e:
                    _log(f"PVR Favorites: Error removing {os.path.basename(settings_path)}: {e}")
                finally:
                    try:
                        xbmc.executeJSONRPC(
                            json.dumps(
                                {
                                    "jsonrpc": "2.0",
                                    "id": 1,
                                    "method": "Addons.SetAddonEnabled",
                                    "params": {
                                        "addonid": "pvr.iptvsimple",
                                        "enabled": True,
                                    },
                                }
                            )
                        )
                        xbmc.sleep(_tvos_sleep(1000))
                        xbmc.executeJSONRPC(
                            '{"jsonrpc": "2.0", "method": "PVR.Manager.Start", "id": 1}'
                        )
                        _log("PVR Favorites: Re-enabled pvr.iptvsimple after removal")
                    except Exception as e:
                        _log(f"PVR Favorites: re-enable warning: {e}")
        except Exception as e:
            _log(f"PVR Favorites: Error removing instance: {e}")
        return

    # Lightweight lock to prevent overlapping syncs
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    lock_path = os.path.join(profile, ".pvr_fav_sync.lock")
    try:
        if os.path.exists(lock_path):
            try:
                age = time.time() - os.path.getmtime(lock_path)
                if age < 30:
                    _log("PVR Favorites: Sync already in progress, skipping")
                    return
            except OSError:
                pass
        with open(lock_path, "w", encoding="utf-8") as f:
            f.write("1")
    except Exception as e:
        _log(f"PVR Favorites: Lock write warning: {e}")

    try:
        pvr_profile_dir = _pvr_iptvsimple_profile_dir()
        settings_path = _pvr_instance_settings_path(_get_pvr_instance_id("favs"))
        expected_m3u = _pvr_favs_m3u_path()
        json_path = _pvr_favs_path()
        m3u_stale = (
            not _m3u_file_has_channels(expected_m3u)
            or (
                os.path.exists(json_path)
                and os.path.exists(expected_m3u)
                and os.path.getmtime(json_path) > os.path.getmtime(expected_m3u)
            )
        )
        if m3u_stale:
            _export_pvr_favs_m3u()
        needs_config = True
        if os.path.exists(settings_path):
            try:
                tree = ET.parse(settings_path)
                root = tree.getroot()
                el = root.find(".//setting[@id='m3uPath']")
                if (
                    el is not None
                    and (el.text or "") == expected_m3u
                    and os.path.exists(expected_m3u)
                    and not m3u_stale
                ):
                    needs_config = False
                    _log(
                        f"PVR Favorites: {os.path.basename(settings_path)} already correct, skipping rewrite"
                    )
            except Exception as e:
                _log(f"PVR Favorites: XML read warning: {e}")
        if needs_config:
            # Disable pvr.iptvsimple before rewriting instance-settings
            # to avoid partial reads while the binary addon holds handles.
            try:
                xbmc.executeJSONRPC(
                    '{"jsonrpc": "2.0", "method": "PVR.Manager.Stop", "id": 1}'
                )
                xbmc.sleep(_tvos_sleep(1000))
            except Exception as e:
                _log(f"PVR Favorites: Manager stop warning: {e}")
            try:
                xbmc.executeJSONRPC(
                    json.dumps(
                        {
                            "jsonrpc": "2.0",
                            "id": 1,
                            "method": "Addons.SetAddonEnabled",
                            "params": {"addonid": "pvr.iptvsimple", "enabled": False},
                        }
                    )
                )
                xbmc.sleep(_tvos_sleep(1500))
                if sys.platform.startswith("win"):
                    xbmc.sleep(_tvos_sleep(2000))
            except Exception as e:
                _log(f"PVR Favorites: disable warning: {e}")
            try:
                _configure_pvr_favs_instance()
            finally:
                try:
                    xbmc.executeJSONRPC(
                        json.dumps(
                            {
                                "jsonrpc": "2.0",
                                "id": 1,
                                "method": "Addons.SetAddonEnabled",
                                "params": {"addonid": "pvr.iptvsimple", "enabled": True},
                            }
                        )
                    )
                    xbmc.sleep(_tvos_sleep(2000))
                    xbmc.executeJSONRPC(
                        '{"jsonrpc": "2.0", "method": "PVR.Manager.Start", "id": 1}'
                    )
                    _log("PVR Favorites: Re-enabled pvr.iptvsimple after config rewrite")
                except Exception as e:
                    _log(f"PVR Favorites: re-enable warning: {e}")
    finally:
        try:
            if os.path.exists(lock_path):
                os.remove(lock_path)
        except Exception as e:
            _log(f"PVR Favorites: Lock remove warning: {e}")


def _safe_sync_pvr_favorites_startup():
    """Startup check only. Never stop/disable/reload PVR from main-menu startup."""
    try:
        expected_m3u = _pvr_favs_m3u_path()
        settings_path = _pvr_instance_settings_path(_get_pvr_instance_id("favs"))
        if not _m3u_file_has_channels(expected_m3u):
            _log("PVR Favorites: Startup sync skipped, no favorite channels")
            return
        if not os.path.exists(settings_path):
            _log("PVR Favorites: Startup sync deferred, favorites instance missing")
            return
        try:
            tree = ET.parse(settings_path)
            root = tree.getroot()
            el = root.find(".//setting[@id='m3uPath']")
            if el is not None and (el.text or "") == expected_m3u:
                _log(
                    f"PVR Favorites: Startup sync skipped, {os.path.basename(settings_path)} already correct"
                )
                return
        except Exception as e:
            _log(f"PVR Favorites: Startup XML read warning: {e}")
        _log("PVR Favorites: Startup sync deferred to manual favorites action")
    except Exception as e:
        _log(f"PVR Favorites: Startup state check error: {e}")


def _sync_pvr_favorites_safe():
    """Wrap _sync_pvr_favorites with PVR stop/disable safety for file writes.

    Required when called during restore or other contexts where pvr.iptvsimple
    may already be enabled and holding SQLite handles (Android safety).
    """
    try:
        xbmc.executeJSONRPC('{"jsonrpc": "2.0", "method": "PVR.Manager.Stop", "id": 1}')
        xbmc.sleep(_tvos_sleep(1000))
    except Exception as e:
        _log(f"PVR Favorites safe: Manager stop warning: {e}")
    try:
        xbmc.executeJSONRPC(
            json.dumps(
                {
                    "jsonrpc": "2.0",
                    "id": 1,
                    "method": "Addons.SetAddonEnabled",
                    "params": {"addonid": "pvr.iptvsimple", "enabled": False},
                }
            )
        )
        xbmc.sleep(_tvos_sleep(1500))
        if sys.platform.startswith("win"):
            xbmc.sleep(_tvos_sleep(2000))
    except Exception as e:
        _log(f"PVR Favorites safe: disable warning: {e}")
    try:
        _sync_pvr_favorites()
    finally:
        try:
            xbmc.executeJSONRPC(
                json.dumps(
                    {
                        "jsonrpc": "2.0",
                        "id": 1,
                        "method": "Addons.SetAddonEnabled",
                        "params": {"addonid": "pvr.iptvsimple", "enabled": True},
                    }
                )
            )
            _log("PVR Favorites safe: Re-enabled pvr.iptvsimple")
            xbmc.sleep(_tvos_sleep(2000))
            xbmc.executeJSONRPC(
                '{"jsonrpc": "2.0", "method": "PVR.Manager.Start", "id": 1}'
            )
        except Exception as e:
            _log(f"PVR Favorites safe: re-enable warning: {e}")


def pvr_favorites_manager(group=None):
    """PVR Favorites Manager - groups with channels, similar to Favorites Manager."""
    groups = _pvr_favs_load_all()

    # Level 1: show groups + New Group
    if group is None:
        for gname, items in groups.items():
            count = len(items)
            li = xbmcgui.ListItem(label=_t(30272).format(gname, count))
            li.setArt({"icon": "DefaultFavourites.png"})
            ctx_items = [
                (
                    _t(30320),
                    f"RunPlugin({build_url({'mode': 'pvr_favs_rename_group', 'group': gname})})",
                ),
                (
                    _t(30321),
                    f"RunPlugin({build_url({'mode': 'pvr_favs_delete_group', 'group': gname})})",
                ),
            ]
            li.addContextMenuItems(ctx_items)
            q = {"mode": "pvr_favorites_manager", "group": gname}
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url=build_url(q), listitem=li, isFolder=True
            )
        # + New PVR Group
        li = xbmcgui.ListItem(label="[COLOR yellow]{0}[/COLOR]".format(_t(30200)))
        xbmcplugin.addDirectoryItem(
            handle=addon_handle,
            url=build_url({"mode": "pvr_favs_new_group"}),
            listitem=li,
            isFolder=False,
        )
        xbmcplugin.endOfDirectory(addon_handle, succeeded=True, updateListing=False, cacheToDisc=False)
        return

    # Level 2: inside a group - manage button + channels
    items = groups.get(group, [])
    li = xbmcgui.ListItem(label="[COLOR yellow]{0}[/COLOR]".format(_t(30201)))
    li.setArt({"icon": "DefaultAddonService.png"})
    xbmcplugin.addDirectoryItem(
        handle=addon_handle,
        url=build_url({"mode": "pvr_favs_manage_group", "group": group}),
        listitem=li,
        isFolder=True,
    )

    if not items:
        li = xbmcgui.ListItem(label="[COLOR gray]{0}[/COLOR]".format(_t(30202)))
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url="", listitem=li, isFolder=False
        )
    else:
        creds = _get_pvr_credentials()
        xt_url = creds.get("xtream_url", "")
        xt_user = creds.get("xtream_username", "")
        xt_pwd = creds.get("xtream_password", "")
        for ch in items:
            name = ch.get("name", "Unknown")
            icon = ch.get("stream_icon", "") or ch.get("icon", "")
            sid = str(ch.get("stream_id", ""))
            li = xbmcgui.ListItem(label=name)
            li.setArt({"icon": icon, "thumb": icon})
            ctx_items = [
                (
                    _t(30322),
                    f"RunPlugin({build_url({'mode': 'pvr_fav_remove', 'stream_id': sid, 'group': group})})",
                ),
            ]
            li.addContextMenuItems(ctx_items)
            stream_url = f"{xt_url}/live/{xt_user}/{xt_pwd}/{sid}.ts" if xt_url else ""
            q = {
                "mode": "play_stream",
                "url": stream_url,
                "name": name,
                "icon": icon,
                "stype": "live",
            }
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
            )

    xbmcplugin.endOfDirectory(addon_handle)


def pvr_favs_manage_group(group):
    """Category browser for adding channels to a specific PVR group."""
    creds = _get_pvr_credentials()
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    if not url:
        xbmcgui.Dialog().notification("Xstream Pro", _t(30056))
        xbmcplugin.endOfDirectory(addon_handle)
        return
    # Current channels multiselect
    current_count = len(_pvr_favs_load(group))
    li = xbmcgui.ListItem(
        label="[COLOR gold]{0}[/COLOR]".format(_t(30203).format(current_count))
    )
    li.setArt({"icon": "DefaultFavourites.png"})
    xbmcplugin.addDirectoryItem(
        handle=addon_handle,
        url=build_url({"mode": "pvr_favs_group_current", "group": group}),
        listitem=li,
        isFolder=False,
    )
    # Search
    li = xbmcgui.ListItem(label="[COLOR yellow]{0}[/COLOR]".format(_t(30204)))
    li.setArt({"icon": "DefaultAddonsSearch.png"})
    xbmcplugin.addDirectoryItem(
        handle=addon_handle,
        url=build_url({"mode": "pvr_favs_group_search", "group": group}),
        listitem=li,
        isFolder=False,
    )
    # Categories - filter out hidden (use PVR profile's hidden lists)
    pvr_pnum = _get_pvr_profile_num()
    cats = _get_cached_xtream_categories(url, user, pwd, "live")
    hidden_live = _get_hidden_subcats("live", pvr_pnum)
    for c in cats or []:
        cat_id = str(c.get("category_id", ""))
        if cat_id in hidden_live:
            continue
        cat_name = c.get("category_name", "Unknown")
        li = xbmcgui.ListItem(label=cat_name)
        li.setArt({"icon": "DefaultFolder.png"})
        q = {
            "mode": "pvr_favs_manage_cat",
            "cat_id": cat_id,
            "cat_name": cat_name,
            "group": group,
        }
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
        )
    xbmcplugin.endOfDirectory(addon_handle)


def pvr_favs_group_current(group):
    """Multiselect dialog to keep/remove channels in a PVR group."""
    items = _pvr_favs_load(group)
    if not items:
        xbmcgui.Dialog().notification("Xstream Pro", _t(30101))
        return
    dialog = xbmcgui.Dialog()
    names = [ch.get("name", "Unknown") for ch in items]
    preselect = list(range(len(items)))
    result = dialog.multiselect(_t(30330, group), names, preselect=preselect)
    if result is None:
        return
    if len(result) == len(items):
        return
    new_favs = [items[i] for i in result]
    _pvr_favs_save(new_favs, group)
    _sync_pvr_favorites_safe()
    removed = len(items) - len(new_favs)
    dialog.notification("Xstream Pro", _t(30168, removed))
    xbmc.sleep(_tvos_sleep(1500))
    dialog.ok("Xstream Pro", _t(30727))
    xbmc.executebuiltin("Container.Refresh")


def pvr_favs_group_search(group):
    """Search channels and multiselect to add/remove from a PVR group."""
    dialog = xbmcgui.Dialog()
    query = dialog.input(_t(30146))
    if not query:
        return
    creds = _get_pvr_credentials()
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    streams = _get_cached_xtream_streams(url, user, pwd, "live")
    pvr_pnum = _get_pvr_profile_num()
    hidden_live = _get_hidden_subcats("live", pvr_pnum)
    hidden_items = _get_hidden_items("live", pvr_pnum)
    if not streams:
        dialog.notification("Xstream Pro", _t(30067))
        return
    query_lower = query.lower()
    filtered = [
        s
        for s in streams
        if query_lower in s.get("name", "").lower()
        and str(s.get("category_id", "")) not in hidden_live
        and str(s.get("stream_id", "")) not in hidden_items
    ]
    if not filtered:
        dialog.notification("Xstream Pro", _t(30067))
        return
    _pvr_favs_multiselect(filtered, dialog, group)


def pvr_favs_manage_cat(cat_id, cat_name, group):
    """Pick a category: add entire or multiselect individual channels for a PVR group."""
    dialog = xbmcgui.Dialog()
    action = dialog.select(cat_name, [_t(30331, group), _t(30332)])
    if action < 0:
        return
    creds = _get_pvr_credentials()
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    streams = _get_cached_xtream_streams(url, user, pwd, "live")
    pvr_pnum = _get_pvr_profile_num()
    hidden_items = _get_hidden_items("live", pvr_pnum)
    filtered = [
        s
        for s in (streams or [])
        if str(s.get("category_id", "")) == cat_id
        and str(s.get("stream_id", "")) not in hidden_items
    ]
    if not filtered:
        dialog.notification("Xstream Pro", _t(30068))
        return
    if action == 0:
        current_favs = _pvr_favs_load(group)
        fav_ids = {str(i.get("stream_id", "")) for i in current_favs}
        added = 0
        for s in filtered:
            if str(s.get("stream_id", "")) not in fav_ids:
                current_favs.append(s)
                added += 1
        _pvr_favs_save(current_favs, group)
        _sync_pvr_favorites_safe()
        dialog.notification("Xstream Pro", _t(30169, added))
        xbmc.sleep(_tvos_sleep(1500))
        dialog.ok("Xstream Pro", _t(30727))
        xbmc.executebuiltin("Container.Refresh")
        return
    _pvr_favs_multiselect(filtered, dialog, group)


def _pvr_favs_multiselect(filtered, dialog, group=None):
    """Multiselect channels to add/remove from a PVR group."""
    if group is None:
        group = _t(30703) or "Favorites"
    names = [s.get("name", "Unknown") for s in filtered]
    current_favs = _pvr_favs_load(group)
    fav_ids = {str(i.get("stream_id", "")) for i in current_favs}
    preselect = [
        i for i, s in enumerate(filtered) if str(s.get("stream_id", "")) in fav_ids
    ]
    result = dialog.multiselect(_t(30151, group), names, preselect=preselect)
    if result is None:
        return
    filtered_ids = {str(s.get("stream_id", "")) for s in filtered}
    new_favs = [
        f for f in current_favs if str(f.get("stream_id", "")) not in filtered_ids
    ]
    for i in result or []:
        new_favs.append(filtered[i])
    _pvr_favs_save(new_favs, group)
    _sync_pvr_favorites_safe()
    dialog.notification("Xstream Pro", _t(30170, len(new_favs), group))
    xbmc.sleep(_tvos_sleep(1500))
    dialog.ok("Xstream Pro", _t(30727))
    xbmc.executebuiltin("Container.Refresh")


def _sports_pvr_item_key(ch):
    return str(ch.get("stream_id", "") or ch.get("tvg_id", "") or ch.get("url", ""))


def _sports_pvr_source_country_groups():
    groups = {}
    prefix = "Sports TV - "
    for ch in _sports_pvr_source_channels() or []:
        label = ch.get("group", "") or "Other Countries"
        if label.startswith(prefix):
            label = label[len(prefix):]
        if label == "Sports TV":
            label = "Other Countries"
        groups.setdefault(label or "Other Countries", []).append(ch)
    return sorted(
        groups.items(),
        key=lambda item: (item[0] == "Other Countries", item[0].lower()),
    )


def sports_pvr_favorites_manager(group=None):
    """PVR Favorites Manager - groups with channels, similar to Live TV PVR Favorites Manager."""
    groups = _sports_pvr_favs_load_all()

    if group is None:
        for gname, items in groups.items():
            count = len(items)
            li = xbmcgui.ListItem(label=_t(30272).format(gname, count))
            li.setArt({"icon": "DefaultFavourites.png"})
            ctx_items = [
                (
                    _t(30320),
                    f"RunPlugin({build_url({'mode': 'sports_pvr_favs_rename_group', 'group': gname})})",
                ),
                (
                    _t(30321),
                    f"RunPlugin({build_url({'mode': 'sports_pvr_favs_delete_group', 'group': gname})})",
                ),
            ]
            li.addContextMenuItems(ctx_items)
            q = {"mode": "sports_pvr_favorites_manager", "group": gname}
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url=build_url(q), listitem=li, isFolder=True
            )
        li = xbmcgui.ListItem(label="[COLOR yellow]{0}[/COLOR]".format(_t(30200)))
        xbmcplugin.addDirectoryItem(
            handle=addon_handle,
            url=build_url({"mode": "sports_pvr_favs_new_group"}),
            listitem=li,
            isFolder=False,
        )
        xbmcplugin.endOfDirectory(addon_handle, succeeded=True, updateListing=False, cacheToDisc=False)
        return

    items = groups.get(group, [])
    li = xbmcgui.ListItem(label="[COLOR yellow]{0}[/COLOR]".format(_t(30201)))
    li.setArt({"icon": "DefaultAddonService.png"})
    xbmcplugin.addDirectoryItem(
        handle=addon_handle,
        url=build_url({"mode": "sports_pvr_favs_manage_group", "group": group}),
        listitem=li,
        isFolder=True,
    )

    if not items:
        li = xbmcgui.ListItem(label="[COLOR gray]{0}[/COLOR]".format(_t(30202)))
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url="", listitem=li, isFolder=False
        )
    else:
        for ch in items:
            name = ch.get("name", "Unknown")
            icon = ch.get("logo", "") or ch.get("stream_icon", "")
            sid = _sports_pvr_item_key(ch)
            li = xbmcgui.ListItem(label=name)
            li.setArt({"icon": icon, "thumb": icon})
            li.setProperty("IsPlayable", "true")
            _prepare_playback_item(li)
            _set_live_props(li)
            ctx_items = [
                (
                    _t(30322),
                    f"RunPlugin({build_url({'mode': 'sports_pvr_fav_remove', 'stream_id': sid, 'group': group})})",
                ),
            ]
            li.addContextMenuItems(ctx_items)
            q = {
                "mode": "play_stream",
                "url": ch.get("url", ""),
                "name": name,
                "icon": icon,
                "stype": "live",
                "profile_num": ch.get("profile_num", _get_sports_pvr_profile_num()),
            }
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
            )

    xbmcplugin.endOfDirectory(addon_handle)


def sports_pvr_favs_manage(group=None):
    sports_pvr_favs_manage_group(group or "Favorites")


def sports_pvr_favs_manage_group(group):
    """Category browser for adding channels to a specific Sports PVR group."""
    current_count = len(_sports_pvr_favs_load(group))
    li = xbmcgui.ListItem(
        label="[COLOR gold]{0}[/COLOR]".format(_t(30203).format(current_count))
    )
    li.setArt({"icon": "DefaultFavourites.png"})
    xbmcplugin.addDirectoryItem(
        handle=addon_handle,
        url=build_url({"mode": "sports_pvr_favs_group_current", "group": group}),
        listitem=li,
        isFolder=False,
    )
    li = xbmcgui.ListItem(label="[COLOR yellow]{0}[/COLOR]".format(_t(30204)))
    li.setArt({"icon": "DefaultAddonsSearch.png"})
    xbmcplugin.addDirectoryItem(
        handle=addon_handle,
        url=build_url({"mode": "sports_pvr_favs_group_search", "group": group}),
        listitem=li,
        isFolder=False,
    )
    for country, _channels in _sports_pvr_source_country_groups():
        li = xbmcgui.ListItem(label=country)
        li.setArt({"icon": "DefaultFolder.png"})
        q = {
            "mode": "sports_pvr_favs_manage_country",
            "country": country,
            "group": group,
        }
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
        )
    xbmcplugin.endOfDirectory(addon_handle)


def sports_pvr_favs_group_current(group):
    """Multiselect dialog to keep/remove channels in a Sports PVR group."""
    items = _sports_pvr_favs_load(group)
    if not items:
        xbmcgui.Dialog().notification("Xstream Pro", _t(30101))
        return
    dialog = xbmcgui.Dialog()
    names = [ch.get("name", "Unknown") for ch in items]
    preselect = list(range(len(items)))
    result = dialog.multiselect(_t(30330, group), names, preselect=preselect)
    if result is None:
        return
    if len(result) == len(items):
        return
    new_favs = [items[i] for i in result]
    _sports_pvr_favs_save(new_favs, group)
    addon.setSetting("sports_pvr_enabled", "true")
    _sync_sports_pvr_safe()
    removed = len(items) - len(new_favs)
    dialog.notification("Xstream Pro", _t(30168, removed))
    xbmc.sleep(_tvos_sleep(1500))
    dialog.ok("Xstream Pro", _t(30727))
    xbmc.executebuiltin("Container.Refresh")


def sports_pvr_favs_group_search(group):
    """Search sports channels and multiselect to add/remove from a Sports PVR group."""
    dialog = xbmcgui.Dialog()
    query = dialog.input(_t(30146))
    if not query:
        return
    channels = _sports_pvr_source_channels()
    if not channels:
        dialog.notification("Xstream Pro", _t(30067))
        return
    query_lower = query.lower()
    filtered = [
        ch for ch in channels
        if query_lower in ch.get("name", "").lower()
    ]
    if not filtered:
        dialog.notification("Xstream Pro", _t(30067))
        return
    _sports_pvr_favs_multiselect(filtered, dialog, group)


def sports_pvr_favs_manage_country(country, group):
    """Pick a country: multiselect individual channels for a Sports PVR group."""
    dialog = xbmcgui.Dialog()
    for cname, channels in _sports_pvr_source_country_groups():
        if cname == country:
            filtered = channels
            break
    else:
        dialog.notification("Xstream Pro", _t(30068))
        return
    if not filtered:
        dialog.notification("Xstream Pro", _t(30068))
        return
    _sports_pvr_favs_multiselect(filtered, dialog, group)


def _sports_pvr_favs_multiselect(filtered, dialog, group=None):
    """Multiselect channels to add/remove from a Sports PVR group."""
    if group is None:
        group = _t(30703) or "Favorites"
    names = [s.get("name", "Unknown") for s in filtered]
    current_favs = _sports_pvr_favs_load(group)
    fav_ids = {_sports_pvr_item_key(i) for i in current_favs}
    preselect = [
        i for i, s in enumerate(filtered) if _sports_pvr_item_key(s) in fav_ids
    ]
    result = dialog.multiselect(_t(30151, group), names, preselect=preselect)
    if result is None:
        return
    filtered_ids = {_sports_pvr_item_key(s) for s in filtered}
    new_favs = [
        f for f in current_favs if _sports_pvr_item_key(f) not in filtered_ids
    ]
    for i in result or []:
        new_favs.append(filtered[i])
    _sports_pvr_favs_save(new_favs, group)
    addon.setSetting("sports_pvr_enabled", "true")
    _sync_sports_pvr_safe()
    dialog.notification("Xstream Pro", _t(30170, len(new_favs), group))
    xbmc.sleep(_tvos_sleep(1500))
    dialog.ok("Xstream Pro", _t(30727))
    xbmc.executebuiltin("Container.Refresh")


def sports_pvr_favs_new_group():
    dialog = xbmcgui.Dialog()
    name = dialog.input(_t(30147))
    if name:
        groups = _sports_pvr_favs_load_all()
        if name in groups:
            dialog.notification("Xstream Pro", _t(30125))
        else:
            groups[name] = []
            _sports_pvr_favs_save_all(groups)
            dialog.notification("Xstream Pro", _t(30126, name))
            xbmc.sleep(_tvos_sleep(1500))
            dialog.ok("Xstream Pro", _t(30727))
            xbmc.executebuiltin("Container.Refresh")


def sports_pvr_favs_rename_group():
    old_name = args.get("group", [""])[0]
    dialog = xbmcgui.Dialog()
    new_name = dialog.input(_t(30148), defaultt=old_name)
    if new_name and new_name != old_name:
        groups = _sports_pvr_favs_load_all()
        if new_name in groups:
            dialog.notification("Xstream Pro", _t(30125))
        elif old_name in groups:
            items = groups.pop(old_name)
            groups[new_name] = items
            _sports_pvr_favs_save_all(groups)
            addon.setSetting("sports_pvr_enabled", "true")
            _sync_sports_pvr_safe()
            dialog.notification("Xstream Pro", _t(30178, new_name))
            xbmc.executebuiltin("Container.Refresh")


def sports_pvr_favs_delete_group():
    group_name = args.get("group", [""])[0]
    dialog = xbmcgui.Dialog()
    if dialog.yesno(_t(30038), _t(30252, group_name)):
        groups = _sports_pvr_favs_load_all()
        if group_name in groups:
            del groups[group_name]
            _sports_pvr_favs_save_all(groups)
            addon.setSetting("sports_pvr_enabled", "true")
            _sync_sports_pvr_safe()
            dialog.notification("Xstream Pro", _t(30179, group_name))
            xbmc.executebuiltin("Container.Refresh")


def sports_pvr_fav_remove(stream_id, group=None):
    group = group or "Favorites"
    items = _sports_pvr_favs_load(group)
    sid = str(stream_id)
    new_items = [
        ch for ch in items
        if _sports_pvr_item_key(ch) != sid
    ]
    if len(new_items) != len(items):
        _sports_pvr_favs_save(new_items, group)
        addon.setSetting("sports_pvr_enabled", "true")
        _sync_sports_pvr_safe()
    xbmc.executebuiltin("Container.Refresh")


def _check_account_expiry():
    """Warn if Xtream account expires within 7 days."""
    creds = _get_credentials()
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    if not url or not user or not pwd:
        return
    # Only check once per day using a per-profile flag file
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    flag = os.path.join(profile, f"expiry_checked_p{pm.active}")
    if os.path.exists(flag):
        try:
            age = time.time() - os.path.getmtime(flag)
            if age < 86400:  # check once per day
                return
        except Exception as e:
            _log(f"expiry flag age check warning: {e}")
    try:
        info = IPTV.validate_xtream(url, user, pwd)
        if not info:
            # HTTP/auth failure — don't write the flag, otherwise we'd rate-limit
            # ourselves for 24h after a transient failure.
            return
        if info.get("exp_date"):
            exp_dt = datetime.datetime.fromtimestamp(int(info["exp_date"]))
            days_left = (exp_dt - datetime.datetime.now()).days
            if days_left < 7:
                xbmcgui.Dialog().notification(
                    "Xstream Pro",
                    _t(30300, days_left),
                    xbmcgui.NOTIFICATION_WARNING,
                    5000,
                )
        try:
            with open(flag, "w", encoding="utf-8") as f:
                f.write("1")
        except Exception as e:
            _log(f"expiry flag write failed: {e}")
    except Exception as e:
        _log(f"account expiry check failed: {e}")


# Per-invocation parse cache for settings.xml. Same plugin invocation may call
# _get_setting_direct() many times across menu builders; reparsing the XML on
# every call is wasteful. We key on (path, mtime) so any external write is seen.
_settings_xml_cache = {"path": None, "mtime": 0, "values": None}


def _get_setting_direct(setting_id, default=""):
    """Read setting directly from settings.xml to bypass Kodi's cache."""
    try:
        profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
        settings_path = os.path.join(profile, "settings.xml")
        if os.path.exists(settings_path):
            mtime = os.path.getmtime(settings_path)
            cache = _settings_xml_cache
            if (
                cache["path"] != settings_path
                or cache["mtime"] != mtime
                or cache["values"] is None
            ):
                import xml.etree.ElementTree as ET

                tree = ET.parse(settings_path)
                root = tree.getroot()
                values = {}
                for setting in root.findall("setting"):
                    sid = setting.get("id")
                    if sid:
                        values[sid] = setting.text or ""
                cache["path"] = settings_path
                cache["mtime"] = mtime
                cache["values"] = values
            if setting_id in cache["values"]:
                return cache["values"][setting_id] or default
    except Exception as e:
        xbmc.log(f"[Xstream Pro] Settings XML read error: {e}", xbmc.LOGWARNING)
    return addon.getSetting(setting_id) or default


def _invalidate_settings_xml_cache():
    """Force the direct-settings cache to re-read on next call."""
    _settings_xml_cache["path"] = None
    _settings_xml_cache["mtime"] = 0
    _settings_xml_cache["values"] = None


def _run_startup_checks():
    """Run startup checks in background thread. Called from main_menu()."""
    _log("Running startup checks")

    backup_display = _read_backup_folder()
    if addon.getSetting("backup_folder_display") != backup_display:
        addon.setSetting("backup_folder_display", backup_display)
    try:
        current = addon.getSetting("pvr_favorites_enabled").lower() == "true"
        saved = addon.getSetting("pvr_fav_saved_state")
        if saved != "" and current != (saved.lower() == "true"):
            profile_path = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
            fresh_bootstrap = not os.path.exists(os.path.join(profile_path, ".bootstrap_signature"))
            if not fresh_bootstrap:
                xbmcgui.Dialog().notification(
                    "Xstream Pro", _t(30729), xbmcgui.NOTIFICATION_INFO, 5000
                )
        new_saved = "true" if current else "false"
        if saved != new_saved:
            addon.setSetting("pvr_fav_saved_state", new_saved)
    except Exception as e:
        _log(f"PVR favorites state check error: {e}")
    _check_credentials_refresh_prompt()  # Prompt to refresh after first credentials entry

    # Skip auto-refresh if restore just completed — profiles will be loaded anyway
    profile_path = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    defer_flag = os.path.join(profile_path, "pvr_sync_after_profile_load.flag")
    if not os.path.exists(defer_flag):
        _check_profiles_auto_refresh()
    else:
        _log("Skipping auto-refresh — restore pending profile load")

    # PVR startup retry: if user declined PVR sync after restore, offer once more
    # Skip modal dialogs on tvOS — they crash Kodi during addon startup
    if not _is_tvos():
        _check_pvr_startup_retry()

    # Post-restore profile load: if restore just completed, prompt to load profiles
    if not _is_tvos():
        _prompt_load_profiles_after_restore()

    # Health check: re-enable pvr.iptvsimple if it was left disabled by a crash
    # or power loss during _sync_pvr_force() — but only when no pending PVR sync
    # exists. If a deferred or retry flag is set, pvr.iptvsimple must stay
    # disabled until _sync_pvr_force() regenerates M3U/XMLTV and re-enables it,
    # otherwise PVR Manager loads an empty channel list from missing files.
    try:
        resp = json.loads(
            xbmc.executeJSONRPC(
                '{"jsonrpc":"2.0","method":"Addons.GetAddonDetails","params":{"addonid":"pvr.iptvsimple","properties":["enabled"]},"id":1}'
            )
        )
        enabled = resp.get("result", {}).get("addon", {}).get("enabled", False)
        if not enabled:
            _pending_sync = (
                os.path.exists(os.path.join(profile_path, "pvr_sync_after_profile_load.flag"))
                or os.path.exists(os.path.join(profile_path, "pvr_sync_retry_needed.flag"))
            )
            if _pending_sync:
                _log("PVR is disabled but sync is pending — skipping auto-enable until sync regenerates files")
            else:
                xbmc.executeJSONRPC(
                    '{"jsonrpc":"2.0","method":"Addons.SetAddonEnabled","params":{"addonid":"pvr.iptvsimple","enabled":true},"id":1}'
                )
                _log("PVR was disabled, enabled it on launch")
        else:
            _log("PVR already enabled on launch")
    except Exception as e:
        _log(f"PVR load on launch error: {e}")

    # Deferred buffer fix restart prompt (tvOS-safe)
    if addon.getSetting("buffer_fix_needs_restart") == "true":
        addon.setSetting("buffer_fix_needs_restart", "")
        if not _is_tvos():
            try:
                if xbmcgui.Dialog().yesno(_t(30726), _t(30792)):
                    _restart_or_prompt()
            except Exception as e:
                _log(f"Buffer restart prompt error: {e}")

    # Run network checks in background with pinned profile snapshot.
    _profile_snapshot = pm.active

    def _bg_startup_checks():
        # Pin profile to snapshot to avoid race with main-thread profile switch.
        try:
            if pm.active != _profile_snapshot:
                _log(
                    f"startup checks: profile changed ({_profile_snapshot}->{pm.active}), pinning to snapshot"
                )
                pm.active = _profile_snapshot
        except Exception as e:
            _log(f"startup checks profile pin warning: {e}")
        try:
            try:
                _check_account_expiry()
            except Exception as e:
                _log(f"Account expiry check error: {e}")
            try:
                from updater import silent_check_on_startup

                silent_check_on_startup()
            except Exception as e:
                _log(f"Update check error: {e}")
        finally:
            try:
                if pm.active != _profile_snapshot:
                    pm.active = _profile_snapshot
            except Exception as e:
                _log(f"startup checks profile restore warning: {e}")

    # Skip background network thread on tvOS — daemon threads + network can crash
    if not _is_tvos():
        t = threading.Thread(target=_bg_startup_checks, name="XStreamStartupChecks")
        t.daemon = True
        t.start()
    else:
        _log("tvOS detected: skipping background startup network checks")
    try:
        current_version = addon.getAddonInfo("version")
        if addon.getSetting("current_version_display") != current_version:
            addon.setSetting("current_version_display", current_version)
    except Exception as e:
        _log(f"Version display update error: {e}")

    # Show changelog on first launch after update
    # Skip on tvOS — textviewer dialog during startup can crash Kodi
    if not _is_tvos():
        try:
            if addon.getSetting("show_changelog_on_update") != "false":
                current_version = addon.getAddonInfo("version")
                last_seen = addon.getSetting("last_seen_version") or "0.0.0"
                if current_version != last_seen:
                    changelog_path = os.path.join(
                        xbmcvfs.translatePath(addon.getAddonInfo("path")), "CHANGELOG.md"
                    )
                    shown = False
                    try:
                        with open(changelog_path, "r", encoding="utf-8") as f:
                            changelog = f.read().strip()
                        if changelog:
                            xbmcgui.Dialog().textviewer(
                                _t(30340, current_version), changelog
                            )
                            shown = True
                    except Exception as e:
                        _log(f"changelog read error: {e}")
                    if shown:
                        addon.setSetting("last_seen_version", current_version)
        except Exception as e:
            _log(f"Changelog display error: {e}")
    else:
        _log("tvOS detected: skipping changelog display")

    # Obsolete: pvr_reload_after_restore.flag prompt removed.
    # Restore now triggers PVR sync immediately via _finalize_restore().


_M3U_MOVIE_WORDS = (
    "movie", "movies", "film", "films", "vod", "cinema", "4k movies",
)
_M3U_SERIES_WORDS = (
    "series", "tv show", "tv shows", "show", "shows", "episode", "episodes", "season",
)
_M3U_LIVE_WORDS = (
    "live", "tv", "channel", "channels", "news", "sport", "sports", "radio",
)


def _classify_m3u_channel(ch):
    group = (ch.get("group") or "").lower()
    name = (ch.get("name") or "").lower()
    url = (ch.get("url") or "").lower()
    haystack = " ".join((group, name, url))
    if re.search(r"\bS\d{1,2}E\d{1,2}\b", name, re.IGNORECASE):
        return "series"
    if re.search(r"\b\d{1,2}x\d{1,2}\b", name, re.IGNORECASE):
        return "series"
    if re.search(r"\bseason\s*\d+\b|\bepisode\s*\d+\b", name, re.IGNORECASE):
        return "series"
    if any(word in haystack for word in _M3U_SERIES_WORDS):
        return "series"
    if any(word in haystack for word in _M3U_MOVIE_WORDS):
        return "movie"
    if any(word in haystack for word in _M3U_LIVE_WORDS):
        return "live"
    return "live"


def _m3u_simple_menu(profile_num):
    pnum = profile_num
    creds = _get_credentials_for_profile(pnum)
    channels = _get_cached_m3u_channels(creds.get("m3u_url", ""))
    counts = {"live": 0, "movie": 0, "series": 0}
    for ch in channels:
        counts[_classify_m3u_channel(ch)] += 1
    items = [
        (_t(30002), "live", "DefaultAddonPVRClient.png"),
        (_t(30004), "movie", "DefaultMovies.png"),
        (_t(30005), "series", "DefaultTVShows.png"),
        (_t(30771), "", "DefaultVideo.png"),
    ]
    for label, stype, icon in items:
        count = len(channels) if not stype else counts.get(stype, 0)
        if count <= 0:
            continue
        li = xbmcgui.ListItem(label=f"{label} ({count})")
        li.setArt({"icon": icon})
        q = {
            "mode": "m3u_simple_section",
            "pnum": pnum,
            "stype": stype,
            "page": 1,
        }
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=True
        )
    xbmcplugin.endOfDirectory(addon_handle)


def _show_all_m3u_channels(profile_num, page=1, stype_filter=""):
    """Show all M3U channels directly without categories (for simple M3U without creds), with pagination."""
    pnum = profile_num
    creds = _get_credentials_for_profile(pnum)
    m3u_url = creds.get("m3u_url", "")

    if not m3u_url:
        return

    channels = _get_cached_m3u_channels(m3u_url)

    hidden_subcats = _get_hidden_subcats("live", pnum)
    hidden_items = _get_hidden_items("live", pnum)
    channels = [
        ch
        for ch in channels
        if (ch.get("group") or "General") not in hidden_subcats
        and str(ch.get("tvg_id") or ch.get("url") or ch.get("name", ""))
        not in hidden_items
    ]

    if stype_filter:
        channels = [
            ch for ch in channels
            if _classify_m3u_channel(ch) == stype_filter
        ]

    per_page = _get_pagination_limit("m3u_classic")
    if per_page == 0:
        per_page = len(channels)
    total = len(channels)
    start = (page - 1) * per_page
    end = start + per_page
    page_channels = channels[start:end]

    if stype_filter == "movie":
        xbmcplugin.setContent(addon_handle, "movies")
    elif stype_filter == "series":
        xbmcplugin.setContent(addon_handle, "episodes")
    else:
        xbmcplugin.setContent(addon_handle, "livetv")

    for ch in page_channels:
        name = ch.get("name", "Unknown")
        url = ch.get("url", "")
        icon = ch.get("logo", "") or "DefaultVideo.png"
        tvg_id = ch.get("tvg_id") or name
        item_id = str(ch.get("tvg_id") or ch.get("url") or ch.get("name", ""))

        li = xbmcgui.ListItem(label=name)
        li.setArt({"icon": icon})
        li.setInfo("video", {"title": name})
        li.setProperty("IsPlayable", "true")
        item_stype = stype_filter or _classify_m3u_channel(ch)
        if item_stype == "live":
            li.setProperty("IsLiveTV", "1")
        _prepare_playback_item(li)
        ctx = _build_favorite_ctx(
            item_id, name, item_stype, icon, url,
            epg_id=tvg_id, profile_num=pnum
        )
        li.addContextMenuItems(ctx)

        q = {
            "mode": "play_stream",
            "url": url,
            "name": name,
            "icon": icon,
            "profile_num": pnum,
            "stype": item_stype,
        }
        kodi_props = ch.get("kodi_props") or {}
        stream_ua = ch.get("user_agent", "")
        stream_ref = ch.get("referrer", "")
        if kodi_props:
            import json as _json
            q["kodi_props"] = _json.dumps(kodi_props)
        if stream_ua:
            q["stream_ua"] = stream_ua
        if stream_ref:
            q["stream_ref"] = stream_ref
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
        )

    if end < total:
        li = xbmcgui.ListItem(
            label="[COLOR yellow]{0} ({1})[/COLOR]".format(_t(30232), page + 1)
        )
        art = {"icon": "DefaultFolder.png"}
        _fanart = _addon_fanart()
        if _fanart:
            art["fanart"] = _fanart
        li.setArt(art)
        q = {
            "mode": "m3u_simple_section" if stype_filter else "m3u_all_channels",
            "pnum": pnum,
            "stype": stype_filter,
            "page": page + 1,
        }
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=True
        )

    xbmcplugin.endOfDirectory(addon_handle)


def profile_menu(pnum):
    """Show sub-menu for a specific profile (TV Classic, Movies, Series, etc)."""
    _log(f"Opening profile menu for profile {pnum}")

    profile_name = addon.getSetting(f"profile_{pnum}_name") or _t(30390, pnum)

    if not _prompt_profile_credentials(pnum):
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
        return

    # Temporarily set pm.active for credential loading
    original_active = pm.active
    pm.active = str(pnum)

    try:
        creds = pm.get_credentials()
        load_live = pm.get_profile_setting("load_live") != "false"
        load_movies = pm.get_profile_setting("load_movies") != "false"
        load_series = pm.get_profile_setting("load_series") != "false"
        has_xtream = bool(creds.get("xtream_url"))
        has_m3u_with_creds = bool(creds.get("m3u_url")) and _m3u_has_credentials(
            creds.get("m3u_url")
        )
        has_m3u_simple = bool(creds.get("m3u_url")) and not has_m3u_with_creds
        has_live = (has_xtream or has_m3u_with_creds or has_m3u_simple) and load_live
        has_movies = (has_xtream or has_m3u_with_creds) and load_movies
        has_series = (has_xtream or has_m3u_with_creds) and load_series
        has_replay = (has_xtream or has_m3u_with_creds) and load_live

        # M3U without creds - special layout: Favs, Recent, Search at top, then all channels
        if has_m3u_simple and load_live:
            # Top: Search
            li = xbmcgui.ListItem(label="[COLOR yellow]{0}[/COLOR]".format(_t(30007)))
            li.setArt({"icon": _local_icon("search")})
            xbmcplugin.addDirectoryItem(
                handle=addon_handle,
                url=build_url({"mode": "search_global", "profile_num": pnum}),
                listitem=li,
                isFolder=True,
            )

            # Then: All M3U channels directly
            _m3u_simple_menu(pnum)
            return

        # Xtream or M3U with creds - standard category layout
        _pmenu_visible = set(pm.get_visible_categories())
        items = []
        if has_live and _profile_section_visible(_pmenu_visible, pnum, "live"):
            items.append(
                (
                    _t(30002),
                    {"mode": "live_menu", "profile_num": pnum},
                    _local_icon("live_tv_pvr"),
                    True,
                )
            )
        if has_movies and _profile_section_visible(_pmenu_visible, pnum, "movies"):
            items.append(
                (
                    _t(30004),
                    {"mode": "movies_menu", "profile_num": pnum},
                    _local_icon("movies"),
                    True,
                )
            )
        if has_series and _profile_section_visible(_pmenu_visible, pnum, "series"):
            items.append(
                (
                    _t(30005),
                    {"mode": "series_menu", "profile_num": pnum},
                    _local_icon("tv_shows"),
                    True,
                )
            )
        if has_replay and _profile_section_visible(_pmenu_visible, pnum, "replay"):
            items.append(
                (
                    _t(30006),
                    {"mode": "replay_menu", "profile_num": pnum},
                    _local_icon("in_progress"),
                    True,
                )
            )
        if _profile_section_visible(_pmenu_visible, pnum, "search"):
            items.append(
                (
                    _t(30007),
                    {"mode": "search_global", "profile_num": pnum},
                    _local_icon("search"),
                    True,
                )
            )

        for label, q, icon, is_folder in items:
            li = xbmcgui.ListItem(label=label)
            li.setArt({"icon": icon})
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url=build_url(q), listitem=li, isFolder=is_folder
            )
        xbmcplugin.endOfDirectory(addon_handle)
    except Exception as e:
        _log(f"profile_menu error for profile {pnum}: {e}")

        _log(f"Traceback: {traceback.format_exc()}")
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
    finally:
        pm.active = original_active


def manage_active_profiles():
    """Show dialog to enable/disable PVR and profiles from main menu."""
    try:
        dialog = xbmcgui.Dialog()
        changed = False

        while True:
            items = []
            states = []

            # PVR item at the top
            pvr_path = _pvr_m3u_path()
            pvr_loaded = _m3u_file_has_channels(pvr_path)
            pvr_status = (
                f"[COLOR green]{_t(30786)}[/COLOR]"
                if pvr_loaded
                else f"[COLOR red]{_t(30787)}[/COLOR]"
            )
            pvr_label = f"{_t(30001)} ({_t(30390, _get_pvr_profile_num())})"
            items.append(f"{pvr_status} {pvr_label}")
            states.append(pvr_loaded)

            for n in range(1, 11):
                name = addon.getSetting(f"profile_{n}_name") or _t(30390, n)
                label = (
                    _t(31400, n, name) if name != _t(30390, n) else _t(30390, n)
                )
                has_creds = bool(
                    addon.getSetting(f"profile_{n}_xtream_url")
                    or addon.getSetting(f"profile_{n}_m3u")
                )
                if not has_creds:
                    items.append(f"[COLOR grey]{label} ({_t(30864)})[/COLOR]")
                    states.append(None)
                    continue
                enabled = addon.getSetting(f"profile_{n}_enabled") == "true"
                status = (
                    "[COLOR green]ON[/COLOR]" if enabled else "[COLOR red]OFF[/COLOR]"
                )
                items.append(f"{status} {label}")
                states.append(enabled)

            items.append("[COLOR yellow]{0}[/COLOR]".format(_t(30158)))

            choice = dialog.select(_t(30740), items)
            if choice == -1 or choice >= len(items) - 1:
                break

            # choice 0 = PVR
            if choice == 0:
                if states[0]:
                    # PVR is loaded — offer unload
                    unload_now = dialog.yesno(
                        _t(30770), _t(30894), nolabel=_t(30163), yeslabel=_t(30162)
                    )
                    if unload_now:
                        try:
                            _unload_pvr()
                        except Exception as e:
                            _log(f"PVR unload error: {e}")
                        dialog.notification("Xstream Pro", _t(30896))
                else:
                    # PVR is unloaded — offer load
                    load_now = dialog.yesno(
                        _t(30770), _t(30897), nolabel=_t(30403), yeslabel=_t(30162)
                    )
                    if load_now:
                        try:
                            _sync_pvr_force()
                        except Exception as e:
                            _log(f"PVR load error: {e}")
                        dialog.notification("Xstream Pro", _t(30878))
                continue

            # Profiles (shifted by +1 because PVR is at index 0)
            profile_num = choice
            if states[choice] is None:
                dialog.notification("Xstream Pro", _t(30893))
                continue

            current = addon.getSetting(f"profile_{profile_num}_enabled") == "true"
            new_state = "false" if current else "true"
            addon.setSetting(f"profile_{profile_num}_enabled", new_state)
            changed = True

            # If profile was enabled, auto-show it on main menu and ask to refresh data
            if new_state == "true":
                visible_cats = set(pm.get_visible_categories())
                if f"profile_{profile_num}" not in visible_cats:
                    visible_cats.add(f"profile_{profile_num}")
                    pm.set_visible_categories(list(visible_cats))
                name = addon.getSetting(f"profile_{profile_num}_name") or _t(
                    30390, profile_num
                )
                refresh_now = dialog.yesno(
                    _t(30770), _t(30748, name), nolabel=_t(30403), yeslabel=_t(30162)
                )
                if refresh_now:
                    _refresh_profile_data(profile_num)
            else:
                # Profile was disabled - ask to unload cached data
                name = addon.getSetting(f"profile_{profile_num}_name") or _t(
                    30390, profile_num
                )
                unload_now = dialog.yesno(
                    _t(30770), _t(30809, name), nolabel=_t(30163), yeslabel=_t(30162)
                )
                if unload_now:
                    count = _purge_profile_data(profile_num)
                    _log(f"Cleared {count} files for profile {profile_num}")

        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
        if changed:
            xbmc.executebuiltin(
                "Container.Update(plugin://plugin.video.xstream-pro/)"
            )
    except Exception as e:
        _log(f"manage_active_profiles error: {e}")
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)


_MAIN_MENU_CUSTOMIZED_KEY = "main_menu_customized"
_MAIN_MENU_MANAGED_KEYS = [
    "live_pvr",
    "pvr_replay",
    "guide",
    "sports",
    "tmdb_movies",
    "tmdb_tvshows",
    "tmdb_animations",
    "tmdb_people",
    "tmdb_lists",
    "tmdb_randomised",
    "tmdb_discover",
    "tmdb_trakt",
    "tmdb_search",
    "search_global",
    "favorites",
    "downloads",
] + ["profile_{0}".format(n) for n in range(1, 11)]

def _main_menu_item_visible(visible, key):
    if key in ("main_tools", "tools"):
        return True
    customized = _MAIN_MENU_CUSTOMIZED_KEY in visible
    if not customized:
        if key in ("live_pvr", "pvr_replay", "guide"):
            return key in visible
        return True
    return key in visible

def _main_menu_available_item_labels():
    items = []

    pvr_creds = _get_pvr_credentials()
    pvr_has_live = bool(pvr_creds.get("xtream_url") or pvr_creds.get("m3u_url"))
    pvr_profile_num = _get_pvr_profile_num()
    pvr_m3u_url = pvr_creds.get("m3u_url", "")

    pvr_has_replay = (
        addon.getSetting(f"profile_{pvr_profile_num}_load_live") != "false"
        and (
            bool(pvr_creds.get("xtream_url"))
            or (bool(pvr_m3u_url) and _m3u_has_credentials(pvr_m3u_url))
        )
    )

    if pvr_has_live:
        items.append(("live_pvr", _pvr_numbered_label("Live TV - PVR", pvr_profile_num)))
        if pvr_has_replay:
            items.append(("pvr_replay", _t(30006)))
        items.append(("guide", _t(30901)))
        items.append(("sports", "Sports"))

    tmdb_enabled = addon.getSetting("tmdb_enabled").lower() != "false"

    if tmdb_enabled:
        items.extend([
            ("tmdb_movies", _t(30902)),
            ("tmdb_tvshows", _t(30903)),
            ("tmdb_animations", _t(31238)),
            ("tmdb_people", _t(30930)),
            ("tmdb_lists", _t(31239)),
            ("tmdb_randomised", _t(30904)),
            ("tmdb_discover", _t(31111)),
            ("tmdb_trakt", _t(30906)),
            ("tmdb_search", _t(30907)),
        ])
    else:
        has_search = False

        for n in range(1, 11):
            if addon.getSetting("profile_{0}_enabled".format(n)) == "true":
                name = addon.getSetting("profile_{0}_name".format(n)) or _t(30390, n)
                items.append(("profile_{0}".format(n), name))

            if (
                addon.getSetting("profile_{0}_xtream_url".format(n))
                or addon.getSetting("profile_{0}_m3u".format(n))
            ):
                has_search = True

        if has_search:
            items.append(("search_global", _t(30889)))

    items.extend([
        ("favorites", _t(30009)),
        ("downloads", _t(30940)),
    ])

    return items


def manage_main_menu_items():
    dialog = xbmcgui.Dialog()
    available = _main_menu_available_item_labels()

    if not available:
        dialog.notification("Xstream Pro", _t(31229))
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
        return

    visible = set(pm.get_visible_categories())
    labels = [label for _key, label in available]

    preselect = [
        idx
        for idx, (key, _label) in enumerate(available)
        if _main_menu_item_visible(visible, key)
    ]

    result = dialog.multiselect(_t(31388), labels, preselect=preselect)

    if result is None:
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
        return

    selected = {available[idx][0] for idx in result}
    updated = visible - set(_MAIN_MENU_MANAGED_KEYS) - {_MAIN_MENU_CUSTOMIZED_KEY}
    updated.update(selected)
    updated.update({_MAIN_MENU_CUSTOMIZED_KEY, "tools", "main_tools"})

    pm.set_visible_categories(list(updated))
    dialog.notification("Xstream Pro", _t(31389))
    xbmc.executebuiltin("Container.Update(plugin://plugin.video.xstream-pro/)")
    xbmcplugin.endOfDirectory(addon_handle, succeeded=False)


def main_menu():
    global _NO_CREDS_NOTIFICATION_SHOWN

    _log("Opening main menu")

    if (
        addon.getSetting("pvr_favorites_enabled").lower() == "true"
        and _session_should_run("pvr_favs_startup", 300)
    ):
        try:
            threading.Thread(target=_safe_sync_pvr_favorites_startup, daemon=True).start()
        except Exception as e:
            _log(f"PVR Favorites startup sync error: {e}")

    if _session_should_run("startup_checks", 300):
        _run_startup_checks()

    if _session_should_run("zombie_download_cleanup"):
        try:
            mark_zombie_downloads_failed()
        except Exception as e:
            _log(f"Zombie download cleanup error: {e}")

    if (_get_setting_direct("buffer_fix_needs_restart", "false") or "").lower() == "true":
        addon.setSetting("buffer_fix_needs_restart", "false")
        xbmcgui.Dialog().ok("Xstream Pro", _t(30569))

    last_pvr = addon.getSetting("last_pvr_profile")
    current_pvr = addon.getSetting("active_pvr_profile") or "Profile 1"

    if last_pvr and current_pvr != last_pvr:
        restart = xbmcgui.Dialog().yesno(
            "Xstream Pro",
            _t(30570),
            yeslabel=_t(30165),
            nolabel=_t(30164),
        )
        if restart:
            _restart_or_prompt()

    if current_pvr != last_pvr:
        addon.setSetting("last_pvr_profile", current_pvr)

    visible = set(pm.get_visible_categories())
    visible.update(("tools", "main_tools"))
    visible.add("user_playlists")
    if addon.getSetting("sports_enabled").lower() != "false":
        visible.add("sports")
    else:
        visible.discard("sports")

    items_dict = {}

    pvr_creds = _get_pvr_credentials()
    pvr_has_live = bool(pvr_creds.get("xtream_url") or pvr_creds.get("m3u_url"))
    pvr_profile_num = _get_pvr_profile_num()
    pvr_m3u_url = pvr_creds.get("m3u_url", "")

    pvr_has_replay = (
        addon.getSetting(f"profile_{pvr_profile_num}_load_live") != "false"
        and (
            bool(pvr_creds.get("xtream_url"))
            or (bool(pvr_m3u_url) and _m3u_has_credentials(pvr_m3u_url))
        )
    )

    if "live_pvr" in visible and pvr_has_live:
        items_dict["live_pvr"] = (
            _pvr_numbered_label("Live TV - PVR", pvr_profile_num),
            {"mode": "open_pvr"},
            _local_icon("live_tv_pvr"),
            False,
        )

    if "pvr_replay" in visible and pvr_has_replay:
        items_dict["pvr_replay"] = (
            _t(30006),
            {"mode": "replay_menu", "profile_num": pvr_profile_num},
            _local_icon("in_progress"),
            True,
        )

    if "guide" in visible and pvr_has_live:
        items_dict["guide"] = (
            _t(30901),
            {"mode": "open_pvr_guide"},
            _local_icon("guide"),
            False,
        )

    if "sports" in visible and pvr_has_live:
        sports_icon = _local_icon("sports")

        if not os.path.exists(
            os.path.join(
                addon.getAddonInfo("path"),
                "resources",
                "icons",
                "sports.png",
            )
        ):
            sports_icon = _local_icon("live_tv_pvr")

        items_dict["sports"] = (
            "Sports",
            {"mode": "sports_menu"},
            sports_icon,
            True,
        )

    tmdb_enabled = addon.getSetting("tmdb_enabled").lower() != "false"

    if tmdb_enabled:
        items_dict["tmdb_movies"] = (
            _t(30902),
            {"mode": "xstream_tmdb_movies_menu"},
            _local_icon("movies"),
            True,
        )
        items_dict["tmdb_tvshows"] = (
            _t(30903),
            {"mode": "xstream_tmdb_tvshows_menu"},
            _local_icon("tv_shows"),
            True,
        )
        items_dict["tmdb_animations"] = (
            _t(31238),
            {"mode": "xstream_tmdb_animation_menu"},
            _local_icon("animation"),
            True,
        )
        items_dict["tmdb_people"] = (
            _t(30930),
            {"mode": "xstream_tmdb_people_menu"},
            _local_icon("people"),
            True,
        )
        items_dict["tmdb_lists"] = (
            _t(31239),
            {"mode": "xstream_tmdb_lists_menu"},
            _local_icon("lists"),
            True,
        )
        items_dict["tmdb_randomised"] = (
            _t(30904),
            {"mode": "xstream_tmdb_randomised_menu"},
            _local_icon("randomised"),
            True,
        )
        items_dict["tmdb_discover"] = (
            _t(31111),
            {"mode": "xstream_tmdb_discover_menu"},
            _local_icon("discover"),
            True,
        )
        items_dict["tmdb_trakt"] = (
            _t(30906),
            {"mode": "xstream_tmdb_trakt_menu"},
            _local_icon("trakt"),
            True,
        )
        items_dict["tmdb_search"] = (
            _t(30907),
            {"mode": "xstream_tmdb_search_menu"},
            _local_icon("search"),
            True,
        )
    else:
        for n in range(1, 11):
            if addon.getSetting(f"profile_{n}_enabled") == "true":
                name = addon.getSetting(f"profile_{n}_name") or _t(30390, n)
                items_dict[f"profile_{n}"] = (
                    name,
                    {"mode": "profile_menu", "pnum": n},
                    _local_icon("profiles"),
                    True,
                )

        has_search = any(
            bool(
                addon.getSetting(f"profile_{n}_xtream_url")
                or addon.getSetting(f"profile_{n}_m3u")
            )
            for n in range(1, 11)
        )

        if has_search:
            items_dict["search_global"] = (
                _t(30889),
                {"mode": "search_global"},
                _local_icon("search"),
                True,
            )

    items_dict["main_tools"] = (
        _t(30010),
        {"mode": "main_tools_menu"},
        _local_icon("tools"),
        True,
    )

    items_dict["favorites"] = (
        _t(30009),
        {"mode": "favorites_menu"},
        _local_icon("favorites"),
        True,
    )

    downloads_icon = "DefaultFolder.png"
    downloads_icon_path = os.path.join(
        addon.getAddonInfo("path"),
        "resources",
        "icons",
        "downloads.png",
    )

    if os.path.exists(downloads_icon_path):
        downloads_icon = _local_icon("downloads")

    items_dict["downloads"] = (
        _t(30940),
        {"mode": "downloads_manager"},
        downloads_icon,
        True,
    )

    default_order = ["live_pvr", "pvr_replay", "guide", "sports"]

    if tmdb_enabled:
        default_order.extend([
            "tmdb_movies",
            "tmdb_tvshows",
            "tmdb_animations",
            "tmdb_people",
            "tmdb_lists",
            "tmdb_randomised",
            "tmdb_discover",
            "tmdb_trakt",
            "tmdb_search",
        ])
    else:
        for n in range(1, 11):
            key = f"profile_{n}"
            if key in items_dict:
                default_order.append(key)

        if "search_global" in items_dict:
            default_order.append("search_global")

    default_order.extend(["favorites", "downloads", "main_tools"])

    items = [
        items_dict[key]
        for key in default_order
        if key in items_dict and _main_menu_item_visible(visible, key)
    ]

    fanart = _addon_fanart()
    directory_items = []

    for label, query, icon, is_folder in items:
        li = _new_listitem(label)
        li.setArt(_menu_art(icon, fanart))
        item_url = query if isinstance(query, str) else build_url(query)
        directory_items.append((item_url, li, is_folder))

    xbmcplugin.addDirectoryItems(addon_handle, directory_items)

    if not _NO_CREDS_NOTIFICATION_SHOWN and not _any_profile_has_credentials():
        _NO_CREDS_NOTIFICATION_SHOWN = True

        try:
            xbmcgui.Dialog().notification(
                "Xstream Pro",
                _t(31145),
                xbmcgui.NOTIFICATION_INFO,
                8000,
            )
        except Exception:
            pass

    xbmcplugin.endOfDirectory(addon_handle)
    _log_route_timing("main_menu")



def favorites_menu(folder=None, stype_filter=None):
    stype_labels = {
        "live": _t(30002),
        "movie": _t(30004),
        "series": _t(30005),
        "folder": _t(30808),
    }
    stype_icons = {
        "live": "DefaultAddonPVRClient.png",
        "movie": "DefaultMovies.png",
        "series": "DefaultTVShows.png",
        "folder": "DefaultFolder.png",
    }
    folders = fav.get_folders()
    protected_folders = {"Favorites", _t(30009) or "Favorites"}
    custom_folders = [f for f in folders if f not in protected_folders]

    show_counts = addon.getSetting("show_content_counts").lower() == "true"
    if folder is None and stype_filter is None:
        _fanart = _addon_fanart()
        for gname in custom_folders:
            count = len(fav.get_all(gname))
            if show_counts:
                label = f"{gname}  [COLOR gray]({count})[/COLOR]"
            else:
                label = gname
            li = xbmcgui.ListItem(label=label)
            li.setArt(_menu_art("DefaultFavourites.png", _fanart))
            ctx_items = [
                (
                    _t(30320),
                    f"RunPlugin({build_url({'mode': 'fav_rename_folder', 'folder': gname})})",
                ),
                (
                    _t(30325),
                    f"RunPlugin({build_url({'mode': 'export_favorites', 'folder': gname})})",
                ),
                (
                    _t(30321),
                    f"RunPlugin({build_url({'mode': 'fav_delete_folder', 'folder': gname})})",
                ),
            ]
            li.addContextMenuItems(ctx_items)
            q = {"mode": "favorites_menu", "folder": gname}
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url=build_url(q), listitem=li, isFolder=True
            )
        # + New Favorites Group
        li = xbmcgui.ListItem(label="[COLOR yellow]{0}[/COLOR]".format(_t(30210)))
        xbmcplugin.addDirectoryItem(
            handle=addon_handle,
            url=build_url({"mode": "fav_new_folder"}),
            listitem=li,
            isFolder=False,
        )
        xbmcplugin.endOfDirectory(addon_handle)
        return

    # Inside a custom group — show all items flat with visual type separators
    if folder and stype_filter is None:
        items = fav.get_all(folder)
        if not items:
            li = xbmcgui.ListItem(label="[COLOR gray]{0}[/COLOR]".format(_t(30211)))
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url="", listitem=li, isFolder=False
            )
            xbmcplugin.endOfDirectory(addon_handle)
            return

        type_order = ["live", "movie", "series", "folder"]
        items_by_type = {}
        for item in items:
            st = item.get("stype", "live")
            items_by_type.setdefault(st, []).append(item)

        show_separators = len(items_by_type) > 1
        for st in type_order:
            if st not in items_by_type:
                continue
            if show_separators:
                label = stype_labels.get(st, st)
                sep = xbmcgui.ListItem(
                    label=f"[COLOR gray]────── {label} ──────[/COLOR]"
                )
                sep.setArt({"icon": stype_icons.get(st, "DefaultFolder.png")})
                sep.setProperty("IsPlayable", "false")
                ctx = [
                    (
                        _t(30813).format(label),
                        f"RunPlugin({build_url({'mode': 'fav_remove_by_type', 'folder': folder, 'stype': st})})",
                    )
                ]
                sep.addContextMenuItems(ctx)
                xbmcplugin.addDirectoryItem(
                    handle=addon_handle, url="", listitem=sep, isFolder=False
                )
            _fav_render_items(items_by_type[st], source_folder=folder)

        xbmcplugin.endOfDirectory(addon_handle)
        return

    # Items in custom group filtered by stype (legacy direct links still supported)
    if folder and stype_filter:
        items = [i for i in fav.get_all(folder) if i.get("stype") == stype_filter]
        if not items:
            li = xbmcgui.ListItem(label="[COLOR gray]{0}[/COLOR]".format(_t(30212)))
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url="", listitem=li, isFolder=False
            )
            xbmcplugin.endOfDirectory(addon_handle)
            return
        _fav_render_items(items, source_folder=folder)
        xbmcplugin.endOfDirectory(addon_handle)


def _fav_render_items(items, source_folder=None, profile_num=None):
    """Render a list of favorite items with context menus."""
    if profile_num:
        fav_instance = _get_profile_fav(profile_num)
        remove_mode = "profile_fav_remove"
    else:
        fav_instance = fav
        remove_mode = "fav_remove"

    hide_adult = addon.getSetting("hide_adult_categories").lower() == "true"
    if hide_adult:
        items = [
            i
            for i in items
            if not _is_adult_category(
                i.get("name", "") + " " + i.get("category_name", "")
            )
        ]
    has_live = any(i.get("stype", "live") == "live" for i in items)
    epg = None
    if has_live and _epg_enabled():
        epg = EPG(addon, profile_num=profile_num or pm.active)
        epg.load()
    for item in items:
        stype = item.get("stype", "live")
        name = item.get("name", "Unknown")
        li = xbmcgui.ListItem(label=name)
        info_tag = li.getVideoInfoTag()
        info_tag.setMediaType("video")
        info_tag.setTitle(name)
        if item.get("icon"):
            li.setArt({"icon": item["icon"], "thumb": item["icon"]})
        if stype not in ("folder", "tmdb"):
            li.setProperty("IsPlayable", "true")
            _prepare_playback_item(li)
        plot = ""
        current_title = ""
        if stype == "live":
            _set_live_props(li)
            plot, display_name, current_title = (
                _make_epg_info(epg, item.get("epg_id", ""), name)
                if epg
                else ("", name, "")
            )
            info_tag = li.getVideoInfoTag()
            info_tag.setMediaType("video")
            info_tag.setTitle(current_title or name)
            info_tag.setPlot(plot)
        remove_folder = source_folder or "__all__"
        if profile_num:
            ctx = [
                (
                    _t(30545),
                    f"RunPlugin({build_url({'mode': remove_mode, 'id': item.get('id'), 'folder': remove_folder, 'pnum': profile_num})})",
                ),
            ]
        else:
            ctx = [
                (
                    _t(30545),
                    f"RunPlugin({build_url({'mode': remove_mode, 'id': item.get('id'), 'folder': remove_folder})})",
                ),
            ]
        if not profile_num:
            protected_folders = {"Favorites", _t(30009) or "Favorites"}
            for gname in fav_instance.get_folders():
                if gname in protected_folders:
                    continue
                ctx.append(
                    (
                        _t(30215).format(gname),
                        f"RunPlugin({build_url({'mode': 'toggle_fav', 'id': item.get('id'), 'name': name, 'stype': stype, 'icon': item.get('icon', ''), 'url': item.get('url', ''), 'epg_id': item.get('epg_id', ''), 'folder': gname})})",
                    )
                )
        li.addContextMenuItems(ctx)
        if stype == "live":
            q = {
                "mode": "play_stream",
                "url": item.get("url"),
                "name": name,
                "title": current_title or name,
                "plot": plot,
                "icon": item.get("icon", ""),
                "profile_num": profile_num or pm.active,
            }
            item_url = build_url(q)
        elif stype == "tmdb":
            item_url = item.get("url")
        else:
            item_url = item.get("url")
        xbmcplugin.addDirectoryItem(
            handle=addon_handle,
            url=item_url,
            listitem=li,
            isFolder=stype in ("series", "folder", "tmdb"),
        )


def toggle_favorite(
    item_id,
    name,
    stype,
    icon,
    url,
    epg_id="",
    folder=None,
    catchup="",
    catchup_source="",
    catchup_days="",
):
    if folder is None:
        folder = _t(30009) or "Favorites"
    item = {
        "id": item_id,
        "name": name,
        "stype": stype,
        "icon": icon,
        "url": url,
        "epg_id": epg_id,
        "catchup": catchup,
        "catchup_source": catchup_source,
        "catchup_days": catchup_days,
    }
    if fav.is_favorite(item_id, folder):
        fav.remove(item_id, folder)
        xbmcgui.Dialog().notification("Xstream Pro", _t(30085).format(folder))
        xbmc.executebuiltin("Container.Refresh")
        return
    fav.add(item, folder)
    xbmcgui.Dialog().notification("Xstream Pro", _t(30084).format(folder))
    xbmc.executebuiltin("Container.Refresh")




def _local_icon(name):
    cached = _ICON_CACHE.get(name)
    if cached:
        return cached
    try:
        icon_path = os.path.join(addon.getAddonInfo("path"), "resources", "icons", f"{name}.png")
        if os.path.exists(icon_path):
            _ICON_CACHE[name] = icon_path
            return icon_path
    except Exception:
        pass
    _ICON_CACHE[name] = "DefaultFolder.png"
    return "DefaultFolder.png"


def _menu_art(icon, fanart=None):
    icon = icon or "DefaultFolder.png"
    art = {
        "icon": icon,
        "thumb": icon,
        "poster": icon,
    }
    fanart = fanart if fanart is not None else _addon_fanart()
    if fanart:
        art["fanart"] = fanart
    return art


def _addon_fanart():
    try:
        custom = addon.getSetting("custom_background")
        if _FANART_CACHE.get("setting") == custom:
            return _FANART_CACHE.get("path", "")
        fanart = ""
        if custom == "__none__":
            # Explicitly cleared: let Kodi theme show through.
            fanart = ""
        elif custom == "__default__" or not custom:
            # Addon default background.
            fanart_path = os.path.join(
                addon.getAddonInfo("path"),
                "resources",
                "background",
                "background.png",
            )
            if os.path.exists(fanart_path):
                fanart = fanart_path
        else:
            # User-selected background.
            custom_path = xbmcvfs.translatePath(custom)
            if os.path.exists(custom_path):
                fanart = custom_path
            else:
                # If the saved custom file disappeared, fall back to addon default.
                fanart_path = os.path.join(
                    addon.getAddonInfo("path"),
                    "resources",
                    "background",
                    "background.png",
                )
                if os.path.exists(fanart_path):
                    fanart = fanart_path
        _FANART_CACHE["setting"] = custom
        _FANART_CACHE["path"] = fanart
        return fanart
    except Exception:
        return ""


def _clear_background():
    """Clear custom background so Kodi theme shows through."""
    try:
        addon.setSetting("custom_background", "__none__")
        _FANART_CACHE.clear()
        xbmc.executebuiltin("Container.Refresh")
        return True
    except Exception as e:
        _log("_clear_background error: {0}".format(e))
        return False


def _restore_background():
    """Restore addon default background."""
    try:
        addon.setSetting("custom_background", "__default__")
        _FANART_CACHE.clear()
        xbmc.executebuiltin("Container.Refresh")
        return True
    except Exception as e:
        _log("_restore_background error: {0}".format(e))
        return False
















def _normalize_tmdb_type(tmdb_type):
    raw = str(tmdb_type or "").lower()
    if raw in ("tv", "tvshow", "show", "season", "episode"):
        return "tv"
    if raw in ("movie", "movies"):
        return "movie"
    return ""












def _tmdb_source_select_url(title, year, tmdb_type, tmdb_id="", season="", episode="", intent="", showname=""):
    q = {
        "mode": "source_select",
        "title": title,
        "year": year,
        "tmdb_type": tmdb_type,
        "tmdb_id": tmdb_id,
    }
    if tmdb_type == "tv":
        q["showname"] = showname or title
    if season:
        q["season"] = season
    if episode:
        q["episode"] = episode
    if intent:
        q["intent"] = intent
    return build_url(q)


def _tmdb_source_action_url(title, year, tmdb_type, tmdb_id="", season="", episode="", intent="play", showname=""):
    q = {
        "mode": "tmdb_source_action",
        "title": title,
        "year": year,
        "tmdb_type": tmdb_type,
        "tmdb_id": tmdb_id,
        "intent": intent,
    }
    if showname:
        q["showname"] = showname
    if season:
        q["season"] = season
    if episode:
        q["episode"] = episode
    return build_url(q)


def _tmdb_series_action_url(showname, year, intent, tmdb_id="", season=""):
    q = {
        "mode": "tmdb_series_action",
        "showname": showname,
        "year": year,
        "intent": intent,
        "tmdb_id": tmdb_id,
    }
    if season:
        q["season"] = season
    return build_url(q)


def _tmdb_item_ctx(label, icon, year, tmdb_type, tmdb_id="", season="", episode="", showname="", kind="", collection_id="", is_watched=False):
    real_showname = showname or label
    is_tv_show_or_season = tmdb_type == "tv" and kind in ("tvshow", "season")
    fav_url = _tmdb_source_select_url(label, year, tmdb_type, tmdb_id, season, episode, showname=showname)
    fav_id = "tmdb:{0}:{1}:{2}:{3}".format(tmdb_type, tmdb_id or label, season, episode)
    ctx = []
    if is_tv_show_or_season:
        ctx.append((_t(31073), "RunPlugin({0})".format(_tmdb_series_action_url(real_showname, year, "play", tmdb_id, season if kind == "season" else ""))))
        ctx.append((_t(30941), "RunPlugin({0})".format(_tmdb_series_action_url(real_showname, year, "download", tmdb_id, season if kind == "season" else ""))))
    else:
        ctx.append((_t(31073), "RunPlugin({0})".format(_tmdb_source_action_url(label, year, tmdb_type, tmdb_id, season, episode, "play", showname))))
        ctx.append((_t(30941), "RunPlugin({0})".format(_tmdb_source_action_url(label, year, tmdb_type, tmdb_id, season, episode, "download", showname))))
    # Mark watched / unwatched (local addon state) — all types including tvshow/season
    _tw_url = build_url({
        "mode": "toggle_tmdb_watched",
        "tmdb_id": tmdb_id,
        "tmdb_type": tmdb_type,
        "season": season,
        "episode": episode,
    })
    _tw_label = _t(31082) if is_watched else _t(31081)
    ctx.append((_tw_label, "RunPlugin({0})".format(_tw_url)))
    # Trailer
    if tmdb_id:
        _trailer_url = build_url({
            "mode": "xstream_tmdb_trailer",
            "tmdb_type": tmdb_type,
            "tmdb_id": tmdb_id,
        })
        ctx.append((_t(31078), "RunPlugin({0})".format(_trailer_url)))
    # Similar
    if tmdb_id:
        _similar_url = build_url({
            "mode": "xstream_tmdb_similar",
            "tmdb_type": tmdb_type,
            "tmdb_id": tmdb_id,
            "relation": "similar",
        })
        ctx.append((_t(31079), "ActivateWindow(Videos,{0},return)".format(_similar_url)))
    # Seasons (TV shows)
    if tmdb_type == "tv" and tmdb_id and kind in ("tvshow", "season", ""):
        _fs_url = build_url({
            "mode": "xstream_tmdb_seasons",
            "tmdb_id": tmdb_id,
            "title": real_showname or label,
            "year": year,
        })
        ctx.append((_t(31110), "ActivateWindow(Videos,{0},return)".format(_fs_url)))    # Recommendations
    if tmdb_id:
        _rec_url = build_url({
            "mode": "xstream_tmdb_recommendations",
            "tmdb_type": tmdb_type,
            "tmdb_id": tmdb_id,
            "relation": "recommendations",
        })
        ctx.append((_t(31116), "ActivateWindow(Videos,{0},return)".format(_rec_url)))
    # Trakt Options
    if tmdb_id:
        _trakt_url = build_url({
            "mode": "xstream_trakt_item_action",
            "tmdb_type": tmdb_type,
            "tmdb_id": tmdb_id,
            "season": season,
            "episode": episode,
        })
        ctx.append((_t(31080), "RunPlugin({0})".format(_trakt_url)))
    # View Collection
    if tmdb_type == "movie" and tmdb_id:
        _coll_url = build_url({
            "mode": "xstream_tmdb_collection_from_movie",
            "tmdb_id": tmdb_id,
        })
        ctx.append((_t(31108), "ActivateWindow(Videos,{0},return)".format(_coll_url)))
    ctx.extend(_build_favorite_ctx(
        fav_id,
        label,
        "tmdb",
        icon,
        fav_url,
        include_download=False,
    ))
    return ctx


def _rebuild_provider_availability_cache(show_progress=False):
    """Build provider availability index from all enabled profiles' VOD/series data."""
    index = {"movie": {"titles": {}, "tmdb_ids": []}, "tv": {"titles": {}, "tmdb_ids": []}}
    if show_progress:
        progress = xbmcgui.DialogProgress()
        progress.create(_t(30012), "Building provider availability cache...")
    profiles = _enabled_profiles_with_credentials()
    total = len(profiles)
    try:
        for idx, (pnum, pname) in enumerate(profiles):
            if show_progress and progress.iscanceled():
                break
            if show_progress:
                pct = int(((idx + 1) / total) * 90) if total else 50
                progress.update(pct, "Scanning profile {0}/{1}...".format(idx + 1, total))
            creds = _get_credentials_for_profile(pnum)
            xt_url = creds.get("xtream_url", "")
            xt_user = creds.get("xtream_username", "")
            xt_pwd = creds.get("xtream_password", "")
            m3u_url = creds.get("m3u_url", "")
            original_active = pm.active
            pm.active = str(pnum)
            try:
                if xt_url and xt_user and xt_pwd:
                    for item in _get_cached_xtream_streams(xt_url, xt_user, xt_pwd, "movie"):
                        name = item.get("name", "")
                        _provider_availability_add(index, "movie", name, item)
                        for s in (item.get("streams") or []):
                            _provider_availability_add(index, "movie", s.get("name", name), s)
                    for item in _get_cached_xtream_streams(xt_url, xt_user, xt_pwd, "series"):
                        name = item.get("name", "")
                        _provider_availability_add(index, "tv", name, item)
                if m3u_url and not _m3u_has_credentials(m3u_url):
                    for ch in _get_cached_m3u_channels(m3u_url):
                        group = (ch.get("group") or "").lower()
                        stype = "tv" if any(kw in group for kw in ("series", "tvshow", "show")) else "movie"
                        _provider_availability_add(index, stype, ch.get("name", ""), ch)
            finally:
                pm.active = original_active
        # Deduplicate tmdb_ids
        for media_type in ("movie", "tv"):
            ids = index.get(media_type, {}).get("tmdb_ids", [])
            seen = set()
            deduped = []
            for id_val in ids:
                if id_val not in seen:
                    seen.add(id_val)
                    deduped.append(id_val)
            index.setdefault(media_type, {})["tmdb_ids"] = deduped
        _save_provider_availability_cache(index)
        return index
    finally:
        if show_progress:
            try:
                progress.update(100, "Done")
                xbmc.sleep(200)
            except Exception:
                pass
            try:
                progress.close()
            except Exception:
                pass


def _provider_item_available_for_tmdb(item, strict_title_year=False):
    cache = _load_provider_availability_cache()
    if not cache:
        cache = _rebuild_provider_availability_cache(show_progress=False)
    if not cache:
        return True
    return _provider_availability_item_available(item, cache, strict_title_year=strict_title_year)


def _refresh_provider_availability_cache_after_profile_load(show_progress=False):
    if not _provider_only_tmdb_enabled():
        return
    try:
        return _rebuild_provider_availability_cache(show_progress=show_progress)
    except Exception as e:
        _log("Provider availability cache rebuild warning: {0}".format(e))
        _log("Traceback: {0}".format(traceback.format_exc()))


def _setup_tmdb_routes():
    global _tmdb_routes, _tmdb_routes_setup
    if not _tmdb_routes_setup or not _tmdb_routes:
        try:
            import tmdb_routes as _tmdb_routes_module
            _tmdb_routes = _tmdb_routes_module
            _tmdb_routes_setup = _tmdb_routes_module.setup
        except Exception as e:
            _log("TMDB routes import failed: {0}".format(e))
            _log("Traceback: {0}".format(traceback.format_exc()))
            xbmcgui.Dialog().notification("Xstream Pro", _t(31146))
            li = _new_listitem(_t(31146))
            li.setArt(_menu_art(_local_icon("themoviedb")))
            xbmcplugin.addDirectoryItem(addon_handle, "", li, False)
            xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
            return False

    if not _tmdb_routes_setup or not _tmdb_routes:
        xbmcgui.Dialog().notification("Xstream Pro", _t(31146))
        li = _new_listitem(_t(31146))
        li.setArt(_menu_art(_local_icon("themoviedb")))
        xbmcplugin.addDirectoryItem(addon_handle, "", li, False)
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
        return False
    _tmdb_routes_setup({
        "addon": addon,
        "addon_handle": addon_handle,
        "build_url": build_url,
        "_t": _t,
        "_local_icon": _local_icon,
        "_addon_fanart": _addon_fanart,
        "_menu_art": _menu_art,
        "_new_listitem": _new_listitem,
        "_set_tmdb_item_info": _set_tmdb_item_info,
        "_set_tmdb_content": _set_tmdb_content,
        "_tmdb_source_select_url": _tmdb_source_select_url,
        "_tmdb_item_ctx": _tmdb_item_ctx,
        "_filter_provider_available_items": _filter_provider_available_items,
        "_filter_provider_available_items_strict": _filter_provider_available_items_strict,
        "_provider_item_available_for_tmdb": _provider_item_available_for_tmdb,
        "_filter_provider_seasons": _filter_provider_seasons,
        "_filter_provider_episodes": _filter_provider_episodes,
        "_folder_favorite_ctx": _folder_favorite_ctx,
        "WatchedMovies": WatchedMovies,
        "WatchedEpisodes": WatchedEpisodes,
        "WatchHistory": WatchHistory,
        "ResumePoints": ResumePoints,
        "pm": pm,
        "_log": _log,
        "_addon_profile": lambda: xbmcvfs.translatePath(addon.getAddonInfo("profile")),
        "_log_route_timing": _log_route_timing,
    })
    return True


_sports_results_routes = None
_sports_results_routes_setup = None

def _setup_sports_results_routes():
    global _sports_results_routes, _sports_results_routes_setup

    if not _sports_results_routes_setup or not _sports_results_routes:
        try:
            import sports_results as _sports_results_routes_module
            _sports_results_routes = _sports_results_routes_module
            _sports_results_routes_setup = _sports_results_routes_module.setup
        except Exception as e:
            _log("Sports results routes import failed: {0}".format(e))
            _log("Traceback: {0}".format(traceback.format_exc()))
            xbmcgui.Dialog().notification("Xstream Pro", "Sports results failed to load")
            li = _new_listitem("Sports results failed to load")
            xbmcplugin.addDirectoryItem(addon_handle, "", li, False)
            xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
            return False

    _sports_results_routes_setup({
        "addon": addon,
        "addon_handle": addon_handle,
        "build_url": build_url,
        "_t": _t,
        "_new_listitem": _new_listitem,
        "_local_icon": _local_icon,
        "_addon_fanart": _addon_fanart,
        "_menu_art": _menu_art,
        "_log": _log,
        "_log_route_timing": _log_route_timing,
        "_tvos_sleep": _tvos_sleep,
    })
    return True


def _setup_sports_routes():
    global _sports_routes, _sports_routes_setup
    if _sports_routes_setup:
        return True
    try:
        import sports as routes
        routes.setup({
            "addon": addon,
            "addon_handle": addon_handle,
            "build_url": build_url,
            "_t": _t,
            "_log": _log,
            "_log_route_timing": _log_route_timing,
            "_new_listitem": _new_listitem,
            "_local_icon": _local_icon,
            "_menu_art": _menu_art,
            "_addon_fanart": _addon_fanart,
            "_get_pvr_profile_num": _get_pvr_profile_num,
            "_pvr_numbered_label": _pvr_numbered_label,
            "_get_sports_pvr_profile_num": _get_sports_pvr_profile_num,
            "_sports_pvr_numbered_label": _sports_pvr_numbered_label,
            "_prompt_profile_credentials": _prompt_profile_credentials,
            "_enabled_profiles_with_credentials": _enabled_profiles_with_credentials,
            "_get_credentials_for_profile": _get_credentials_for_profile,
            "_call_with_active_profile": _call_with_active_profile,
            "_get_cached_xtream_categories": _get_cached_xtream_categories,
            "_get_cached_xtream_streams": _get_cached_xtream_streams,
            "_get_cached_m3u_channels": _get_cached_m3u_channels,
            "refresh_profile_menu": refresh_profile_menu,
            "_get_hidden_subcats": _get_hidden_subcats,
            "_get_hidden_items": _get_hidden_items,
            "_is_adult_category": _is_adult_category,
            "_truthy_catchup": _truthy_catchup,
            "_xtream_timeshift_catchup_source": _xtream_timeshift_catchup_source,
            "EPG": EPG,
            "_make_epg_info": _make_epg_info,
            "_prepare_playback_item": _prepare_playback_item,
            "_set_live_props": _set_live_props,
            "_build_favorite_ctx": _build_favorite_ctx,
            "_switch_pvr_instance_scope": _switch_pvr_instance_scope,
            "_sports_pvr_ready": _sports_pvr_ready,
            "_sync_sports_pvr_safe": _sync_sports_pvr_safe,
            "_tvos_sleep": _tvos_sleep,
            "_sports_pvr_m3u_path": _sports_pvr_m3u_path,
            "_sports_pvr_favs_m3u_path": _sports_pvr_favs_m3u_path,
            "_m3u_file_has_channels": _m3u_file_has_channels,
            "_profile_has_data": _profile_has_data,
        })
        _sports_routes = routes
        _sports_routes_setup = True
        return True
    except Exception as exc:
        _log("Sports route setup failed: {0}".format(exc))
        return False




def user_playlists_menu():
    """Submenu containing profiles, search, PVR favorites, and tools."""
    _log("Opening user playlists menu")
    visible = set(pm.get_visible_categories())
    # Safety: tools must always be visible so users can't lock themselves out
    visible.add("tools")

    active_profiles = []
    enabled_profiles = []
    profile_names = {}
    for n in range(1, 11):
        if addon.getSetting(f"profile_{n}_enabled") == "true":
            enabled_profiles.append(n)
            profile_names[n] = addon.getSetting(f"profile_{n}_name") or _t(30390, n)
            has_xtream = bool(addon.getSetting(f"profile_{n}_xtream_url"))
            has_m3u = bool(addon.getSetting(f"profile_{n}_m3u"))
            if has_xtream or has_m3u:
                active_profiles.append(n)

    has_search = len(active_profiles) > 0

    pvr_creds = _get_pvr_credentials()
    pvr_has_live = bool(pvr_creds.get("xtream_url") or pvr_creds.get("m3u_url"))

    items = []

    # Enabled profiles as folders
    for n in enabled_profiles:
        section_keys = {
            f"profile_{n}_live",
            f"profile_{n}_movies",
            f"profile_{n}_series",
            f"profile_{n}_replay",
            f"profile_{n}_favs",
            f"profile_{n}_recent",
            f"profile_{n}_search",
        }
        any_section_visible = bool(section_keys & visible)
        if f"profile_{n}" in visible or any_section_visible:
            items.append(
                (
                    profile_names[n],
                    {"mode": "profile_menu", "pnum": n},
                    _local_icon("profiles"),
                    True,
                )
            )

    if "pvr_favs" in visible and pvr_has_live:
        items.append(
            (
                _t(30008),
                {"mode": "pvr_favorites_manager"},
                "DefaultFavourites.png",
                True,
            )
        )

    if "search" in visible and has_search:
        items.append(
            (
                _t(30889),
                {"mode": "search_global"},
                "DefaultAddonsSearch.png",
                True,
            )
        )

    if "tools" in visible:
        items.append(
            (
                _t(30010),
                {"mode": "tools_menu"},
                "DefaultAddonProgram.png",
                True,
            )
        )

    _fanart = _addon_fanart()
    directory_items = []
    for item in items:
        label, q, icon, is_folder = item
        li = _new_listitem(label)
        art = {"icon": icon, "thumb": icon, "poster": icon}
        if _fanart:
            art["fanart"] = _fanart
        li.setArt(art)
        directory_items.append((build_url(q), li, is_folder))
    xbmcplugin.addDirectoryItems(addon_handle, directory_items)
    xbmcplugin.endOfDirectory(addon_handle)


def profiles_search_menu():
    """List enabled profiles and global search for the Tools hub."""
    _log("Opening profiles search menu")
    enabled_profiles = []
    profile_names = {}
    for n in range(1, 11):
        if addon.getSetting(f"profile_{n}_enabled") == "true":
            enabled_profiles.append(n)
            profile_names[n] = addon.getSetting(f"profile_{n}_name") or _t(30390, n)

    has_search = any(
        bool(
            addon.getSetting(f"profile_{n}_xtream_url")
            or addon.getSetting(f"profile_{n}_m3u")
        )
        for n in range(1, 11)
    )

    items = []
    for n in enabled_profiles:
        items.append(
            (
                profile_names[n],
                {"mode": "profile_menu", "pnum": n},
                _local_icon("profiles"),
                True,
            )
        )

    if has_search:
        items.append(
            (
                _t(30889),
                {"mode": "search_global"},
                _local_icon("search"),
                True,
            )
        )

    _fanart = _addon_fanart()
    for label, q, icon, is_folder in items:
        li = xbmcgui.ListItem(label=label)
        art = {"icon": icon}
        if _fanart:
            art["fanart"] = _fanart
        li.setArt(art)
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=is_folder
        )
    xbmcplugin.endOfDirectory(addon_handle)


def main_tools_menu():
    _log("Opening main tools menu")
    items = [
        (
            _t(31390),
            {"mode": "profiles_search_menu"},
            _local_icon("profiles"),
        ),
        (
            _t(30017, _get_pvr_profile_num()),
            {"mode": "switch_profile"},
            _local_icon("live_tv_pvr"),
        ),
        (
            _t(31532),
            {"mode": "sync_sports_pvr"},
            _local_icon("live_tv_pvr"),
        ),
        (
            _t(30008),
            {"mode": "pvr_favorites_manager"},
            _local_icon("favorites"),
        ),
        (
            _t(30740),
            {"mode": "manage_active_profiles"},
            _local_icon("manage_user_profile"),
        ),
        (
            _t(31388),
            {"mode": "manage_main_menu_items"},
            _local_icon("settings"),
        ),
        (
            _t(30718),
            {"mode": "reset_reload_menu"},
            _local_icon("reset_reload"),
        ),
        (
            _t(30016),
            {"mode": "clear_cache_menu"},
            _local_icon("clear_cache_history"),
        ),
        (
            _t(30020),
            {"mode": "check_update"},
            _local_icon("check_for_update"),
        ),
        (
            _t(30723),
            {"mode": "account_iptv_menu"},
            _local_icon("account_info"),
        ),
        (
            _t(30909),
            {"mode": "settings"},
            _local_icon("settings"),
        ),
    ]
    _fanart = _addon_fanart()
    for label, q, icon in items:
        li = xbmcgui.ListItem(label=label)
        art = {"icon": icon}
        if _fanart:
            art["fanart"] = _fanart
        li.setArt(art)
        if isinstance(q, str):
            item_url = q
            is_folder = True
        else:
            item_url = build_url(q)
            is_folder = q.get("mode") not in (
                "settings",
                "clear_cache_menu",
                "manage_active_profiles",
                "manage_main_menu_items",
                "check_update",
            )
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=item_url, listitem=li, isFolder=is_folder
        )
    xbmcplugin.endOfDirectory(addon_handle)


def tools_menu():
    _log("Opening tools menu")
    if not _check_pin("Tools"):
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
        return
    creds = _get_credentials()
    active_name = creds.get("name", _t(30390, 1))
    items = [
        (
            "[COLOR yellow]{0}[/COLOR]".format(_t(30011)),
            {"mode": "settings"},
            "DefaultAddonProgram.png",
        ),
        (
            _t(30017, _get_pvr_profile_num()),
            {"mode": "switch_profile"},
            "DefaultAddonPVRClient.png",
        ),
        (_t(30740), {"mode": "manage_active_profiles"}, "DefaultTVShows.png"),
        (_t(31388), {"mode": "manage_main_menu_items"}, "DefaultAddonProgram.png"),
        (_t(30718), {"mode": "reset_reload_menu"}, "DefaultAddonsUpdates.png"),
        (_t(30016), {"mode": "clear_cache_menu"}, "DefaultAddonNone.png"),
        (_t(30723), {"mode": "account_iptv_menu"}, "DefaultAddonWebSkin.png"),
        (
            "[COLOR green]{0}[/COLOR]".format(_t(30020)),
            {"mode": "check_update"},
            "DefaultAddonsUpdates.png",
        ),
    ]
    _fanart = _addon_fanart()
    for label, q, icon in items:
        li = xbmcgui.ListItem(label=label)
        art = {"icon": icon}
        if _fanart:
            art["fanart"] = _fanart
        li.setArt(art)
        is_folder = q.get("mode") not in (
            "settings",
            "refresh_data",
            "toggle_setting",
            "clear_cache_menu",
            "manage_active_profiles",
            "manage_main_menu_items",
        )
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=is_folder
        )
    xbmcplugin.endOfDirectory(addon_handle)


def trakt_restore_credentials_action():
    if not xbmcgui.Dialog().yesno(
        "Xstream Pro",
        _t(31350),
    ):
        return
    if addon.getSetting("trakt_client_id"):
        addon.setSetting("trakt_client_id", "")
    if addon.getSetting("trakt_client_secret"):
        addon.setSetting("trakt_client_secret", "")
    try:
        import trakt
        trakt.clear_tokens()
    except Exception as exc:
        _log("Trakt token clear warning: {0}".format(exc))
    xbmcgui.Dialog().notification("Xstream Pro", _t(31147))


def trakt_clear_cache_action():
    if not xbmcgui.Dialog().yesno("Xstream Pro", _t(31246)):
        return
    try:
        import trakt
        trakt.clear_cache()
    except Exception as exc:
        _log("Trakt cache clear warning: {0}".format(exc))
    xbmcgui.Dialog().notification("Xstream Pro", _t(31148))


def reset_reload_menu():
    """Reset/Reload menu - actions for reloading EPG, PVR, and relaunching Kodi."""
    _log("Opening reset/reload menu")
    items = [
        (_t(30012), {"mode": "refresh_profile_menu"}, _local_icon("reset_reload")),
        (_t(30719), {"mode": "force_reload_epg"}, _local_icon("reset_reload")),
        (_t(30720), {"mode": "force_reload_pvr"}, _local_icon("reset_reload")),
        (_t(31434), {"mode": "reset_pvr_epg_db"}, _local_icon("reset_reload")),
        (_t(30721), {"mode": "relaunch_kodi"}, _local_icon("reset_reload")),
    ]
    _fanart = _addon_fanart()
    for label, q, icon in items:
        li = xbmcgui.ListItem(label=label)
        art = {"icon": icon}
        if _fanart:
            art["fanart"] = _fanart
        li.setArt(art)
        is_folder = q.get("mode") not in (
            "refresh_profile_menu",
            "force_reload_epg",
            "force_reload_pvr",
            "reset_pvr_epg_db",
            "relaunch_kodi",
        )
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=is_folder
        )
    xbmcplugin.endOfDirectory(addon_handle)


def account_iptv_menu():
    """Account Info menu - test connection and account information."""
    _log("Opening account info menu")
    items = [
        (_t(30724), {"mode": "test_connection"}, _local_icon("currently_airing")),
        (_t(30725), {"mode": "account_info"}, _local_icon("account_info")),
        (_t(30788), {"mode": "loaded_profiles_view"}, _local_icon("profiles")),
    ]
    _fanart = _addon_fanart()
    for label, q, icon in items:
        li = xbmcgui.ListItem(label=label)
        art = {"icon": icon}
        if _fanart:
            art["fanart"] = _fanart
        li.setArt(art)
        is_folder = q.get("mode") not in (
            "test_connection",
            "account_info",
            "loaded_profiles_view",
        )
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=is_folder
        )
    xbmcplugin.endOfDirectory(addon_handle)


def loaded_profiles_view():
    """Show loading status of PVR and all 10 profiles as an unclickable list."""
    # PVR status
    pvr_profile = addon.getSetting("active_pvr_profile") or "Profile 1"
    pvr_num = (
        re.search(r"(\d+)$", pvr_profile).group(1)
        if re.search(r"(\d+)$", pvr_profile)
        else "1"
    )
    pvr_path = _pvr_m3u_path()
    pvr_loaded = _m3u_file_has_channels(pvr_path)
    pvr_status = (
        f"[COLOR green]{_t(30786)}[/COLOR]"
        if pvr_loaded
        else f"[COLOR red]{_t(30787)}[/COLOR]"
    )

    items = [f"{pvr_status} {_t(30001)} {pvr_num}"]

    # All 10 profiles
    for n in range(1, 11):
        profile_name = addon.getSetting(f"profile_{n}_name") or _t(30390, n)
        has_creds = bool(
            addon.getSetting(f"profile_{n}_xtream_url")
            or addon.getSetting(f"profile_{n}_m3u")
        )

        if not has_creds:
            # Grey out profiles without credentials
            items.append(f"[COLOR grey]{profile_name} ({_t(30864)})[/COLOR]")
            continue

        loaded = _profile_has_data(n)
        status = (
            f"[COLOR green]{_t(30786)}[/COLOR]"
            if loaded
            else f"[COLOR red]{_t(30787)}[/COLOR]"
        )
        items.append(f"{status} {profile_name}")

    xbmcgui.Dialog().select(_t(30788), items)


def relaunch_kodi_action():
    """Relaunch Kodi - show confirmation and restart."""
    dialog = xbmcgui.Dialog()
    confirm = dialog.yesno(_t(30721), _t(30726), yeslabel=_t(30165), nolabel=_t(30164))
    if not confirm:
        return
    _log("Relaunching Kodi")
    try:
        xbmc.executeJSONRPC('{"jsonrpc":"2.0","method":"PVR.Manager.Stop","id":1}')
        xbmc.sleep(_tvos_sleep(1000))
    except Exception as e:
        _log(f"PVR stop before relaunch warning: {e}")
    _restart_or_prompt()


def install_upnext():
    if xbmc.getCondVisibility("System.HasAddon(service.upnext)"):
        xbmcgui.Dialog().notification("Xstream Pro", _t(30859))
        return
    xbmc.executebuiltin("InstallAddon(service.upnext)")


def open_upnext_settings():
    xbmc.executebuiltin("Dialog.Close(all,true)")
    xbmc.sleep(_tvos_sleep(500))
    import xbmcaddon
    xbmcaddon.Addon("service.upnext").openSettings()


def _profile_section_visible(visible, pnum, section):
    visible_set = set(visible)
    section_keys = {
        f"profile_{pnum}_live",
        f"profile_{pnum}_movies",
        f"profile_{pnum}_series",
        f"profile_{pnum}_replay",
        f"profile_{pnum}_favs",
        f"profile_{pnum}_recent",
        f"profile_{pnum}_search",
    }
    if section_keys & visible_set:
        return f"profile_{pnum}_{section}" in visible_set
    # Main-menu customization must not hide sections inside Tools > Profiles.
    if _MAIN_MENU_CUSTOMIZED_KEY in visible_set:
        return True
    return f"profile_{pnum}" in visible_set


def _get_hidden_subcats(stype, profile_num=None):
    """Get list of hidden subcategory IDs for a given type (live/movie/series)."""
    pnum = profile_num or pm.active
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    path = os.path.join(profile, f"hidden_subcats_{stype}_{pnum}.json")
    try:
        with open(path, "r", encoding="utf-8") as f:
            return set(json.load(f))
    except Exception:
        return set()


def _set_hidden_subcats(stype, hidden, profile_num=None):
    """Save list of hidden subcategory IDs for a given type."""
    pnum = profile_num or pm.active
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    os.makedirs(profile, exist_ok=True)
    path = os.path.join(profile, f"hidden_subcats_{stype}_{pnum}.json")
    tmp_file = path + ".tmp"
    with open(tmp_file, "w", encoding="utf-8") as f:
        json.dump(list(hidden), f)
        try:
            f.flush()
            _safe_fsync(f)
        except OSError:
            pass
    os.replace(tmp_file, path)


def _get_hidden_items(stype, profile_num=None):
    """Get set of hidden individual stream IDs for a given type."""
    pnum = profile_num or pm.active
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    path = os.path.join(profile, f"hidden_items_{stype}_{pnum}.json")
    try:
        with open(path, "r", encoding="utf-8") as f:
            return set(json.load(f))
    except Exception:
        return set()


def _set_hidden_items(stype, hidden, profile_num=None):
    """Save set of hidden individual stream IDs for a given type."""
    pnum = profile_num or pm.active
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    os.makedirs(profile, exist_ok=True)
    path = os.path.join(profile, f"hidden_items_{stype}_{pnum}.json")
    tmp_file = path + ".tmp"
    with open(tmp_file, "w", encoding="utf-8") as f:
        json.dump(list(hidden), f)
        try:
            f.flush()
            _safe_fsync(f)
        except OSError:
            pass
    os.replace(tmp_file, path)



def _profile_has_data(profile_num):
    """Check if any data is loaded for profile."""
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    prefix = f"data_cache_p{profile_num}_"
    try:
        for fname in os.listdir(profile):
            if fname.startswith(prefix):
                # Boundary check: p1 must not match p10, p11, etc.
                rest = fname[len(prefix) :]
                if rest and rest[0].isdigit():
                    continue
                return True
    except OSError as e:
        _log(f"data cache list warning: {e}")
    return False


def _manage_load_content_toggles(profile_num):
    """Settings dialog to toggle Load TV/Movies/Series for a profile."""
    dialog = xbmcgui.Dialog()
    pnum = profile_num

    # Get profile credentials to determine available content types
    original_active = pm.active
    pm.active = str(pnum)
    try:
        creds = pm.get_credentials()
        has_xtream = bool(creds.get("xtream_url"))
        has_m3u_with_creds = bool(creds.get("m3u_url")) and _m3u_has_credentials(
            creds.get("m3u_url")
        )
        supports_categories = has_xtream or has_m3u_with_creds
    finally:
        pm.active = original_active

    # Get current values
    live = addon.getSetting(f"profile_{pnum}_load_live") == "true"
    movies = addon.getSetting(f"profile_{pnum}_load_movies") == "true"
    series = addon.getSetting(f"profile_{pnum}_load_series") == "true"

    # Build options based on source type
    if supports_categories:
        options = [_t(30002), _t(30004), _t(30005)]  # Live TV, Movies, Series
        preselect = []
        if live:
            preselect.append(0)
        if movies:
            preselect.append(1)
        if series:
            preselect.append(2)
    else:
        # M3U without creds - only Live TV
        options = [_t(30002)]  # Live TV only
        preselect = [0] if live else []

    result = dialog.multiselect(_t(30810), options, preselect=preselect)
    if result is None:
        return

    changed = False
    if supports_categories:
        new_live = "true" if 0 in result else "false"
        new_movies = "true" if 1 in result else "false"
        new_series = "true" if 2 in result else "false"
        if addon.getSetting(f"profile_{pnum}_load_live") != new_live:
            changed = True
        if addon.getSetting(f"profile_{pnum}_load_movies") != new_movies:
            changed = True
        if addon.getSetting(f"profile_{pnum}_load_series") != new_series:
            changed = True
        addon.setSetting(f"profile_{pnum}_load_live", new_live)
        addon.setSetting(f"profile_{pnum}_load_movies", new_movies)
        addon.setSetting(f"profile_{pnum}_load_series", new_series)
    else:
        # M3U simple - only toggle live
        new_live = "true" if 0 in result else "false"
        if addon.getSetting(f"profile_{pnum}_load_live") != new_live:
            changed = True
        addon.setSetting(f"profile_{pnum}_load_live", new_live)

    dialog.notification("Xstream Pro", _t(30083))

    if changed:
        if dialog.yesno(_t(30012), _t(30012)):
            _refresh_profile_data(pnum)
            # If this is the PVR profile, offer PVR sync after reload
            if _is_pvr_profile(pnum) and _is_pvr_iptvsimple_installed_or_disabled():
                xbmc.sleep(_tvos_sleep(500))  # Allow previous dialog to close
                if dialog.yesno("Xstream Pro", _t(30867)):
                    try:
                        _sync_pvr_force()
                        _log("PVR sync triggered after load toggle change")
                    except Exception as e:
                        _log(f"PVR sync after load toggle warning: {e}")
                else:
                    # User declined — flag for startup retry
                    profile_path = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
                    retry_flag = os.path.join(
                        profile_path, "pvr_sync_retry_needed.flag"
                    )
                    with open(retry_flag, "w", encoding="utf-8") as f:
                        f.write("1")
                    _log(
                        "PVR sync deferred after load toggle, flagged for startup retry"
                    )


def _manage_content_select(profile_num):
    """Settings dialog to select which content type to manage."""
    dialog = xbmcgui.Dialog()
    pnum = profile_num

    if not _profile_has_data(pnum):
        dialog.ok("Xstream Pro", _t(30759))
        return

    # Get profile credentials to determine available content types
    original_active = pm.active
    pm.active = str(pnum)
    try:
        creds = pm.get_credentials()
        has_xtream = bool(creds.get("xtream_url"))
        has_m3u_with_creds = bool(creds.get("m3u_url")) and _m3u_has_credentials(
            creds.get("m3u_url")
        )
        supports_categories = has_xtream or has_m3u_with_creds
    finally:
        pm.active = original_active

    # Build options based on source type
    if supports_categories:
        options = [_t(30002), _t(30004), _t(30005)]  # Live TV, Movies, Series
        stype_map = {0: "live", 1: "movie", 2: "series"}
    else:
        # M3U without creds - only Live TV
        options = [_t(30002)]  # Live TV only
        stype_map = {0: "live"}

    choice = dialog.select(_t(30760), options)
    if choice < 0:
        return

    stype = stype_map[choice]

    manage_content_dialog(stype, pnum)


def manage_content_dialog(stype, profile_num):
    """Dialog-based content manager for use from settings. Loops until user cancels."""
    pnum = profile_num or pm.active
    creds = _get_credentials_for_profile(pnum)
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    if not url:
        xbmcgui.Dialog().notification("Xstream Pro", _t(30073))
        return
    cats = _get_cached_xtream_categories(url, user, pwd, stype)
    if not cats:
        xbmcgui.Dialog().notification("Xstream Pro", _t(30074))
        return
    dialog = xbmcgui.Dialog()
    streams = _get_cached_xtream_streams(url, user, pwd, stype)
    stype_label_map = {
        "live": _t(30002),  # Live TV
        "movie": _t(30004),  # Movies
        "series": _t(30005),  # Series
    }
    stype_label = stype_label_map.get(stype, stype.capitalize())
    changed = False
    while True:
        hidden = _get_hidden_subcats(stype, pnum)
        hidden_items = _get_hidden_items(stype, pnum)
        hidden_cat_count = len(hidden)
        hidden_item_count = len(hidden_items)
        labels = [
            "[COLOR gold]{0}[/COLOR]".format(_t(30159, hidden_cat_count)),
            "[COLOR gold]{0}[/COLOR]".format(_t(30190, hidden_item_count)),
        ]
        for c in cats:
            cat_name = c.get("category_name", "Unknown")
            labels.append(cat_name)
        choice = dialog.select(_t(30761, stype_label), labels)
        if choice < 0:
            break
        if choice == 0:
            # Multiselect categories — checked = HIDDEN, with Select/Deselect All
            cat_names = [_t(30333), _t(30334)] + [
                c.get("category_name", "Unknown") for c in cats
            ]
            cat_ids = [str(c.get("category_id", "")) for c in cats]
            preselect = [i + 2 for i, cid in enumerate(cat_ids) if cid in hidden]
            result = dialog.multiselect(_t(30335), cat_names, preselect=preselect)
            if result is None:
                continue
            if 0 in result:
                # Select All — reopen with all selected
                preselect = list(range(2, len(cat_names)))
                result = dialog.multiselect(_t(30335), cat_names, preselect=preselect)
                if result is None:
                    continue
            if 1 in result:
                # Deselect All — reopen with none selected
                result = dialog.multiselect(_t(30523), cat_names, preselect=[])
                if result is None:
                    continue
            real_result = [i - 2 for i in result if i >= 2] if result else []
            new_hidden = {cat_ids[i] for i in real_result} if real_result else set()
            if new_hidden != hidden:
                _set_hidden_subcats(stype, new_hidden, pnum)
                dialog.notification("Xstream Pro", _t(30174, len(new_hidden)))
                changed = True
            continue
        if choice == 1:
            if not hidden_items:
                dialog.notification("Xstream Pro", _t(30075))
                continue
            hidden_streams = [
                s
                for s in (streams or [])
                if str(s.get("stream_id", "")) in hidden_items
            ]
            if not hidden_streams:
                dialog.notification("Xstream Pro", _t(30135))
                continue
            h_names = [_t(30334)] + [s.get("name", "Unknown") for s in hidden_streams]
            h_ids = [str(s.get("stream_id", "")) for s in hidden_streams]
            preselect = list(range(1, len(hidden_streams) + 1))
            result = dialog.multiselect(
                _t(30152, stype_label), h_names, preselect=preselect
            )
            if result is None:
                continue
            if 0 in result:
                # Deselect All — reopen with none selected
                result = dialog.multiselect(
                    _t(30524, stype_label), h_names, preselect=[]
                )
                if result is None:
                    continue
            real_result = [i - 1 for i in result if i >= 1] if result else []
            new_hidden = {h_ids[i] for i in real_result} if real_result else set()
            orphan_ids = hidden_items - set(h_ids)
            _set_hidden_items(stype, new_hidden | orphan_ids, pnum)
            unhidden = len(hidden_streams) - (len(real_result) if real_result else 0)
            if unhidden:
                dialog.notification("Xstream Pro", _t(30175, unhidden))
                changed = True
            continue
        cat = cats[choice - 2]
        cat_id = str(cat.get("category_id", ""))
        cat_name = cat.get("category_name", "Unknown")
        filtered = [
            s for s in (streams or []) if str(s.get("category_id", "")) == cat_id
        ]
        if not filtered:
            dialog.notification("Xstream Pro", _t(30136))
            continue
        ids = [str(s.get("stream_id", "")) for s in filtered]
        names = [_t(30333), _t(30334)] + [s.get("name", "Unknown") for s in filtered]
        preselect = [i + 2 for i, sid in enumerate(ids) if sid in hidden_items]
        result = dialog.multiselect(_t(30336, cat_name), names, preselect=preselect)
        if result is None:
            continue
        if 0 in result:
            preselect = list(range(2, len(names)))
            result = dialog.multiselect(_t(30336, cat_name), names, preselect=preselect)
            if result is None:
                continue
        if 1 in result:
            result = dialog.multiselect(_t(30524, cat_name), names, preselect=[])
            if result is None:
                continue
        real_result = [i - 2 for i in result if i >= 2] if result else []
        cat_id_set = set(ids)
        new_hidden = (hidden_items - cat_id_set) | (
            {ids[i] for i in real_result} if real_result else set()
        )
        if new_hidden != hidden_items:
            _set_hidden_items(stype, new_hidden, pnum)
            hidden_count = len(result) if result else 0
            dialog.notification("Xstream Pro", _t(30176, hidden_count, cat_name))
            changed = True
    # Re-sync PVR if anything changed and this is the active profile
    if changed and pnum == pm.active and stype == "live":
        _sync_pvr_force()
    if changed:
        if dialog.yesno(_t(30012), _t(30012)):
            _refresh_profile_data(pnum)
            return
    # Reopen settings at Profiles tab
    xbmc.executebuiltin("Addon.OpenSettings(plugin.video.xstream-pro)")
    xbmc.sleep(_tvos_sleep(300))
    xbmc.executebuiltin("SetFocus(9,1)")
    xbmc.executebuiltin("SetFocus(10,0)")


def manage_hidden_subcats(stype, profile_num=None):
    """Show subcategories as folder view. Clicking opens dialog: hide entire or browse content."""
    pnum = profile_num or pm.active
    creds = _get_credentials_for_profile(pnum)
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    m3u_url = creds.get("m3u_url", "")

    is_m3u_only = not url and m3u_url
    if not url and not m3u_url:
        # No credentials - nothing to hide
        xbmcgui.Dialog().notification("Xstream Pro", _t(30073))
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
        return

    hidden = _get_hidden_subcats(stype, pnum)
    hidden_items = _get_hidden_items(stype, pnum)

    if is_m3u_only:
        # For M3U without credentials: show flat channel list like main menu
        channels = _get_cached_m3u_channels(m3u_url)
        if not channels:
            xbmcgui.Dialog().notification("Xstream Pro", _t(30074))
            xbmcplugin.endOfDirectory(addon_handle)
            return

        li = xbmcgui.ListItem(label="[COLOR red]{0}[/COLOR]".format(_t(30260)))
        li.setArt({"icon": "DefaultAddonNone.png"})
        q = {"mode": "hide_all_subcats", "stype": stype, "pnum": pnum, "action": "hide"}
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
        )
        li = xbmcgui.ListItem(label="[COLOR green]{0}[/COLOR]".format(_t(30261)))
        li.setArt({"icon": "DefaultAddonNone.png"})
        q = {
            "mode": "hide_all_subcats",
            "stype": stype,
            "pnum": pnum,
            "action": "unhide",
        }
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
        )
        li = xbmcgui.ListItem(
            label="[COLOR gold]{0}[/COLOR]".format(_t(30262).format(len(hidden_items)))
        )
        li.setArt({"icon": "DefaultFavourites.png"})
        q = {"mode": "hidden_items_all", "stype": stype, "pnum": pnum}
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
        )

        # Show all channels flat, sorted by group then name
        sorted_channels = sorted(
            channels,
            key=lambda ch: (ch.get("group") or "General", ch.get("name", "").lower()),
        )
        for ch in sorted_channels:
            item_id = str(ch.get("tvg_id") or ch.get("url") or ch.get("name", ""))
            is_hidden = item_id in hidden_items
            name = ch.get("name", "Unknown")
            group = ch.get("group") or "General"
            label = (
                "[COLOR red]{0}[/COLOR] [{1}] {2}".format(_t(30191), group, name)
                if is_hidden
                else "[{0}] {1}".format(group, name)
            )
            li = xbmcgui.ListItem(label=label)
            li.setArt({"icon": ch.get("logo", "") or "DefaultVideo.png"})
            q = {
                "mode": "hide_m3u_item_action",
                "stype": stype,
                "item_id": item_id,
                "item_name": name,
                "pnum": pnum,
            }
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
            )
        xbmcplugin.endOfDirectory(addon_handle)
        return

    cats = _get_cached_xtream_categories(url, user, pwd, stype)
    if not cats:
        xbmcgui.Dialog().notification("Xstream Pro", _t(30074))
        xbmcplugin.endOfDirectory(addon_handle)
        return

    # Select All / Deselect All at top
    li = xbmcgui.ListItem(label="[COLOR red]{0}[/COLOR]".format(_t(30260)))
    li.setArt({"icon": "DefaultAddonNone.png"})
    q = {"mode": "hide_all_subcats", "stype": stype, "pnum": pnum, "action": "hide"}
    xbmcplugin.addDirectoryItem(
        handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
    )
    li = xbmcgui.ListItem(label="[COLOR green]{0}[/COLOR]".format(_t(30261)))
    li.setArt({"icon": "DefaultAddonNone.png"})
    q = {"mode": "hide_all_subcats", "stype": stype, "pnum": pnum, "action": "unhide"}
    xbmcplugin.addDirectoryItem(
        handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
    )
    li = xbmcgui.ListItem(
        label="[COLOR gold]{0}[/COLOR]".format(_t(30262).format(len(hidden_items)))
    )
    li.setArt({"icon": "DefaultFavourites.png"})
    q = {"mode": "hidden_items_all", "stype": stype, "pnum": pnum}
    xbmcplugin.addDirectoryItem(
        handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
    )
    for c in cats:
        cat_id = str(c.get("category_id", ""))
        cat_name = c.get("category_name", "Unknown")
        is_hidden = cat_id in hidden
        label = (
            "[COLOR red]{0}[/COLOR] {1}".format(_t(30191), cat_name)
            if is_hidden
            else cat_name
        )
        li = xbmcgui.ListItem(label=label)
        li.setArt({"icon": "DefaultFolder.png"})
        q = {
            "mode": "hide_subcat_action",
            "stype": stype,
            "cat_id": cat_id,
            "cat_name": cat_name,
            "pnum": pnum,
        }
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
        )
    xbmcplugin.endOfDirectory(addon_handle)


def hide_m3u_item_action(stype, item_id, item_name, profile_num=None):
    """Toggle hide/unhide for a single M3U channel."""
    pnum = profile_num or pm.active
    hidden_items = _get_hidden_items(stype, pnum)
    is_hidden = item_id in hidden_items

    if is_hidden:
        hidden_items.discard(item_id)
        xbmcgui.Dialog().notification("Xstream Pro", _t(30301, item_name))
    else:
        hidden_items.add(item_id)
        xbmcgui.Dialog().notification("Xstream Pro", _t(30302, item_name))

    _set_hidden_items(stype, hidden_items, pnum)
    xbmc.executebuiltin("Container.Refresh")


def hide_subcat_action(stype, cat_id, cat_name, profile_num=None):
    """Dialog: hide/unhide entire category or browse individual items."""
    pnum = profile_num or pm.active
    hidden = _get_hidden_subcats(stype, pnum)
    is_hidden = cat_id in hidden
    dialog = xbmcgui.Dialog()
    if is_hidden:
        options = [_t(30525), _t(30526)]
    else:
        options = [_t(30527), _t(30526)]
    choice = dialog.select(cat_name, options)
    if choice < 0:
        return
    if choice == 0:
        if is_hidden:
            hidden.discard(cat_id)
            xbmcgui.Dialog().notification("Xstream Pro", _t(30301, cat_name))
        else:
            hidden.add(cat_id)
            xbmcgui.Dialog().notification("Xstream Pro", _t(30302, cat_name))
        _set_hidden_subcats(stype, hidden, pnum)
        xbmc.executebuiltin("Container.Refresh")
    elif choice == 1:
        creds = _get_credentials_for_profile(pnum)
        url = creds.get("xtream_url", "")
        user = creds.get("xtream_username", "")
        pwd = creds.get("xtream_password", "")
        streams = _get_cached_xtream_streams(url, user, pwd, stype)
        filtered = [
            s for s in (streams or []) if str(s.get("category_id", "")) == cat_id
        ]
        if not filtered:
            xbmcgui.Dialog().notification("Xstream Pro", _t(30136))
            return
        hidden_items = _get_hidden_items(stype, pnum)
        ids = [str(s.get("stream_id", "")) for s in filtered]
        names = [_t(30333), _t(30334)] + [s.get("name", "Unknown") for s in filtered]
        preselect = [i + 2 for i, sid in enumerate(ids) if sid in hidden_items]
        result = dialog.multiselect(_t(30528, cat_name), names, preselect=preselect)
        if result is None:
            return
        if 0 in result:
            preselect = list(range(2, len(names)))
            result = dialog.multiselect(_t(30528, cat_name), names, preselect=preselect)
            if result is None:
                return
        if 1 in result:
            result = dialog.multiselect(_t(30528, cat_name), names, preselect=[])
            if result is None:
                return
        real_result = [i - 2 for i in result if i >= 2] if result else []
        cat_ids = set(ids)
        new_hidden = (hidden_items - cat_ids) | (
            {ids[i] for i in real_result} if real_result else set()
        )
        _set_hidden_items(stype, new_hidden, pnum)
        hidden_count = len(result) if result else 0
        xbmcgui.Dialog().notification(
            "Xstream Pro", _t(30312, hidden_count, cat_name)
        )


def hide_all_subcats(stype, action, profile_num=None):
    """Hide or unhide all subcategories at once."""
    pnum = profile_num or pm.active
    creds = _get_credentials_for_profile(pnum)
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    m3u_url = creds.get("m3u_url", "")
    is_m3u_only = not url and m3u_url

    if is_m3u_only:
        # M3U without credentials - hide/show all individual channels
        channels = _get_cached_m3u_channels(m3u_url)
        if not channels:
            return
        if action == "hide":
            new_hidden = {
                str(ch.get("tvg_id") or ch.get("url") or ch.get("name", ""))
                for ch in channels
            }
            _set_hidden_items(stype, new_hidden, pnum)
            xbmcgui.Dialog().notification("Xstream Pro", _t(30313, len(new_hidden)))
        else:
            _set_hidden_items(stype, set(), pnum)
            xbmcgui.Dialog().notification("Xstream Pro", _t(30137))
        xbmc.executebuiltin("Container.Refresh")
        return

    # Xtream/M3U with credentials - hide/show categories
    cats = [
        str(c.get("category_id", ""))
        for c in _get_cached_xtream_categories(url, user, pwd, stype)
    ]

    if not cats:
        return
    if action == "hide":
        new_hidden = set(cats)
        _set_hidden_subcats(stype, new_hidden, pnum)
        xbmcgui.Dialog().notification("Xstream Pro", _t(30313, len(new_hidden)))
    else:
        _set_hidden_subcats(stype, set(), pnum)
        xbmcgui.Dialog().notification("Xstream Pro", _t(30137))
    xbmc.executebuiltin("Container.Refresh")


def _render_recent_items(items, stype, profile_num=None):
    """Render recently watched items."""

    pnum = profile_num or pm.active

    # For series: group by series_id + season_num, keep most recent per group
    if stype == "series":
        seen_groups = {}  # (series_id, season_num) -> entry
        for entry in items:
            series_id = entry.get("series_id")
            season_num = entry.get("season_num")
            if series_id and season_num:
                key = (series_id, season_num)
                # Keep the most recent entry for this series+season
                if key not in seen_groups or entry.get("timestamp", 0) > seen_groups[
                    key
                ].get("timestamp", 0):
                    seen_groups[key] = entry
        for (series_id, season_num), entry in seen_groups.items():
            name = entry.get("name", "")
            icon = entry.get("icon", "")
            ts = entry.get("timestamp", 0)
            when = (
                datetime.datetime.fromtimestamp(ts).strftime("%d/%m %H:%M")
                if ts
                else ""
            )
            series_name = name
            if " S" in name and "E" in name:
                # Try to extract base series name (e.g., "Breaking Bad S01E05" -> "Breaking Bad")
                parts = name.rsplit(" S", 1)
                if parts:
                    series_name = parts[0]
            label = _t(30540, series_name, season_num)
            if when:
                label += f"  [COLOR gray]({when})[/COLOR]"
            li = xbmcgui.ListItem(label=label)
            if icon:
                li.setArt({"icon": icon, "thumb": icon, "poster": icon})
            info_tag = li.getVideoInfoTag()
            info_tag.setTitle(entry.get("title", series_name))
            info_tag.setPlot(entry.get("plot", ""))
            _prepare_playback_item(li)
            q = {
                "mode": "xtream_season",
                "series_id": series_id,
                "season_num": season_num,
            }
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url=build_url(q), listitem=li, isFolder=True
            )
        return

    # For movies/live: render individual entries
    for entry in items:
        name = entry.get("name", "")
        url = entry.get("url", "")
        icon = entry.get("icon", "")
        ts = entry.get("timestamp", 0)
        when = datetime.datetime.fromtimestamp(ts).strftime("%d/%m %H:%M") if ts else ""
        label = f"{name}  [COLOR gray]({when})[/COLOR]" if when else name
        li = xbmcgui.ListItem(label=label)
        if icon:
            li.setArt({"icon": icon, "thumb": icon, "poster": icon})
        li.setProperty("IsPlayable", "true")
        info_tag = li.getVideoInfoTag()
        info_tag.setTitle(entry.get("title", name))
        info_tag.setPlot(entry.get("plot", ""))
        _prepare_playback_item(li)
        q = {
            "mode": "play_stream",
            "url": url,
            "name": name,
            "icon": icon,
            "stype": stype,
            "profile_num": pnum,
        }
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
        )


def tmdb_local_recent(tmdb_type):
    pnum = int(pm.active) if pm.active and str(pm.active).isdigit() else 1
    stype = "series" if tmdb_type == "tv" else "movie"
    items = WatchHistory(addon, profile_num=pnum).get_all(stype)
    if not items:
        li = xbmcgui.ListItem(label=_t(31124))
        xbmcplugin.addDirectoryItem(addon_handle, "", li, False)
        xbmcplugin.endOfDirectory(addon_handle)
        return
    _render_recent_items(items, stype, pnum)
    xbmcplugin.endOfDirectory(addon_handle)

def tmdb_local_in_progress(tmdb_type):
    pnum = int(pm.active) if pm.active and str(pm.active).isdigit() else 1
    stype = "series" if tmdb_type == "tv" else "movie"
    items = [
        i for i in ResumePoints(addon, profile_num=pnum).get_all()
        if i.get("stype") == stype
    ]
    if not items:
        li = xbmcgui.ListItem(label=_t(31125))
        xbmcplugin.addDirectoryItem(addon_handle, "", li, False)
        xbmcplugin.endOfDirectory(addon_handle)
        return
    for entry in items:
        name = entry.get("name", "")
        url = entry.get("url", "")
        icon = entry.get("icon", "")
        pos = int(entry.get("position", 0) or 0)
        dur = int(entry.get("duration", 0) or 0)
        pct = int((pos / dur) * 100) if dur else 0
        label = f"{name}  [COLOR gray]({pct}%)[/COLOR]"
        li = xbmcgui.ListItem(label=label)
        if icon:
            li.setArt({"icon": icon, "thumb": icon, "poster": icon})
        info_tag = li.getVideoInfoTag()
        info_tag.setTitle(entry.get("title", name))
        info_tag.setPlot(entry.get("plot", ""))
        li.setProperty("IsPlayable", "true")
        li.setProperty("ResumeTime", str(pos))
        li.setProperty("TotalTime", str(dur))
        q = {
            "mode": "play_stream",
            "url": url,
            "name": name,
            "icon": icon,
            "stype": stype,
            "profile_num": pnum,
            "resume_pos": pos,
            "resume_total": dur,
        }
        if stype == "series":
            q["series_id"] = entry.get("series_id", "")
            q["season_num"] = entry.get("season_num", "")
            q["ep_id"] = entry.get("ep_id", "")
        xbmcplugin.addDirectoryItem(addon_handle, build_url(q), li, False)
    xbmcplugin.endOfDirectory(addon_handle)

def live_menu(profile_num=None):
    original_active = pm.active
    if profile_num:
        pm.active = str(profile_num)
    if profile_num and not _prompt_profile_credentials(profile_num):
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
        return
    try:
        creds = _get_credentials()
        xtream_url = creds.get("xtream_url", "")
        m3u_url = creds.get("m3u_url", "")
        has_m3u_with_creds = bool(m3u_url) and _m3u_has_credentials(m3u_url)
        _log(
            f"live_menu: profile={pm.active}, xtream_url={bool(xtream_url)}, m3u_url={bool(m3u_url)}, m3u_with_creds={has_m3u_with_creds}"
        )

        if not xtream_url and not m3u_url:
            li = xbmcgui.ListItem(label=_t(30240))
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url="", listitem=li, isFolder=False
            )
            xbmcplugin.endOfDirectory(addon_handle)
            return

        # M3U with credentials OR Xtream - treat as Xtream (show category folders)
        if xtream_url or has_m3u_with_creds:
            li = xbmcgui.ListItem(label="[COLOR yellow]{0}[/COLOR]".format(_t(30007)))
            li.setArt({"icon": "DefaultAddonsSearch.png"})
            xbmcplugin.addDirectoryItem(
                handle=addon_handle,
                url=build_url(
                    {
                        "mode": "search_global",
                        "profile_num": profile_num,
                        "stype": "live",
                    }
                ),
                listitem=li,
                isFolder=True,
            )
            xtream_categories("live", profile_num)
            return

        # M3U without credentials - show flat paginated list of all channels
        if m3u_url:
            li = xbmcgui.ListItem(label="[COLOR yellow]{0}[/COLOR]".format(_t(30007)))
            li.setArt({"icon": "DefaultAddonsSearch.png"})
            xbmcplugin.addDirectoryItem(
                handle=addon_handle,
                url=build_url(
                    {
                        "mode": "search_global",
                        "profile_num": profile_num,
                        "stype": "live",
                    }
                ),
                listitem=li,
                isFolder=True,
            )
            _show_all_m3u_channels(profile_num)
            return

        xbmcplugin.endOfDirectory(addon_handle)
    finally:
        if profile_num:
            pm.active = original_active


def m3u_group(group, profile_num=None, page=1):
    xbmcplugin.setContent(addon_handle, "livetv")
    pnum = profile_num or pm.active
    show_epg = _epg_enabled()
    epg = None
    if show_epg:
        epg = EPG(addon, profile_num=pnum)
        epg.load()
        if epg.is_refreshing:
            _log("EPG background refresh in progress - showing cached/stale data")
    creds = _get_credentials_for_profile(pnum)
    channels = _get_cached_m3u_channels(creds.get("m3u_url", ""))
    hidden_items = _get_hidden_items("live", pnum)
    group_channels = []
    for ch in channels:
        if (ch.get("group") or "General") != group:
            continue
        item_id = str(ch.get("tvg_id") or ch.get("url") or ch.get("name", ""))
        if item_id in hidden_items:
            continue
        group_channels.append(ch)

    per_page = _get_pagination_limit("live")
    if per_page == 0:
        per_page = len(group_channels)
    total = len(group_channels)
    start = (page - 1) * per_page
    end = start + per_page
    page_channels = group_channels[start:end]

    for ch in page_channels:
        name = ch.get("name", "Unknown")
        tvg_id = ch.get("tvg_id") or name
        url = ch.get("url")
        plot, display_name, current_title = (
            _make_epg_info(epg, tvg_id, name) if show_epg else ("", name, "")
        )
        li = xbmcgui.ListItem(label=display_name)
        info_tag = li.getVideoInfoTag()
        info_tag.setMediaType("video")
        info_tag.setTitle(current_title or name)
        info_tag.setPlot(plot)
        if ch.get("logo"):
            li.setArt({"icon": ch["logo"], "thumb": ch["logo"]})
        li.setProperty("IsPlayable", "true")
        li.setProperty("IsLiveTV", "1")
        li.setProperty("previewpath", url)
        _prepare_playback_item(li)
        _set_live_props(li)
        live_play_url = url
        ctx = []
        li.addContextMenuItems(ctx)
        q = {
            "mode": "play_stream",
            "url": live_play_url,
            "name": name,
            "title": current_title or name,
            "plot": plot,
            "icon": ch.get("logo", ""),
            "profile_num": pnum,
        }
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
        )

    if end < total:
        li = xbmcgui.ListItem(
            label="[COLOR yellow]{0} ({1})[/COLOR]".format(_t(30232), page + 1)
        )
        art = {"icon": "DefaultFolder.png"}
        _fanart = _addon_fanart()
        if _fanart:
            art["fanart"] = _fanart
        li.setArt(art)
        q = {
            "mode": "m3u_group",
            "group": group,
            "pnum": pnum,
            "page": page + 1,
        }
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=True
        )

    _apply_sort("live")
    xbmcplugin.endOfDirectory(addon_handle)


def _is_adult_locked(stype):
    """Check if adult content for this type requires PIN."""
    if addon.getSetting("enable_parental_control").lower() != "true":
        return False
    lock_map = {
        "live": "parental_lock_adult_live",
        "movie": "parental_lock_adult_movies",
        "series": "parental_lock_adult_series",
    }
    key = lock_map.get(stype, "")
    return key and addon.getSetting(key).lower() == "true"


def xtream_categories(stype, profile_num=None):
    pnum = profile_num or pm.active
    creds = _get_credentials_for_profile(pnum)
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    cats = _get_cached_xtream_categories(url, user, pwd, stype)
    show_counts = addon.getSetting("show_content_counts").lower() == "true"
    cat_counts = {}
    if show_counts:
        cat_counts = _cache_load(f"xtream_counts_{stype}") or {}
        if not cat_counts:
            # Fallback: compute once from streams, then cache for next time
            all_streams = _get_cached_xtream_streams(url, user, pwd, stype, None)
            cat_counts = {}
            for s in all_streams:
                cid = str(s.get("category_id", ""))
                cat_counts[cid] = cat_counts.get(cid, 0) + 1
            _cache_save(f"xtream_counts_{stype}", cat_counts)
    hide_adult = addon.getSetting("hide_adult_categories").lower() == "true"
    adult_locked = _is_adult_locked(stype)
    hidden_subcats = _get_hidden_subcats(stype, pnum)
    for c in cats:
        name = c.get("category_name", "Unknown")
        cat_id = str(c.get("category_id", ""))
        if cat_id in hidden_subcats:
            continue
        if hide_adult and _is_adult_category(name):
            continue
        is_adult = _is_adult_category(name)
        count = cat_counts.get(cat_id, 0)
        if show_counts:
            display = f"{name}  [COLOR gray]({count})[/COLOR]"
        else:
            display = name
        if is_adult and adult_locked:
            display = f"[COLOR red]🔒[/COLOR] {display}"
        li = xbmcgui.ListItem(label=display)
        li.setArt({"icon": "DefaultFolder.png"})
        q = {
            "mode": "xtream_streams",
            "type": stype,
            "cat_id": c.get("category_id", ""),
            "adult": "1" if is_adult else "0",
            "profile_num": profile_num,
        }
        cat_url = build_url(q)
        ctx = _folder_favorite_ctx(name, cat_url, "DefaultFolder.png")
        li.addContextMenuItems(ctx)
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=cat_url, listitem=li, isFolder=True
        )
    # _apply_sort is intentionally skipped — category folders stay in provider order
    xbmcplugin.endOfDirectory(addon_handle)


def xtream_streams(stype, cat_id, page=1, adult="0", profile_num=None):
    if adult == "1" and _is_adult_locked(stype):
        if not _check_pin("Adult Content"):
            xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
            return
    pnum = profile_num or pm.active
    creds = _get_credentials_for_profile(pnum) if profile_num else _get_credentials()
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    streams = _get_cached_xtream_streams(url, user, pwd, stype, cat_id)
    hidden_items = _get_hidden_items(stype, profile_num)
    if hidden_items:
        streams = [
            s for s in streams if str(s.get("stream_id", "")) not in hidden_items
        ]

    # Pre-sort by "added" timestamp when user selects "Newest first"
    if stype in ("movie", "series"):
        sort_key = f"sort_order_{stype}"
        if addon.getSetting(sort_key) == "Newest first":
            try:
                streams.sort(
                    key=lambda s: int(s.get("added", 0) or 0), reverse=True
                )
            except Exception:
                pass  # Malformed "added" field — keep provider order

    # Pagination from settings
    per_page = _get_pagination_limit(stype)
    if per_page == 0:
        per_page = len(streams)
    total = len(streams)
    start = (page - 1) * per_page
    end = start + per_page
    page_streams = streams[start:end]

    if stype == "movie" and url and user and pwd:
        if addon.getSetting("provider_movie_enabled").lower() == "true":
            _prefetch_vod_info_batch(page_streams, url, user, pwd)

    if stype == "live":
        xbmcplugin.setContent(addon_handle, "livetv")
    elif stype == "movie":
        xbmcplugin.setContent(addon_handle, "movies")
    elif stype == "series":
        xbmcplugin.setContent(addon_handle, "tvshows")
    show_epg = _epg_enabled() if stype == "live" else False
    epg = None
    if show_epg:
        epg = EPG(addon, profile_num=pnum)
        epg.load()
        if epg.is_refreshing:
            _log("EPG background refresh in progress - showing cached/stale data")
    for s in page_streams:
        name = s.get("name", "Unknown")
        if stype == "live":
            sid = str(s.get("stream_id", ""))
            epg_id = s.get("epg_channel_id") or sid
            play_url = IPTV.build_xtream_stream_url(url, user, pwd, s, "live")
            plot, display_name, current_title = (
                _make_epg_info(epg, epg_id, name) if show_epg else ("", name, "")
            )
            li = xbmcgui.ListItem(label=display_name)
            info_tag = li.getVideoInfoTag()
            info_tag.setMediaType("video")
            info_tag.setTitle(current_title or name)
            info_tag.setPlot(plot)
            if s.get("stream_icon"):
                li.setArt({"icon": s["stream_icon"], "thumb": s["stream_icon"]})
            li.setProperty("IsPlayable", "true")
            li.setProperty("IsLiveTV", "1")
            li.setProperty("previewpath", play_url)
            _prepare_playback_item(li)
            _set_live_props(li)
            catchup = "default" if _truthy_catchup(s.get("tv_archive")) or _truthy_catchup(s.get("catchup")) else ""
            catchup_source = (
                _xtream_timeshift_catchup_source(url, user, pwd, sid)
                if (_truthy_catchup(s.get("tv_archive")) or _truthy_catchup(s.get("catchup")))
                else ""
            )
            catchup_days = (
                str(s.get("tv_archive_duration", "") or "7")
                if (_truthy_catchup(s.get("tv_archive")) or _truthy_catchup(s.get("catchup")))
                else ""
            )
            ctx = _build_favorite_ctx(
                sid, name, "live", s.get("stream_icon", ""), play_url,
                epg_id=epg_id, catchup=catchup,
                catchup_source=catchup_source, catchup_days=catchup_days,
                profile_num=pnum
            )
            li.addContextMenuItems(ctx)
            q = {
                "mode": "play_stream",
                "url": play_url,
                "name": name,
                "title": current_title or name,
                "plot": plot,
                "icon": s.get("stream_icon", ""),
                "profile_num": pnum,
            }
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
            )
        elif stype == "movie":
            sid = str(s.get("stream_id", ""))
            play_url = IPTV.build_xtream_stream_url(url, user, pwd, s, "movie")
            info = _enrich_movie_info(s, url, user, pwd)
            label = name
            li = xbmcgui.ListItem(label=label)
            info_tag = li.getVideoInfoTag()
            info_tag.setMediaType("movie")
            info_tag.setTitle(name)
            header_parts = []
            if info.get("year"):
                header_parts.append(info["year"])
            if info.get("genre"):
                header_parts.append(info["genre"])
            header = " | ".join(header_parts)
            plot = info["plot"]
            if info.get("cast"):
                cast_names = ", ".join([a["name"] for a in info["cast"][:5]])
                if header:
                    plot = f"{header}\n[COLOR gray]Cast: {cast_names}[/COLOR]\n\n{plot}"
                else:
                    plot = f"[COLOR gray]Cast: {cast_names}[/COLOR]\n\n{plot}"
            elif header:
                plot = f"{header}\n\n{plot}"
            info_tag.setPlot(plot)
            if info["rating"]:
                try:
                    info_tag.setRating(float(info["rating"]))
                except Exception as e:
                    _log(f"setRating warning: {e}")
            if info["year"]:
                try:
                    info_tag.setYear(int(info["year"]))
                except Exception as e:
                    _log(f"setYear warning: {e}")
            if info.get("duration"):
                try:
                    info_tag.setDuration(int(info["duration"]))
                except Exception as e:
                    _log(f"setDuration warning: {e}")
            if info.get("cast"):
                cast_list = []
                for idx, actor in enumerate(info["cast"][:10]):  # Top 10 actors
                    name = actor.get("name", "")
                    role = actor.get("role", "")
                    thumbnail = actor.get("thumbnail", "")
                    cast_list.append(xbmc.Actor(name, role, idx, thumbnail))
                if cast_list:
                    info_tag.setCast(cast_list)
            art = {}
            if info["poster_url"]:
                art["icon"] = info["poster_url"]
                art["thumb"] = info["poster_url"]
                art["poster"] = info["poster_url"]
            else:
                art["icon"] = "DefaultMovies.png"
            li.setArt(art)
            li.setProperty("IsPlayable", "true")
            _prepare_playback_item(li)
            ctx = _build_favorite_ctx(
                sid, name, "movie", s.get("stream_icon", ""), play_url,
                profile_num=pnum, year=info.get("year", "")
            )
            li.addContextMenuItems(ctx)
            q = {
                "mode": "source_select",
                "title": name,
                "year": info.get("year", ""),
                "tmdb_type": "movie",
            }
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
            )
        elif stype == "series":
            sid = str(s.get("series_id", ""))
            info = _enrich_series_info(s, url, user, pwd)
            label = name
            q = {"mode": "xtream_series", "series_id": sid, "profile_num": pnum}
            series_url = build_url(q)
            li = xbmcgui.ListItem(label=label)
            info_tag = li.getVideoInfoTag()
            info_tag.setMediaType("tvshow")
            info_tag.setTitle(name)
            header_parts = []
            if info.get("year"):
                header_parts.append(info["year"])
            if info.get("genre"):
                header_parts.append(info["genre"])
            header = " | ".join(header_parts)
            plot = info["plot"]
            if info.get("cast"):
                cast_names = ", ".join([a["name"] for a in info["cast"][:5]])
                if header:
                    plot = f"{header}\n[COLOR gray]Cast: {cast_names}[/COLOR]\n\n{plot}"
                else:
                    plot = f"[COLOR gray]Cast: {cast_names}[/COLOR]\n\n{plot}"
            elif header:
                plot = f"{header}\n\n{plot}"
            info_tag.setPlot(plot)
            if info["rating"]:
                try:
                    info_tag.setRating(float(info["rating"]))
                except Exception as e:
                    _log(f"setRating warning: {e}")
            if info["year"]:
                try:
                    info_tag.setYear(int(info["year"]))
                except Exception as e:
                    _log(f"setYear warning: {e}")
            if info.get("cast"):
                cast_list = []
                for idx, actor in enumerate(info["cast"][:10]):  # Top 10 actors
                    name = actor.get("name", "")
                    role = actor.get("role", "")
                    thumbnail = actor.get("thumbnail", "")
                    cast_list.append(xbmc.Actor(name, role, idx, thumbnail))
                if cast_list:
                    info_tag.setCast(cast_list)
            art = {}
            if info["poster_url"]:
                art["icon"] = info["poster_url"]
                art["thumb"] = info["poster_url"]
                art["poster"] = info["poster_url"]
            li.setArt(art)
            ctx = _build_favorite_ctx(
                sid, name, "series", s.get("stream_icon", ""), series_url,
                profile_num=pnum
            )
            li.addContextMenuItems(ctx)
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url=series_url, listitem=li, isFolder=True
            )

    if end < total:
        li = xbmcgui.ListItem(
            label="[COLOR yellow]{0} ({1})[/COLOR]".format(_t(30232), page + 1)
        )
        art = {"icon": "DefaultFolder.png"}
        _fanart = _addon_fanart()
        if _fanart:
            art["fanart"] = _fanart
        li.setArt(art)
        q = {
            "mode": "xtream_streams",
            "type": stype,
            "cat_id": cat_id,
            "page": page + 1,
        }
        if adult:
            q["adult"] = adult
        if profile_num is not None:
            q["profile_num"] = profile_num
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=True
        )

    _apply_sort(stype)
    xbmcplugin.endOfDirectory(addon_handle)


def xtream_series(series_id, profile_num=None):
    pnum = profile_num or pm.active
    creds = _get_credentials_for_profile(pnum)
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    info = IPTV.get_xtream_series_info(url, user, pwd, series_id)
    episodes = info.get("episodes", {})
    xbmcplugin.setContent(addon_handle, "tvshows")

    # Provider metadata toggles for series
    use_prov = addon.getSetting("provider_series_enabled").lower() == "true"
    use_prov_plot = use_prov and (addon.getSetting("provider_series_plot").lower() == "true")
    use_prov_posters = use_prov and (addon.getSetting("provider_series_posters").lower() == "true")
    use_prov_ratings = use_prov and (addon.getSetting("provider_series_ratings").lower() == "true")
    use_prov_cast = use_prov and (addon.getSetting("provider_series_cast").lower() == "true")
    use_prov_genre = use_prov and (addon.getSetting("provider_series_genre").lower() == "true")
    use_prov_duration = use_prov and (addon.getSetting("provider_series_duration").lower() == "true")

    # Look up series metadata from API response or series list cache
    series_meta = info.get("info", {}) or {}
    if not series_meta.get("plot") and not series_meta.get("cover"):
        # Fall back to series list cache
        try:
            all_series = _cache_load("xtream_streams_series") or []
            for s in all_series:
                if str(s.get("series_id", "")) == str(series_id):
                    series_meta = s
                    break
        except Exception:
            pass  # Cache may be missing or corrupt; will be re-fetched

    # Extract metadata fields (respecting toggles)
    series_plot = ""
    if use_prov_plot:
        series_plot = (
            series_meta.get("plot", "")
            or series_meta.get("description", "")
            or info.get("plot", "")
            or ""
        )

    series_cover = ""
    if use_prov_posters:
        series_cover = (
            series_meta.get("cover", "")
            or series_meta.get("stream_icon", "")
            or series_meta.get("poster_url", "")
            or ""
        )

    series_backdrop = ""
    if use_prov_posters:
        bp = series_meta.get("backdrop_path", []) or info.get("backdrop_path", [])
        if bp and isinstance(bp, list) and bp[0]:
            series_backdrop = bp[0]

    series_rating = ""
    if use_prov_ratings:
        series_rating = str(
            series_meta.get("rating", "") or series_meta.get("rating_5based", "") or ""
        )

    series_year = ""
    if use_prov_ratings:
        release_raw = (
            series_meta.get("releaseDate", "")
            or series_meta.get("release_date", "")
            or series_meta.get("releasedate", "")
            or ""
        )
        if release_raw:
            series_year = str(release_raw)[:4]

    series_genre = ""
    if use_prov_genre:
        series_genre = series_meta.get("genre", "") or ""

    series_cast_str = ""
    if use_prov_cast:
        series_cast_str = series_meta.get("cast", "") or ""

    # episode_run_time may be string "60" or integer
    ep_run_time = 0
    if use_prov_duration:
        ep_run_time_raw = series_meta.get("episode_run_time", "") or ""
        try:
            ep_run_time = int(ep_run_time_raw) if ep_run_time_raw else 0
        except (ValueError, TypeError):
            ep_run_time = 0

    show_counts = addon.getSetting("show_content_counts").lower() == "true"
    ctx = []
    for season_num in sorted(
        episodes.keys(), key=lambda x: (0, int(x)) if str(x).isdigit() else (1, str(x))
    ):
        eps = episodes.get(season_num, [])
        total_eps = len(eps)
        if show_counts and total_eps:
            label = f"{_t(30541, season_num)}  [COLOR gray]({total_eps})[/COLOR]"
        else:
            label = _t(30541, season_num)
        li = xbmcgui.ListItem(label=label)
        info_tag = li.getVideoInfoTag()
        info_tag.setMediaType("season")
        info_tag.setTitle(label)

        # Plot
        if use_prov_plot and series_plot:
            info_tag.setPlot(series_plot)

        # Season / Episode count
        try:
            info_tag.setSeason(int(season_num))
        except (ValueError, TypeError):
            pass
        if total_eps and show_counts:
            info_tag.setEpisode(total_eps)

        # Year
        if use_prov_ratings and series_year:
            try:
                info_tag.setYear(int(series_year))
            except (ValueError, TypeError):
                pass

        # Rating
        if use_prov_ratings and series_rating:
            try:
                info_tag.setRating(float(series_rating))
            except (ValueError, TypeError):
                pass

        # Cast
        if use_prov_cast and series_cast_str:
            cast_list = []
            for idx, actor_name in enumerate(series_cast_str.split(",")[:10]):
                name = actor_name.strip()
                if name:
                    cast_list.append(xbmc.Actor(name, "", idx, ""))
            if cast_list:
                info_tag.setCast(cast_list)

        # Duration: episode_run_time (minutes) * episode count, or sum of episode durations
        if use_prov_duration:
            season_duration = 0
            if ep_run_time and total_eps:
                season_duration = ep_run_time * total_eps * 60
            else:
                for ep in eps:
                    d = int(ep.get("info", {}).get("duration_secs", 0) or 0)
                    season_duration += d
            if season_duration:
                try:
                    info_tag.setDuration(int(season_duration))
                except Exception as e:
                    _log(f"setDuration warning (season): {e}")

        # Art
        art = {}
        if use_prov_posters and series_cover:
            art["icon"] = series_cover
            art["thumb"] = series_cover
            art["poster"] = series_cover
        else:
            art["icon"] = "DefaultFolder.png"
        if use_prov_posters and series_backdrop:
            art["fanart"] = series_backdrop
        li.setArt(art)

        li.addContextMenuItems(ctx)
        q = {
            "mode": "xtream_season",
            "series_id": series_id,
            "season_num": season_num,
            "profile_num": pnum,
        }
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=True
        )

    xbmcplugin.endOfDirectory(addon_handle)


def xtream_season(series_id, season_num, profile_num=None):
    pnum = profile_num or pm.active
    creds = _get_credentials_for_profile(pnum)
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    info = IPTV.get_xtream_series_info(url, user, pwd, series_id)
    eps = info.get("episodes", {}).get(season_num, [])
    xbmcplugin.setContent(addon_handle, "episodes")

    # Provider metadata toggles for series
    use_prov = addon.getSetting("provider_series_enabled").lower() == "true"
    use_prov_plot = use_prov and (addon.getSetting("provider_series_plot").lower() == "true")
    use_prov_posters = use_prov and (addon.getSetting("provider_series_posters").lower() == "true")
    use_prov_duration = use_prov and (addon.getSetting("provider_series_duration").lower() == "true")
    for ep in eps:
        ep_id = str(ep.get("id", ""))
        title = ep.get("title") or _t(30542, ep.get("episode_num", "?"))
        label = title
        li = xbmcgui.ListItem(label=label)
        info_tag = li.getVideoInfoTag()
        info_tag.setMediaType("episode")
        info_tag.setTitle(title)

        # Plot
        if use_prov_plot:
            info_tag.setPlot(ep.get("info", {}).get("plot", ""))

        # Duration: provider first, then playback-discovered fallback
        ep_duration = 0
        if use_prov_duration:
            ep_duration = int(ep.get("info", {}).get("duration_secs", 0) or 0)
        if not ep_duration and use_prov_duration and ep_id:
            pb_dur = _load_playback_duration(ep_id)
            if pb_dur:
                ep_duration = pb_dur
        if ep_duration:
            try:
                info_tag.setDuration(ep_duration)
            except Exception as e:
                _log(f"setDuration warning: {e}")

        movie_image = ep.get("info", {}).get("movie_image")
        if use_prov_posters and movie_image:
            li.setArt({"icon": movie_image, "thumb": movie_image})
        li.setProperty("IsPlayable", "true")
        _prepare_playback_item(li)
        play_url = IPTV.build_xtream_stream_url(url, user, pwd, ep, "series")
        ctx = _build_favorite_ctx(
            ep_id, title, "series", movie_image or "", play_url,
            profile_num=profile_num,
            showname=info.get("info", {}).get("name", ""),
            season=season_num,
            episode=ep.get("episode_num", "")
        )
        li.addContextMenuItems(ctx)
        series_name = info.get("info", {}).get("name", "")
        q = {
            "mode": "source_select",
            "showname": series_name,
            "title": title,
            "season": season_num,
            "episode": ep.get("episode_num", ""),
            "tmdb_type": "tv",
        }
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
        )

    xbmcplugin.endOfDirectory(addon_handle)


def movies_menu(profile_num=None):
    original_active = pm.active
    if profile_num:
        pm.active = str(profile_num)
    if profile_num and not _prompt_profile_credentials(profile_num):
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
        return
    try:
        creds = _get_credentials()
        xtream_url = creds.get("xtream_url", "")
        if xtream_url:
            li = xbmcgui.ListItem(label="[COLOR yellow]{0}[/COLOR]".format(_t(30007)))
            li.setArt({"icon": "DefaultAddonsSearch.png"})
            xbmcplugin.addDirectoryItem(
                handle=addon_handle,
                url=build_url(
                    {
                        "mode": "search_global",
                        "profile_num": profile_num,
                        "stype": "movie",
                    }
                ),
                listitem=li,
                isFolder=True,
            )
            xtream_categories("movie", profile_num)
        else:
            li = xbmcgui.ListItem(label=_t(30241))
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url="", listitem=li, isFolder=False
            )
            xbmcplugin.endOfDirectory(addon_handle)
    finally:
        if profile_num:
            pm.active = original_active


def series_menu(profile_num=None):
    original_active = pm.active
    if profile_num:
        pm.active = str(profile_num)
    if profile_num and not _prompt_profile_credentials(profile_num):
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
        return
    try:
        creds = _get_credentials()
        xtream_url = creds.get("xtream_url", "")
        if xtream_url:
            li = xbmcgui.ListItem(label="[COLOR yellow]{0}[/COLOR]".format(_t(30007)))
            li.setArt({"icon": "DefaultAddonsSearch.png"})
            xbmcplugin.addDirectoryItem(
                handle=addon_handle,
                url=build_url(
                    {
                        "mode": "search_global",
                        "profile_num": profile_num,
                        "stype": "series",
                    }
                ),
                listitem=li,
                isFolder=True,
            )
            xtream_categories("series", profile_num)
        else:
            li = xbmcgui.ListItem(label=_t(30242))
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url="", listitem=li, isFolder=False
            )
            xbmcplugin.endOfDirectory(addon_handle)
    finally:
        if profile_num:
            pm.active = original_active


def replay_xtream_categories(profile_num=None):
    pnum = profile_num or pm.active
    creds = _get_credentials_for_profile(pnum)
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    cats = _get_cached_xtream_categories(url, user, pwd, "live")

    # Load all live streams once to compute catchup-enabled category sets/counts
    all_streams = _get_cached_xtream_streams(url, user, pwd, "live", None)
    catchup_cat_ids = set()
    cat_counts = {}
    for s in all_streams:
        if not _truthy_catchup(s.get("tv_archive")) and not _truthy_catchup(s.get("catchup")):
            continue
        cid = str(s.get("category_id", ""))
        if not cid:
            continue
        catchup_cat_ids.add(cid)
        cat_counts[cid] = cat_counts.get(cid, 0) + 1

    show_counts = addon.getSetting("show_content_counts").lower() == "true"
    hide_adult = addon.getSetting("hide_adult_categories").lower() == "true"
    adult_locked = _is_adult_locked("live")
    hidden_subcats = _get_hidden_subcats("live", pnum)

    for c in cats:
        name = c.get("category_name", "Unknown")
        cat_id = str(c.get("category_id", ""))
        if cat_id not in catchup_cat_ids:
            continue
        if cat_id in hidden_subcats:
            continue
        if hide_adult and _is_adult_category(name):
            continue
        is_adult = _is_adult_category(name)
        count = cat_counts.get(cat_id, 0)
        if show_counts:
            display = f"{name}  [COLOR gray]({count})[/COLOR]"
        else:
            display = name
        if is_adult and adult_locked:
            display = f"[COLOR red]🔒[/COLOR] {display}"
        li = xbmcgui.ListItem(label=display)
        li.setArt({"icon": "DefaultFolder.png"})
        q = {
            "mode": "replay_channels",
            "cat_id": c.get("category_id", ""),
            "adult": "1" if is_adult else "0",
            "profile_num": profile_num,
        }
        cat_url = build_url(q)
        ctx = _folder_favorite_ctx(name, cat_url, "DefaultFolder.png")
        li.addContextMenuItems(ctx)
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=cat_url, listitem=li, isFolder=True
        )
    # _apply_sort is intentionally skipped — category folders stay in provider order
    xbmcplugin.endOfDirectory(addon_handle)


def replay_menu(profile_num=None):
    pnum = profile_num or pm.active
    if profile_num and not _prompt_profile_credentials(profile_num):
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
        return
    original_active = pm.active
    if profile_num:
        pm.active = str(profile_num)
    try:
        creds = _get_credentials()
        xtream_url = creds.get("xtream_url", "")
        m3u_url = creds.get("m3u_url", "")
        has_m3u_with_creds = bool(m3u_url) and _m3u_has_credentials(m3u_url)

        if xtream_url or has_m3u_with_creds:
            replay_xtream_categories(profile_num)
            return

        # M3U-simple fallback (currently unreachable from profile_menu for replay)
        if m3u_url:
            m3u_channels = _get_cached_m3u_channels(m3u_url)
            for ch in m3u_channels:
                if not _truthy_catchup(ch.get("catchup")) and not ch.get("catchup_source"):
                    continue
                name = ch.get("name", "Unknown")
                li = xbmcgui.ListItem(label=f"[M3U] {name}")
                if ch.get("logo"):
                    li.setArt({"icon": ch["logo"], "thumb": ch["logo"]})
                q = {
                    "mode": "replay_channel_m3u",
                    "channel_name": name,
                    "epg_id": ch.get("tvg_id", ""),
                    "channel_url": ch.get("url", ""),
                    "catchup": ch.get("catchup", ""),
                    "catchup_source": ch.get("catchup_source", ""),
                    "logo": ch.get("logo", ""),
                    "profile_num": pnum,
                }
                xbmcplugin.addDirectoryItem(
                    handle=addon_handle, url=build_url(q), listitem=li, isFolder=True
                )

        _apply_sort("replay")
        xbmcplugin.endOfDirectory(addon_handle)
    finally:
        if profile_num:
            pm.active = original_active


def replay_channels(cat_id, page=1, adult="0", profile_num=None):
    if adult == "1" and _is_adult_locked("live"):
        if not _check_pin("Adult Content"):
            xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
            return
    pnum = profile_num or pm.active
    creds = _get_credentials_for_profile(pnum)
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    streams = _get_cached_xtream_streams(url, user, pwd, "live", cat_id)

    # Filter catchup-enabled only
    streams = [
        s for s in streams
        if _truthy_catchup(s.get("tv_archive")) or _truthy_catchup(s.get("catchup"))
    ]

    hidden_items = _get_hidden_items("live", profile_num)
    if hidden_items:
        streams = [
            s for s in streams if str(s.get("stream_id", "")) not in hidden_items
        ]

    if addon.getSetting("sort_order_replay") == "Newest first":
        try:
            streams.sort(
                key=lambda s: int(s.get("added", 0) or 0), reverse=True
            )
        except Exception:
            pass

    # Pagination
    per_page = _get_pagination_limit("live")
    if per_page == 0:
        per_page = len(streams)
    total = len(streams)
    start = (page - 1) * per_page
    end = start + per_page
    page_streams = streams[start:end]

    xbmcplugin.setContent(addon_handle, "livetv")
    for s in page_streams:
        name = s.get("name", "Unknown")
        li = xbmcgui.ListItem(label=name)
        if s.get("stream_icon"):
            li.setArt({"icon": s["stream_icon"], "thumb": s["stream_icon"]})
        q = {
            "mode": "replay_channel",
            "stream_id": str(s.get("stream_id", "")),
            "epg_id": s.get("epg_channel_id") or str(s.get("stream_id", "")),
            "name": name,
            "profile_num": pnum,
        }
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=True
        )

    if end < total:
        li = xbmcgui.ListItem(
            label="[COLOR yellow]{0} ({1})[/COLOR]".format(_t(30232), page + 1)
        )
        art = {"icon": "DefaultFolder.png"}
        _fanart = _addon_fanart()
        if _fanart:
            art["fanart"] = _fanart
        li.setArt(art)
        q = {
            "mode": "replay_channels",
            "cat_id": cat_id,
            "adult": adult,
            "profile_num": profile_num,
            "page": page + 1,
        }
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url=build_url(q), listitem=li, isFolder=True
        )

    _apply_sort("replay")
    xbmcplugin.endOfDirectory(addon_handle)


def replay_channel(stream_id, epg_id, name="", profile_num=None):
    pnum = profile_num or pm.active
    epg = EPG(addon, profile_num=pnum)
    epg.load()
    try:
        days_back = int(addon.getSetting("replay_days") or "7")
    except ValueError:
        days_back = 7
    programs = epg.get_programs_for_channel(
        epg_id, channel_name=name, days_back=days_back
    )

    if not programs:
        now = datetime.datetime.now()
        for hours_back in range(1, 25):
            start = now - datetime.timedelta(hours=hours_back)
            start_fmt = start.strftime("%Y-%m-%d:%H-%M")
            title = f"{start.strftime('%d/%m %H:%M')} - {_t(30543)}"
            li = xbmcgui.ListItem(label=title)
            info_tag = li.getVideoInfoTag()
            info_tag.setMediaType("video")
            info_tag.setTitle(title)
            info_tag.setDuration(3600)
            li.setProperty("IsPlayable", "true")
            _prepare_playback_item(li)
            q = {
                "mode": "replay_play",
                "stream_id": stream_id,
                "start": start_fmt,
                "duration": 3600,
                "profile_num": pnum,
            }
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
            )
    else:
        for prog in programs:
            if _parse_xmltv_time(prog["start"]) > time.time():
                continue
            title = f"{prog['start_str']} - {prog['title']}"
            li = xbmcgui.ListItem(label=title)
            info_tag = li.getVideoInfoTag()
            info_tag.setMediaType("video")
            info_tag.setTitle(prog["title"])
            info_tag.setPlot(prog.get("desc", ""))
            info_tag.setDuration(int(prog["duration_sec"]))
            li.setProperty("IsPlayable", "true")
            _prepare_playback_item(li)
            q = {
                "mode": "replay_play",
                "stream_id": stream_id,
                "start": prog["start_timestamp"],
                "duration": prog["duration_sec"],
                "profile_num": pnum,
            }
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
            )

    xbmcplugin.endOfDirectory(addon_handle)


def replay_play(stream_id, start, duration, profile_num=None):
    pnum = profile_num or pm.active
    creds = _get_credentials_for_profile(pnum)
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    try:
        duration = int(duration)
    except (ValueError, TypeError):
        duration = 3600
    play_url = IPTV.build_catchup_url(url, user, pwd, stream_id, start, duration)
    li = xbmcgui.ListItem(path=play_url)
    li.setProperty("IsPlayable", "true")
    info_tag = li.getVideoInfoTag()
    info_tag.setMediaType("video")
    info_tag.setTitle(f"Replay {stream_id}")
    _prepare_playback_item(li)
    xbmcplugin.setResolvedUrl(addon_handle, True, listitem=li)


def replay_channel_m3u(
    channel_name, epg_id, channel_url, catchup, catchup_source, logo, profile_num=None
):
    """Show replay/catchup programs for an M3U-based channel."""
    pnum = profile_num or pm.active
    epg = EPG(addon, profile_num=pnum)
    epg.load()
    try:
        days_back = int(addon.getSetting("replay_days") or "7")
    except ValueError:
        days_back = 7

    programs = epg.get_programs_for_channel(
        epg_id, channel_name=channel_name, days_back=days_back
    )

    if not programs:
        # No EPG data - show hourly slots going back

        now = datetime.datetime.now()
        for hours_back in range(1, 25):
            start = now - datetime.timedelta(hours=hours_back)
            start_ts = int(start.timestamp())
            end_ts = start_ts + 3600
            start_fmt = start.strftime("%Y-%m-%d:%H-%M")
            title = f"{start.strftime('%d/%m %H:%M')} - {_t(30543)}"
            li = xbmcgui.ListItem(label=title)
            info_tag = li.getVideoInfoTag()
            info_tag.setMediaType("video")
            info_tag.setTitle(title)
            info_tag.setDuration(3600)
            li.setProperty("IsPlayable", "true")
            _prepare_playback_item(li)
            q = {
                "mode": "replay_play_m3u",
                "channel_name": channel_name,
                "channel_url": channel_url,
                "catchup": catchup,
                "catchup_source": catchup_source,
                "logo": logo,
                "start_ts": str(start_ts),
                "end_ts": str(end_ts),
                "profile_num": pnum,
            }
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
            )
    else:
        for prog in programs:
            title = f"{prog['start_str']} - {prog['title']}"
            li = xbmcgui.ListItem(label=title)
            info_tag = li.getVideoInfoTag()
            info_tag.setMediaType("video")
            info_tag.setTitle(prog["title"])
            info_tag.setPlot(prog.get("desc", ""))
            info_tag.setDuration(int(prog["duration_sec"]))
            li.setProperty("IsPlayable", "true")
            _prepare_playback_item(li)
            q = {
                "mode": "replay_play_m3u",
                "channel_name": channel_name,
                "channel_url": channel_url,
                "catchup": catchup,
                "catchup_source": catchup_source,
                "logo": logo,
                "start_ts": str(prog["start_timestamp"]),
                "end_ts": str(prog["start_timestamp"] + prog["duration_sec"]),
                "profile_num": pnum,
            }
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
            )

    xbmcplugin.endOfDirectory(addon_handle)


def replay_play_m3u(
    channel_name,
    channel_url,
    catchup,
    catchup_source,
    logo,
    start_ts,
    end_ts,
    profile_num=None,
):
    """Play M3U catchup stream."""
    channel_data = {
        "name": channel_name,
        "url": channel_url,
        "catchup": catchup,
        "catchup_source": catchup_source,
    }
    try:
        start_utc = int(start_ts)
        end_utc = int(end_ts)
    except (ValueError, TypeError):
        xbmcgui.Dialog().notification(
            "Xstream Pro", _t(30730), xbmcgui.NOTIFICATION_ERROR
        )
        return

    play_url = IPTV.build_m3u_catchup_url(channel_data, start_utc, end_utc)
    if not play_url:
        xbmcgui.Dialog().notification(
            "Xstream Pro", _t(30731), xbmcgui.NOTIFICATION_ERROR
        )
        return

    li = xbmcgui.ListItem(path=play_url)
    li.setProperty("IsPlayable", "true")
    info_tag = li.getVideoInfoTag()
    info_tag.setMediaType("video")
    info_tag.setTitle(f"Replay: {channel_name}")
    if logo:
        li.setArt({"icon": logo, "thumb": logo})
    _prepare_playback_item(li)
    xbmcplugin.setResolvedUrl(addon_handle, True, listitem=li)


def _search_history_path(profile_num=None):
    profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    if profile_num is None:
        return os.path.join(profile, "search_history.json")
    return os.path.join(profile, f"search_history_p{profile_num}.json")


def _load_search_history(profile_num=None):
    path = _search_history_path(profile_num)
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return []


def _save_search_history(history, profile_num=None):
    history_path = _search_history_path(profile_num)
    tmp_file = history_path + ".tmp"
    with open(tmp_file, "w", encoding="utf-8") as f:
        json.dump(history[:10], f, ensure_ascii=False)
        try:
            f.flush()
            _safe_fsync(f)
        except OSError:
            pass
    os.replace(tmp_file, history_path)


def search_global(query=None, profile_num=None, stype=None):
    try:
        # If profile not specified, ask user which profile to search
        if profile_num is None:
            profiles = []
            for i in range(1, 11):
                if addon.getSetting(f"profile_{i}_enabled") == "true":
                    name = addon.getSetting(f"profile_{i}_name") or _t(30390, i)
                    profiles.append((i, name))
            if not profiles:
                xbmcgui.Dialog().notification(_t(30770), _t(30074))
                xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
                return
            options = [_t(30771)]  # All Profiles
            options.extend([p[1] for p in profiles])
            idx = xbmcgui.Dialog().select(_t(30140), options)
            if idx < 0:
                xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
                return
            if idx == 0:
                # All Profiles
                if query is None:
                    history = _load_search_history()
                    if history:
                        hist_options = [_t(30544)] + history
                        idx2 = xbmcgui.Dialog().select(_t(30140), hist_options)
                        if idx2 < 0:
                            xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
                            return
                        if idx2 == 0:
                            kb = xbmcgui.Dialog().input(
                                _t(30140), type=xbmcgui.INPUT_ALPHANUM
                            )
                            if not kb:
                                xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
                                return
                            query = kb
                        else:
                            query = history[idx2 - 1]
                    else:
                        kb = xbmcgui.Dialog().input(
                            _t(30140), type=xbmcgui.INPUT_ALPHANUM
                        )
                        if not kb:
                            xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
                            return
                        query = kb
                history = _load_search_history()
                if query in history:
                    history.remove(query)
                history.insert(0, query)
                _save_search_history(history)
                search_all_profiles(query)
                return
            else:
                profile_num = profiles[idx - 1][0]

        original_active = pm.active
        pm.active = str(profile_num)
        try:
            if query is None:
                history = _load_search_history()
                if history:
                    options = [_t(30544)] + history
                    idx = xbmcgui.Dialog().select(_t(30140), options)
                    if idx < 0:
                        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
                        return
                    if idx == 0:
                        kb = xbmcgui.Dialog().input(
                            _t(30140), type=xbmcgui.INPUT_ALPHANUM
                        )
                        if not kb:
                            xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
                            return
                        query = kb
                    else:
                        query = history[idx - 1]
                else:
                    kb = xbmcgui.Dialog().input(_t(30140), type=xbmcgui.INPUT_ALPHANUM)
                    if not kb:
                        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
                        return
                    query = kb
            history = _load_search_history()
            if query in history:
                history.remove(query)
            history.insert(0, query)
            _save_search_history(history)

            creds = _get_credentials()
            has_xtream = bool(creds.get("xtream_url"))
            has_m3u = bool(creds.get("m3u_url"))

            if has_xtream:
                unified_search(query, stype=stype)
            elif has_m3u:
                search_m3u(query, stype=stype)
            else:
                xbmcgui.Dialog().notification(_t(30770), _t(30223))
                xbmcplugin.endOfDirectory(addon_handle)
        finally:
            pm.active = original_active
    except Exception as e:
        _log(f"search_global error: {e}")

        _log(f"Traceback: {traceback.format_exc()}")
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)


def unified_search(query, stype=None):
    creds = _get_credentials()
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    qlower = query.lower()
    show_epg = False
    hide_adult = addon.getSetting("hide_adult_categories").lower() == "true"

    stype_labels = {
        "live": _t(30220),
        "movie": _t(30004),
        "series": _t(30005),
    }
    stype_icons = {
        "live": "DefaultAddonPVRClient.png",
        "movie": "DefaultMovies.png",
        "series": "DefaultTVShows.png",
    }

    results = {}

    if stype is None or stype == "live":
        live_streams = _get_cached_xtream_streams(url, user, pwd, "live")
        live_results = [s for s in live_streams if qlower in s.get("name", "").lower()]
        if hide_adult:
            live_results = [
                s
                for s in live_results
                if not _is_adult_category(
                    s.get("name", "") + " " + s.get("category_name", "")
                )
            ]
        results["live"] = live_results

    if stype is None or stype == "movie":
        movie_streams = _get_cached_xtream_streams(url, user, pwd, "movie")
        movie_results = [
            s for s in movie_streams if qlower in s.get("name", "").lower()
        ]
        if hide_adult:
            movie_results = [
                s
                for s in movie_results
                if not _is_adult_category(
                    s.get("name", "") + " " + s.get("category_name", "")
                )
            ]
        results["movie"] = movie_results

    if stype is None or stype == "series":
        series_streams = _get_cached_xtream_streams(url, user, pwd, "series")
        series_results = [
            s for s in series_streams if qlower in s.get("name", "").lower()
        ]
        if hide_adult:
            series_results = [
                s
                for s in series_results
                if not _is_adult_category(
                    s.get("name", "") + " " + s.get("category_name", "")
                )
            ]
        results["series"] = series_results

    has_any = any(results.values())
    if not has_any:
        li = xbmcgui.ListItem(label=_t(30223))
        xbmcplugin.addDirectoryItem(
            handle=addon_handle, url="", listitem=li, isFolder=False
        )
        xbmcplugin.endOfDirectory(addon_handle)
        return

    show_separators = len([v for v in results.values() if v]) > 1 and stype is None
    type_order = ["live", "movie", "series"]

    for t in type_order:
        if t not in results or not results[t]:
            continue
        if show_separators:
            sep = xbmcgui.ListItem(
                label=f"[COLOR gray]────── {stype_labels.get(t, t)} ──────[/COLOR]"
            )
            sep.setArt({"icon": stype_icons.get(t, "DefaultFolder.png")})
            sep.setProperty("IsPlayable", "false")
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url="", listitem=sep, isFolder=False
            )
        for s in results[t]:
            name = s.get("name", "Unknown")
            sid = str(s.get("stream_id", ""))
            if t == "live":
                epg_id = s.get("epg_channel_id") or sid
                play_url = IPTV.build_xtream_stream_url(url, user, pwd, s, "live")
                plot, display_name, current_title = (
                    _make_epg_info(epg, epg_id, name) if show_epg else ("", name, "")
                )
                li = xbmcgui.ListItem(label=display_name)
                info_tag = li.getVideoInfoTag()
                info_tag.setMediaType("video")
                info_tag.setTitle(current_title or name)
                info_tag.setPlot(plot)
                if s.get("stream_icon"):
                    li.setArt(
                        {
                            "icon": s["stream_icon"],
                            "thumb": s["stream_icon"],
                            "poster": s["stream_icon"],
                        }
                    )
                li.setProperty("IsPlayable", "true")
                _prepare_playback_item(li)
                _set_live_props(li)
                ctx = []
                li.addContextMenuItems(ctx)
                q = {
                    "mode": "play_stream",
                    "url": play_url,
                    "name": name,
                    "title": current_title or name,
                    "plot": plot,
                    "icon": s.get("stream_icon", ""),
                    "profile_num": pm.active,
                }
                xbmcplugin.addDirectoryItem(
                    handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
                )
            elif t == "movie":
                play_url = IPTV.build_xtream_stream_url(url, user, pwd, s, "movie")
                info = _enrich_movie_info(s, url, user, pwd)
                li = xbmcgui.ListItem(label=name)
                info_tag = li.getVideoInfoTag()
                info_tag.setMediaType("movie")
                info_tag.setTitle(name)
                plot = info.get("plot", "")
                info_tag.setPlot(plot)
                art = {}
                if info.get("poster_url"):
                    art["icon"] = info["poster_url"]
                    art["thumb"] = info["poster_url"]
                    art["poster"] = info["poster_url"]
                else:
                    art["icon"] = "DefaultMovies.png"
                li.setArt(art)
                li.setProperty("IsPlayable", "true")
                _prepare_playback_item(li)
                ctx = []
                li.addContextMenuItems(ctx)
                q = {
                    "mode": "play_stream",
                    "url": play_url,
                    "name": name,
                    "title": name,
                    "plot": plot,
                    "icon": s.get("stream_icon", ""),
                    "stype": "movie",
                    "profile_num": pm.active,
                }
                xbmcplugin.addDirectoryItem(
                    handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
                )
            elif t == "series":
                q_series = {
                    "mode": "xtream_series",
                    "series_id": sid,
                    "profile_num": pm.active,
                }
                li = xbmcgui.ListItem(label=name)
                info_tag = li.getVideoInfoTag()
                info_tag.setMediaType("tvshow")
                info_tag.setTitle(name)
                if s.get("cover"):
                    li.setArt(
                        {"icon": s["cover"], "thumb": s["cover"], "poster": s["cover"]}
                    )
                ctx = []
                li.addContextMenuItems(ctx)
                xbmcplugin.addDirectoryItem(
                    handle=addon_handle,
                    url=build_url(q_series),
                    listitem=li,
                    isFolder=True,
                )

    xbmcplugin.endOfDirectory(addon_handle)


def search_m3u(query=None, stype=None):
    try:
        if query is None:
            kb = xbmcgui.Dialog().input(_t(30141), type=xbmcgui.INPUT_ALPHANUM)
            if not kb:
                xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
                return
            query = kb
        if stype in ("movie", "series"):
            li = xbmcgui.ListItem(label=_t(30223))
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url="", listitem=li, isFolder=False
            )
            xbmcplugin.endOfDirectory(addon_handle)
            return
        show_epg = False
        creds = _get_credentials()
        channels = _get_cached_m3u_channels(creds.get("m3u_url", ""))
        qlower = query.lower()
        for ch in channels:
            if (
                qlower not in ch.get("name", "").lower()
                and qlower not in ch.get("group", "").lower()
            ):
                continue
            name = ch.get("name", "Unknown")
            tvg_id = ch.get("tvg_id") or name
            url = ch.get("url")
            item_id = ch.get("tvg_id") or url
            plot, display_name, current_title = (
                _make_epg_info(epg, tvg_id, name) if show_epg else ("", name, "")
            )
            li = xbmcgui.ListItem(label=display_name)
            info_tag = li.getVideoInfoTag()
            info_tag.setMediaType("video")
            info_tag.setTitle(current_title or name)
            info_tag.setPlot(plot)
            if ch.get("logo"):
                li.setArt({"icon": ch["logo"], "thumb": ch["logo"]})
            li.setProperty("IsPlayable", "true")
            _prepare_playback_item(li)
            _set_live_props(li)
            ctx = []
            li.addContextMenuItems(ctx)
            q = {
                "mode": "play_stream",
                "url": url,
                "name": name,
                "title": current_title or name,
                "plot": plot,
                "icon": ch.get("logo", ""),
                "profile_num": pm.active,
            }
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url=build_url(q), listitem=li, isFolder=False
            )
        _apply_sort("live")
        xbmcplugin.endOfDirectory(addon_handle)
    except Exception as e:
        _log(f"search_m3u error: {e}")

        _log(f"Traceback: {traceback.format_exc()}")
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)


def search_all_profiles(query):
    """Search across all enabled profiles and show results grouped by profile."""
    try:
        hide_adult = addon.getSetting("hide_adult_categories").lower() == "true"

        profiles = []
        for i in range(1, 11):
            if addon.getSetting(f"profile_{i}_enabled") == "true":
                name = addon.getSetting(f"profile_{i}_name") or _t(30390, i)
                profiles.append((i, name))

        if not profiles:
            xbmcgui.Dialog().notification(_t(30770), _t(30074))
            xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
            return

        li = xbmcgui.ListItem(label="[COLOR gold]{0}[/COLOR]".format(_t(30771)))
        li.setArt({"icon": "DefaultFolder.png"})
        xbmcplugin.addDirectoryItem(
            handle=addon_handle,
            url=build_url(
                {
                    "mode": "search_all_combined",
                    "query": query,
                }
            ),
            listitem=li,
            isFolder=True,
        )

        any_results = False
        for profile_num, profile_name in profiles:
            original_active = pm.active
            pm.active = str(profile_num)
            try:
                creds = _get_credentials()
                has_xtream = bool(creds.get("xtream_url"))
                has_m3u = bool(creds.get("m3u_url"))

                if not has_xtream and not has_m3u:
                    continue

                has_results = False
                qlower = query.lower()

                if has_xtream:
                    url = creds.get("xtream_url", "")
                    user = creds.get("xtream_username", "")
                    pwd = creds.get("xtream_password", "")

                    live_streams = _get_cached_xtream_streams(url, user, pwd, "live")
                    live_results = [
                        s for s in live_streams if qlower in s.get("name", "").lower()
                    ]
                    if hide_adult:
                        live_results = [
                            s
                            for s in live_results
                            if not _is_adult_category(
                                s.get("name", "") + " " + s.get("category_name", "")
                            )
                        ]

                    movie_streams = _get_cached_xtream_streams(url, user, pwd, "movie")
                    movie_results = [
                        s for s in movie_streams if qlower in s.get("name", "").lower()
                    ]
                    if hide_adult:
                        movie_results = [
                            s
                            for s in movie_results
                            if not _is_adult_category(
                                s.get("name", "") + " " + s.get("category_name", "")
                            )
                        ]

                    series_streams = _get_cached_xtream_streams(
                        url, user, pwd, "series"
                    )
                    series_results = [
                        s for s in series_streams if qlower in s.get("name", "").lower()
                    ]
                    if hide_adult:
                        series_results = [
                            s
                            for s in series_results
                            if not _is_adult_category(
                                s.get("name", "") + " " + s.get("category_name", "")
                            )
                        ]

                    if live_results or movie_results or series_results:
                        has_results = True

                elif has_m3u:
                    channels = _get_cached_m3u_channels(creds.get("m3u_url", ""))
                    for ch in channels:
                        if (
                            qlower in ch.get("name", "").lower()
                            or qlower in ch.get("group", "").lower()
                        ):
                            has_results = True
                            break

                if has_results:
                    any_results = True
                    li = xbmcgui.ListItem(label=profile_name)
                    li.setArt({"icon": "DefaultAddonPVRClient.png"})
                    xbmcplugin.addDirectoryItem(
                        handle=addon_handle,
                        url=build_url(
                            {
                                "mode": "search_global",
                                "profile_num": profile_num,
                                "query": query,
                            }
                        ),
                        listitem=li,
                        isFolder=True,
                    )
            finally:
                pm.active = original_active

        if not any_results:
            li = xbmcgui.ListItem(label=_t(30223))
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url="", listitem=li, isFolder=False
            )

        xbmcplugin.endOfDirectory(addon_handle)
    except Exception as e:
        _log(f"search_all_profiles error: {e}")

        _log(f"Traceback: {traceback.format_exc()}")
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)


def search_all_profiles_combined(query):
    """Show combined search results from all enabled profiles."""
    try:
        hide_adult = addon.getSetting("hide_adult_categories").lower() == "true"
        show_epg = False

        qlower = query.lower()
        has_any = False

        profiles = []
        for i in range(1, 11):
            if addon.getSetting(f"profile_{i}_enabled") == "true":
                name = addon.getSetting(f"profile_{i}_name") or _t(30390, i)
                profiles.append((i, name))

        for profile_num, profile_name in profiles:
            original_active = pm.active
            pm.active = str(profile_num)
            try:
                creds = _get_credentials()
                has_xtream = bool(creds.get("xtream_url"))
                has_m3u = bool(creds.get("m3u_url"))

                if not has_xtream and not has_m3u:
                    continue

                if has_xtream:
                    url = creds.get("xtream_url", "")
                    user = creds.get("xtream_username", "")
                    pwd = creds.get("xtream_password", "")

                    live_streams = _get_cached_xtream_streams(url, user, pwd, "live")
                    live_results = [
                        s for s in live_streams if qlower in s.get("name", "").lower()
                    ]
                    if hide_adult:
                        live_results = [
                            s
                            for s in live_results
                            if not _is_adult_category(
                                s.get("name", "") + " " + s.get("category_name", "")
                            )
                        ]

                    movie_streams = _get_cached_xtream_streams(url, user, pwd, "movie")
                    movie_results = [
                        s for s in movie_streams if qlower in s.get("name", "").lower()
                    ]
                    if hide_adult:
                        movie_results = [
                            s
                            for s in movie_results
                            if not _is_adult_category(
                                s.get("name", "") + " " + s.get("category_name", "")
                            )
                        ]

                    series_streams = _get_cached_xtream_streams(
                        url, user, pwd, "series"
                    )
                    series_results = [
                        s for s in series_streams if qlower in s.get("name", "").lower()
                    ]
                    if hide_adult:
                        series_results = [
                            s
                            for s in series_results
                            if not _is_adult_category(
                                s.get("name", "") + " " + s.get("category_name", "")
                            )
                        ]

                    profile_header_added = False
                    if live_results:
                        has_any = True
                        if not profile_header_added:
                            profile_header_added = True
                            header = xbmcgui.ListItem(
                                label="[COLOR yellow]{0}[/COLOR]".format(profile_name)
                            )
                            header.setArt({"icon": "DefaultAddonPVRClient.png"})
                            header.setProperty("IsPlayable", "false")
                            xbmcplugin.addDirectoryItem(
                                handle=addon_handle,
                                url="",
                                listitem=header,
                                isFolder=False,
                            )
                        sep = xbmcgui.ListItem(
                            label="[COLOR gray]────── {0} ──────[/COLOR]".format(
                                _t(30220)
                            )
                        )
                        sep.setArt({"icon": "DefaultAddonPVRClient.png"})
                        sep.setProperty("IsPlayable", "false")
                        xbmcplugin.addDirectoryItem(
                            handle=addon_handle, url="", listitem=sep, isFolder=False
                        )
                        for s in live_results:
                            name = s.get("name", "Unknown")
                            sid = str(s.get("stream_id", ""))
                            epg_id = s.get("epg_channel_id") or sid
                            play_url = IPTV.build_xtream_stream_url(
                                url, user, pwd, s, "live"
                            )
                            plot, display_name, current_title = (
                                _make_epg_info(epg, epg_id, name)
                                if show_epg
                                else ("", name, "")
                            )
                            li = xbmcgui.ListItem(label=display_name)
                            info_tag = li.getVideoInfoTag()
                            info_tag.setMediaType("video")
                            info_tag.setTitle(current_title or name)
                            info_tag.setPlot(plot)
                            if s.get("stream_icon"):
                                li.setArt(
                                    {
                                        "icon": s["stream_icon"],
                                        "thumb": s["stream_icon"],
                                        "poster": s["stream_icon"],
                                    }
                                )
                            li.setProperty("IsPlayable", "true")
                            _prepare_playback_item(li)
                            _set_live_props(li)
                            ctx = []
                            li.addContextMenuItems(ctx)
                            q = {
                                "mode": "play_stream",
                                "url": play_url,
                                "name": name,
                                "title": current_title or name,
                                "plot": plot,
                                "icon": s.get("stream_icon", ""),
                                "profile_num": profile_num,
                            }
                            xbmcplugin.addDirectoryItem(
                                handle=addon_handle,
                                url=build_url(q),
                                listitem=li,
                                isFolder=False,
                            )

                    if movie_results:
                        has_any = True
                        if not profile_header_added:
                            profile_header_added = True
                            header = xbmcgui.ListItem(
                                label="[COLOR yellow]{0}[/COLOR]".format(profile_name)
                            )
                            header.setArt({"icon": "DefaultAddonPVRClient.png"})
                            header.setProperty("IsPlayable", "false")
                            xbmcplugin.addDirectoryItem(
                                handle=addon_handle,
                                url="",
                                listitem=header,
                                isFolder=False,
                            )
                        sep = xbmcgui.ListItem(
                            label="[COLOR gray]────── {0} ──────[/COLOR]".format(
                                _t(30221)
                            )
                        )
                        sep.setArt({"icon": "DefaultMovies.png"})
                        sep.setProperty("IsPlayable", "false")
                        xbmcplugin.addDirectoryItem(
                            handle=addon_handle, url="", listitem=sep, isFolder=False
                        )
                        for s in movie_results:
                            name = s.get("name", "Unknown")
                            sid = str(s.get("stream_id", ""))
                            play_url = IPTV.build_xtream_stream_url(
                                url, user, pwd, s, "movie"
                            )
                            info = _enrich_movie_info(s, url, user, pwd)
                            li = xbmcgui.ListItem(label=name)
                            info_tag = li.getVideoInfoTag()
                            info_tag.setMediaType("movie")
                            info_tag.setTitle(name)
                            header_parts = []
                            if info.get("year"):
                                header_parts.append(info["year"])
                            if info.get("genre"):
                                header_parts.append(info["genre"])
                            header = " | ".join(header_parts)
                            plot = info["plot"]
                            if info.get("cast"):
                                cast_names = ", ".join(
                                    [a["name"] for a in info["cast"][:5]]
                                )
                                if header:
                                    plot = f"{header}\n[COLOR gray]Cast: {cast_names}[/COLOR]\n\n{plot}"
                                else:
                                    plot = f"[COLOR gray]Cast: {cast_names}[/COLOR]\n\n{plot}"
                            elif header:
                                plot = f"{header}\n\n{plot}"
                            info_tag.setPlot(plot)
                            if info["rating"]:
                                try:
                                    info_tag.setRating(float(info["rating"]))
                                except (ValueError, TypeError):
                                    pass  # Rating malformed or None, skip
                            if info["year"]:
                                try:
                                    info_tag.setYear(int(info["year"]))
                                except (ValueError, TypeError):
                                    pass  # Year malformed or None, skip
                            if info.get("cast"):
                                cast_list = []
                                for idx, actor in enumerate(info["cast"][:10]):
                                    cast_list.append(
                                        xbmc.Actor(
                                            actor.get("name", ""),
                                            actor.get("role", ""),
                                            idx,
                                            actor.get("thumbnail", ""),
                                        )
                                    )
                                if cast_list:
                                    info_tag.setCast(cast_list)
                            art = {}
                            if info["poster_url"]:
                                art = {
                                    "icon": info["poster_url"],
                                    "thumb": info["poster_url"],
                                    "poster": info["poster_url"],
                                }
                            li.setArt(art)
                            li.setProperty("IsPlayable", "true")
                            _prepare_playback_item(li)
                            ctx = []
                            li.addContextMenuItems(ctx)
                            q = {
                                "mode": "play_stream",
                                "url": play_url,
                                "name": name,
                                "icon": info.get("poster_url", ""),
                                "stype": "movie",
                                "profile_num": profile_num,
                            }
                            xbmcplugin.addDirectoryItem(
                                handle=addon_handle,
                                url=build_url(q),
                                listitem=li,
                                isFolder=False,
                            )

                    if series_results:
                        has_any = True
                        if not profile_header_added:
                            profile_header_added = True
                            header = xbmcgui.ListItem(
                                label="[COLOR yellow]{0}[/COLOR]".format(profile_name)
                            )
                            header.setArt({"icon": "DefaultAddonPVRClient.png"})
                            header.setProperty("IsPlayable", "false")
                            xbmcplugin.addDirectoryItem(
                                handle=addon_handle,
                                url="",
                                listitem=header,
                                isFolder=False,
                            )
                        sep = xbmcgui.ListItem(
                            label="[COLOR gray]────── {0} ──────[/COLOR]".format(
                                _t(30222)
                            )
                        )
                        sep.setArt({"icon": "DefaultTVShows.png"})
                        sep.setProperty("IsPlayable", "false")
                        xbmcplugin.addDirectoryItem(
                            handle=addon_handle, url="", listitem=sep, isFolder=False
                        )
                        for s in series_results:
                            name = s.get("name", "Unknown")
                            sid = str(s.get("series_id", ""))
                            series_url = build_url(
                                {"mode": "xtream_series", "series_id": sid}
                            )
                            li = xbmcgui.ListItem(label=name)
                            info_tag = li.getVideoInfoTag()
                            info_tag.setMediaType("tvshow")
                            info_tag.setTitle(name)
                            info_tag.setPlot(s.get("plot", ""))
                            if s.get("cover"):
                                li.setArt({"icon": s["cover"], "thumb": s["cover"]})
                            ctx = []
                            li.addContextMenuItems(ctx)
                            xbmcplugin.addDirectoryItem(
                                handle=addon_handle,
                                url=series_url,
                                listitem=li,
                                isFolder=True,
                            )

                elif has_m3u:
                    channels = _get_cached_m3u_channels(creds.get("m3u_url", ""))
                    m3u_results = [
                        ch
                        for ch in channels
                        if qlower in ch.get("name", "").lower()
                        or qlower in ch.get("group", "").lower()
                    ]
                    if m3u_results:
                        has_any = True
                        header = xbmcgui.ListItem(
                            label="[COLOR yellow]{0}[/COLOR]".format(profile_name)
                        )
                        header.setArt({"icon": "DefaultAddonPVRClient.png"})
                        header.setProperty("IsPlayable", "false")
                        xbmcplugin.addDirectoryItem(
                            handle=addon_handle, url="", listitem=header, isFolder=False
                        )
                        sep = xbmcgui.ListItem(
                            label="[COLOR gray]────── {0} ──────[/COLOR]".format(
                                _t(30220)
                            )
                        )
                        sep.setArt({"icon": "DefaultAddonPVRClient.png"})
                        sep.setProperty("IsPlayable", "false")
                        xbmcplugin.addDirectoryItem(
                            handle=addon_handle, url="", listitem=sep, isFolder=False
                        )
                        for ch in m3u_results:
                            name = ch.get("name", "Unknown")
                            tvg_id = ch.get("tvg_id") or name
                            url = ch.get("url")
                            item_id = ch.get("tvg_id") or url
                            plot, display_name, current_title = (
                                _make_epg_info(epg, tvg_id, name)
                                if show_epg
                                else ("", name, "")
                            )
                            li = xbmcgui.ListItem(label=display_name)
                            info_tag = li.getVideoInfoTag()
                            info_tag.setMediaType("video")
                            info_tag.setTitle(current_title or name)
                            info_tag.setPlot(plot)
                            if ch.get("logo"):
                                li.setArt({"icon": ch["logo"], "thumb": ch["logo"]})
                            li.setProperty("IsPlayable", "true")
                            _prepare_playback_item(li)
                            _set_live_props(li)
                            ctx = []
                            li.addContextMenuItems(ctx)
                            q = {
                                "mode": "play_stream",
                                "url": url,
                                "name": name,
                                "title": current_title or name,
                                "plot": plot,
                                "icon": ch.get("logo", ""),
                                "profile_num": profile_num,
                            }
                            xbmcplugin.addDirectoryItem(
                                handle=addon_handle,
                                url=build_url(q),
                                listitem=li,
                                isFolder=False,
                            )
            finally:
                pm.active = original_active

        if not has_any:
            li = xbmcgui.ListItem(label=_t(30223))
            xbmcplugin.addDirectoryItem(
                handle=addon_handle, url="", listitem=li, isFolder=False
            )

        xbmcplugin.endOfDirectory(addon_handle)
    except Exception as e:
        _log(f"search_all_profiles_combined error: {e}")

        _log(f"Traceback: {traceback.format_exc()}")
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)


def switch_profile():
    """Change the PVR profile and prompt for restart."""
    current = addon.getSetting("active_pvr_profile") or "Profile 1"
    current_num = current.replace("Profile ", "") if "Profile " in current else "1"
    profiles = []
    for i in range(1, 11):
        if addon.getSetting(f"profile_{i}_enabled") != "true":
            continue
        has_xtream = bool(addon.getSetting(f"profile_{i}_xtream_url"))
        has_m3u = bool(addon.getSetting(f"profile_{i}_m3u"))
        if not has_xtream and not has_m3u:
            continue
        name = addon.getSetting(f"profile_{i}_name") or _t(30390, i)
        label = _t(31400, i, name) if name != _t(30390, i) else _t(30390, i)
        marker = " [COLOR green]*[/COLOR]" if str(i) == current_num else ""
        profiles.append((str(i), f"{label}{marker}"))
    if not profiles:
        xbmcgui.Dialog().notification("Xstream Pro", _t(30893))
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
        return
    labels = [p[1] for p in profiles]
    idx = xbmcgui.Dialog().select(_t(30017), labels)
    if idx < 0:
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
        return
    selected_num = profiles[idx][0]
    if selected_num == current_num:
        xbmcgui.Dialog().notification("Xstream Pro", _t(30121))
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
        return
    selected_profile = _t(30390, selected_num)
    # Offer reload PVR or cancel
    dialog = xbmcgui.Dialog()
    options = [_t(30720), _t(30403)]  # Reload PVR, Cancel
    choice = dialog.select(_t(30732, selected_profile), options)
    if choice == 0:
        old_profile = current
        addon.setSetting("active_pvr_profile", selected_profile)
        ok = False
        try:
            ok = _sync_pvr_force()
        except Exception as e:
            _log(f"PVR reload error: {e}")

            _log(f"Traceback: {traceback.format_exc()}")
        if ok:
            addon.setSetting("last_pvr_profile", selected_profile)
            xbmcgui.Dialog().notification("Xstream Pro", _t(30281))
        else:
            addon.setSetting("active_pvr_profile", old_profile)
            addon.setSetting("last_pvr_profile", old_profile)
            xbmcgui.Dialog().notification("Xstream Pro", _t(30082))
    else:
        xbmcgui.Dialog().notification(
            "Xstream Pro",
            _t(30307, profiles[idx][1].replace("[COLOR green]*[/COLOR]", "").strip()),
        )
    xbmcplugin.endOfDirectory(addon_handle, succeeded=False)


def view_changelog():
    """Show full changelog from addon folder."""
    changelog_path = os.path.join(
        xbmcvfs.translatePath(addon.getAddonInfo("path")), "CHANGELOG.md"
    )
    try:
        with open(changelog_path, "r", encoding="utf-8") as f:
            changelog = f.read()
        current_version = addon.getAddonInfo("version")
        xbmcgui.Dialog().textviewer(_t(30341), changelog)
    except Exception:
        xbmcgui.Dialog().notification("Xstream Pro", _t(30103))


def test_connection():
    results = []
    checked = 0
    pd = xbmcgui.DialogProgress()
    pd.create("Xstream Pro", _t(30288))
    for i in range(1, 11):
        if pd.iscanceled():
            break
        if addon.getSetting(f"profile_{i}_enabled") != "true":
            continue
        profile_name = addon.getSetting(f"profile_{i}_name") or _t(30390, i)
        pd.update(int((i - 1) * 10), f"{_t(30288)}: {profile_name}")
        creds = _get_credentials_for_profile(i)
        url = creds.get("xtream_url", "")
        user = creds.get("xtream_username", "")
        pwd = creds.get("xtream_password", "")
        if not url or not user or not pwd:
            continue
        checked += 1
        info = IPTV.validate_xtream(url, user, pwd)
        if info is None:
            results.append(f"[COLOR red]{profile_name}[/COLOR]: {_t(30054)}")
            continue

        exp = info.get("exp_date", "")
        exp_str = _t(30546)
        if exp:
            try:
                exp_dt = datetime.datetime.fromtimestamp(int(exp))
                exp_str = exp_dt.strftime("%Y-%m-%d %H:%M")
                days_left = (exp_dt - datetime.datetime.now()).days
                exp_str += _t(30420, days_left)
            except (ValueError, TypeError):
                exp_str = str(exp)
        status = info.get("status", _t(30428))
        max_conn = info.get("max_connections", "N/A")
        active_cons = info.get("active_cons", "0")
        results.append(
            f"[COLOR green]{profile_name}[/COLOR]: {_t(30421)} {status} | {_t(30422)} {exp_str} | {_t(30423)} {max_conn} | {_t(30424)} {active_cons}"
        )
        results.append("[COLOR gray]────────────────────[/COLOR]")
    pd.close()
    if checked == 0:
        xbmcgui.Dialog().ok("Xstream Pro", _t(30056))
        return
    if not results:
        xbmcgui.Dialog().ok(_t(30031), _t(30054) + "\n" + _t(30055))
        return
    xbmcgui.Dialog().textviewer(_t(30033), "\n".join(results).rstrip())


def account_info():
    lines = []
    has_any = False
    for i in range(1, 11):
        if addon.getSetting(f"profile_{i}_enabled") != "true":
            continue
        profile_name = addon.getSetting(f"profile_{i}_name") or _t(30390, i)
        creds = _get_credentials_for_profile(i)
        url = creds.get("xtream_url", "")
        user = creds.get("xtream_username", "")
        pwd = creds.get("xtream_password", "")
        if not url or not user or not pwd:
            continue
        has_any = True
        info = IPTV.validate_xtream(url, user, pwd)
        if info is None:
            lines.append(f"[B]{profile_name}[/B]: {_t(30123)}")
            lines.append("")
            continue

        exp = info.get("exp_date", "")
        exp_str = _t(30546)
        if exp:
            try:
                exp_dt = datetime.datetime.fromtimestamp(int(exp))
                exp_str = exp_dt.strftime("%Y-%m-%d %H:%M")
            except (ValueError, TypeError):
                exp_str = str(exp)
        lines.append(f"[B]{profile_name}[/B]")
        lines.append(f"{_t(30421)}: {info.get('status', _t(30429))}")
        lines.append(f"{_t(30422)}: {exp_str}")
        lines.append(f"{_t(30423)}: {info.get('max_connections', 'N/A')}")
        lines.append(f"{_t(30425)}: {info.get('active_cons', '0')}")
        lines.append(
            f"{_t(30426)}: {_t(30430) if info.get('is_trial') == '1' else _t(30431)}"
        )
        lines.append(f"{_t(30427)}: {info.get('server_url', 'N/A')}")
        lines.append("[COLOR gray]────────────────────[/COLOR]")
    if not has_any:
        xbmcgui.Dialog().ok("Xstream Pro", _t(30056))
        return
    xbmcgui.Dialog().textviewer(_t(30019), "\n".join(lines).rstrip())


def _validate_settings():
    """Validate settings after user closes settings dialog."""
    from iptv import _validate_url

    _normalize_parental_pin()
    creds = _get_credentials()
    xt_url = creds.get("xtream_url", "")
    m3u_url = creds.get("m3u", "")
    for label, url in [(_t(30566), xt_url), (_t(30567), m3u_url)]:
        if not url:
            continue
        validated = _validate_url(url, allow_http=True)
        if not validated:
            xbmcgui.Dialog().notification(
                "Xstream Pro", _t(30308, label), xbmcgui.NOTIFICATION_WARNING, 5000
            )
        elif (
            url.startswith("http://")
            and addon.getSetting("warn_http").lower() == "true"
        ):
            xbmcgui.Dialog().notification(
                "Xstream Pro", _t(30309, label), xbmcgui.NOTIFICATION_WARNING, 3000
            )
            _log(f"Warning: {label} uses insecure HTTP connection")


def _snapshot_credentials():
    snapshot = {}
    keys = (
        "enabled",
        "source_type",
        "m3u",
        "xtream_url",
        "xtream_username",
        "xtream_password",
        "epg_m3u",
        "epg_xtream",
        "load_live",
        "load_movies",
        "load_series",
    )
    for n in range(1, 11):
        snapshot[n] = {
            key: _get_setting_direct(f"profile_{n}_{key}", "")
            for key in keys
        }
    _log("Credential snapshot taken for profiles 1-10.")
    return snapshot


def _detect_credential_changes(snapshot):
    added = []
    removed = []
    modified = []
    keys = (
        "enabled",
        "source_type",
        "m3u",
        "xtream_url",
        "xtream_username",
        "xtream_password",
        "epg_m3u",
        "epg_xtream",
        "load_live",
        "load_movies",
        "load_series",
    )
    for n in range(1, 11):
        current = {
            key: _get_setting_direct(f"profile_{n}_{key}", "")
            for key in keys
        }
        if snapshot.get(n) == current:
            continue
        had_creds = bool(snapshot.get(n, {}).get("xtream_url") or snapshot.get(n, {}).get("m3u"))
        has_creds = bool(current.get("xtream_url") or current.get("m3u"))
        if has_creds and not had_creds:
            added.append(n)
        elif had_creds and not has_creds:
            removed.append(n)
        elif had_creds and has_creds:
            modified.append(n)
        _log(
            "Profile {0} credential change: had_creds={1} has_creds={2} "
            "old_url={3} new_url={4}".format(
                n, had_creds, has_creds,
                snapshot.get(n, {}).get("xtream_url", "") or snapshot.get(n, {}).get("m3u", "") or "(none)",
                current.get("xtream_url", "") or current.get("m3u", "") or "(none)",
            )
        )
    return added, removed, modified


def _is_pvr_profile(pnum):
    """Check if a profile number is the currently active PVR profile."""
    pvr_profile = addon.getSetting("active_pvr_profile") or "Profile 1"
    match = re.search(r"(\d+)", pvr_profile)
    pvr_num = int(match.group(1)) if match else 1
    return int(pnum) == pvr_num


def _prompt_load_single_profile(pnum):
    dialog = xbmcgui.Dialog()
    profile_name = addon.getSetting(f"profile_{pnum}_name") or _t(30390, pnum)
    is_pvr = _is_pvr_profile(pnum)
    msg = _t(30861, profile_name) if is_pvr else _t(30862, profile_name)
    if dialog.yesno("Xstream Pro", msg):
        _refresh_profile_data(pnum)
        return [pnum]
    return []


def _prompt_load_multiple_profiles(changed_profiles):
    """Show multi-select dialog for loading changed profiles. Returns list of loaded profiles."""
    dialog = xbmcgui.Dialog()
    options = []
    preselect = []
    idx_map = []

    for pnum in changed_profiles:
        profile_name = addon.getSetting(f"profile_{pnum}_name") or _t(30390, pnum)
        has_creds = bool(
            addon.getSetting(f"profile_{pnum}_xtream_url")
            or addon.getSetting(f"profile_{pnum}_m3u")
        )

        if has_creds:
            is_pvr = _is_pvr_profile(pnum)
            label = f"{profile_name} {_t(30863) if is_pvr else ''}"
            options.append(label)
            preselect.append(len(options) - 1)
            idx_map.append(pnum)
        else:
            # Grey out empty profiles
            options.append(f"[COLOR grey]{profile_name} ({_t(30864)})[/COLOR]")
            idx_map.append(None)

    if not options:
        return []

    result = dialog.multiselect(_t(30865), options, preselect=preselect)
    if result is None:
        return []

    loaded = []
    for idx in result:
        pnum = idx_map[idx]
        if pnum is not None:
            _refresh_profile_data(pnum)
            loaded.append(pnum)

    return loaded


def _prompt_sync_pvr_if_needed(loaded_profiles):
    """Offer PVR sync if any loaded profile is the PVR profile.

    Handles restore-time deferred sync: if restore set the deferred flag,
    clear it and only prompt if the PVR-linked profile was actually loaded.
    """
    profile_path = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    defer_flag = os.path.join(profile_path, "pvr_sync_after_profile_load.flag")

    deferred = os.path.exists(defer_flag)
    if deferred:
        pvr_loaded = any(_is_pvr_profile(p) for p in loaded_profiles)
        if not pvr_loaded:
            _log("PVR sync deferred — PVR-linked profile not loaded yet")
            return
        _log("Restore-time deferred PVR sync — PVR-linked profile loaded")

    if not loaded_profiles:
        return

    pvr_loaded = any(_is_pvr_profile(p) for p in loaded_profiles)

    retry_flag = os.path.join(profile_path, "pvr_sync_retry_needed.flag")
    pending_sync = deferred or os.path.exists(retry_flag)

    pvr_installed = _is_pvr_iptvsimple_installed_or_disabled()
    _log(
        f"PVR sync check: loaded_profiles={loaded_profiles}, "
        f"pvr_loaded={pvr_loaded}, pvr_installed={pvr_installed}, "
        f"pending_sync={pending_sync}"
    )

    if not pvr_loaded:
        return

    if not pvr_installed and not pending_sync:
        return

    if not pvr_installed and pending_sync:
        _log("PVR sync pending while IPTV Simple reports not installed/disabled — allowing sync to continue")

    # Allow previous dialog to fully close before showing PVR prompt
    xbmc.sleep(_tvos_sleep(500))

    dialog = xbmcgui.Dialog()
    if dialog.yesno("Xstream Pro", _t(30866)):
        ok = _sync_pvr_force()
        if ok:
            if deferred and os.path.exists(defer_flag):
                os.remove(defer_flag)
            _log("PVR sync triggered after profile load")
        else:
            retry_flag = os.path.join(profile_path, "pvr_sync_retry_needed.flag")
            with open(retry_flag, "w", encoding="utf-8") as f:
                f.write("1")
    else:
        # User declined — flag for startup retry
        retry_flag = os.path.join(profile_path, "pvr_sync_retry_needed.flag")
        with open(retry_flag, "w", encoding="utf-8") as f:
            f.write("1")
        _log("PVR sync deferred after profile load, flagged for startup retry")


def _snapshot_pvr_replay_settings():
    keys = (
        "pvr_catchup_enabled",
        "pvr_catchup_days",
        "pvr_catchup_correction_mode",
        "pvr_catchup_correction",
        "pvr_epg_refresh",
    )
    return {key: _get_setting_direct(key, addon.getSetting(key) or "") for key in keys}


def settings():
    xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
    if not _check_pin("Settings"):
        return
    _log("Settings opened.")

    # Snapshot credentials before settings change
    cred_snapshot = _snapshot_credentials()
    pvr_replay_snapshot = _snapshot_pvr_replay_settings()
    provider_only_snapshot = _provider_only_tmdb_enabled()

    addon.openSettings()
    _invalidate_settings_xml_cache()
    _validate_settings()

    pvr_replay_changed = _snapshot_pvr_replay_settings() != pvr_replay_snapshot
    provider_only_changed = _provider_only_tmdb_enabled() != provider_only_snapshot

    # Detect changes and prompt immediately
    added_profiles, removed_profiles, modified_profiles = _detect_credential_changes(cred_snapshot)
    profiles_to_reload = added_profiles + modified_profiles
    if profiles_to_reload:
        _log("Credential changes detected (reload): {0}".format(profiles_to_reload))
        if len(profiles_to_reload) == 1:
            loaded = _prompt_load_single_profile(profiles_to_reload[0])
            _prompt_sync_pvr_if_needed(loaded)
        else:
            loaded = _prompt_load_multiple_profiles(profiles_to_reload)
            _prompt_sync_pvr_if_needed(loaded)
    if removed_profiles:
        _log("Credential changes detected (removed): {0}".format(removed_profiles))
        for pnum in removed_profiles:
            name = addon.getSetting(f"profile_{pnum}_name") or _t(30390, pnum)
            if xbmcgui.Dialog().yesno(
                _t(30770), _t(30809, name), nolabel=_t(30163), yeslabel=_t(30162)
            ):
                count = _purge_profile_data(pnum)
                _log("Cleared {0} files for profile {1}".format(count, pnum))

    if pvr_replay_changed and is_pvr_iptvsimple_installed():
        _log("PVR replay settings changed")
        if xbmcgui.Dialog().yesno(
            "Xstream Pro",
            "PVR replay settings changed. Reload PVR now?"
        ):
            if not _sync_pvr_force():
                xbmcgui.Dialog().notification(
                    "Xstream Pro",
                    "PVR reload failed. Try Force Reload PVR.",
                    xbmcgui.NOTIFICATION_WARNING,
                    5000,
                )
        else:
            xbmcgui.Dialog().notification(
                "Xstream Pro",
                "Replay changes apply after next PVR reload.",
                xbmcgui.NOTIFICATION_INFO,
                5000,
            )

    if provider_only_changed:
        if _provider_only_tmdb_enabled():
            _rebuild_provider_availability_cache(show_progress=True)
            xbmcgui.Dialog().notification(
                "Xstream Pro",
                "Provider availability cache updated.",
                xbmcgui.NOTIFICATION_INFO,
                5000,
            )
        else:
            if xbmcgui.Dialog().yesno(
                "Xstream Pro",
                "Show only available provider content was disabled.\n\nDelete its availability cache?"
            ):
                _clear_provider_availability_cache()

    try:
        _configure_kodi_pvr_osd()
        _install_pvr_keymap()
    except Exception as e:
        _log(f"PVR keymap refresh after settings error: {e}")

    _log("Settings closed.")


def _prompt_load_profiles_after_restore():
    """If restore just completed, prompt to load profiles before PVR sync."""
    profile_path = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    defer_flag = os.path.join(profile_path, "pvr_sync_after_profile_load.flag")

    if not os.path.exists(defer_flag):
        return

    # Find all enabled profiles with credentials
    enabled_profiles = []
    for n in range(1, 11):
        if addon.getSetting(f"profile_{n}_enabled") == "true":
            has_xtream = bool(addon.getSetting(f"profile_{n}_xtream_url"))
            has_m3u = bool(addon.getSetting(f"profile_{n}_m3u"))
            if has_xtream or has_m3u:
                enabled_profiles.append(n)

    if not enabled_profiles:
        _log("Restore deferred flag set but no enabled profiles with credentials found")
        os.remove(defer_flag)
        return

    # Show multi-select dialog for loading restored profiles
    dialog = xbmcgui.Dialog()
    options = []
    preselect = []
    for pnum in enabled_profiles:
        profile_name = addon.getSetting(f"profile_{pnum}_name") or _t(30390, pnum)
        is_pvr = _is_pvr_profile(pnum)
        label = f"{profile_name} {_t(30863) if is_pvr else ''}"
        options.append(label)
        preselect.append(len(options) - 1)

    result = dialog.multiselect(_t(30887), options, preselect=preselect)
    if result is None:
        # User cancelled — keep flag so they can load profiles later
        _log("User cancelled profile load after restore")
        return

    selected_profiles = [enabled_profiles[idx] for idx in result]
    loaded = _refresh_profiles_batch(selected_profiles)

    if loaded:
        _log(f"Loaded profiles after restore: {loaded}")
        _prompt_sync_pvr_if_needed(loaded)
    else:
        _log("No profiles selected for loading after restore")


def _make_epg_info(epg, channel_id, channel_name):
    matched_id = epg._find_channel_id(channel_id, channel_name)
    if not matched_id:
        return "", channel_name, ""
    now = time.time()
    upcoming = []
    current_title = ""
    current_desc = ""
    # Fast O(log m) lookup using bisect on pre-sorted timestamps
    starts = epg._program_starts.get(matched_id, [])
    prog_list = epg.programs.get(matched_id, [])
    if starts and prog_list:
        import bisect
        # Reverse the offset so we can search raw timestamps directly
        offset_now = now - (epg.offset_hours * 3600)
        idx = bisect.bisect_right(starts, offset_now) - 1
        if 0 <= idx < len(prog_list):
            current_prog = prog_list[idx]
            stop = epg._apply_offset(_parse_xmltv_time(current_prog["stop"]))
            if stop > now:
                upcoming.append(current_prog)
                current_title = current_prog["title"]
                current_desc = current_prog.get("desc", "")
        # Append future programs (up to 7 more, or 8 if no current)
        future_start = idx + 1 if idx >= 0 else 0
        upcoming.extend(prog_list[future_start:future_start + 8])
        upcoming = upcoming[:8]
    else:
        # Fallback to linear scan if bisect data is unavailable
        for prog in epg.programs.get(matched_id, []):
            start = epg._apply_offset(_parse_xmltv_time(prog["start"]))
            stop = epg._apply_offset(_parse_xmltv_time(prog["stop"]))
            if start and start > now:
                upcoming.append(prog)
            elif start and start <= now < stop:
                upcoming.insert(0, prog)
                current_title = prog["title"]
                current_desc = prog.get("desc", "")
        upcoming = upcoming[:8]
    if current_title:
        if current_desc:
            clean_desc = current_desc.replace("\n", " ").replace("\r", " ").strip()
            if len(clean_desc) > 150:
                clean_desc = clean_desc[:147] + "..."
            display_name = f"[B]{channel_name}[/B]  -  {current_title}: {clean_desc}"
        else:
            display_name = f"[B]{channel_name}[/B]  -  {current_title}"
    else:
        display_name = f"[B]{channel_name}[/B]"
    plot_lines = []
    for idx, prog in enumerate(upcoming):
        t = _parse_xmltv_time(prog["start"])
        stop_t = _parse_xmltv_time(prog["stop"])
        is_current = idx == 0 and t and stop_t and t <= now < stop_t
        if t:
            time_str = time.strftime("%H:%M", time.localtime(t))
            line = f"{time_str} - {prog['title']}"
        else:
            line = prog["title"]
        if is_current:
            line = f"[B][COLOR yellow]> {line}[/COLOR][/B]"
        plot_lines.append(line)
    plot = "\n".join(plot_lines)
    _log(
        f"EPG info for {channel_name}: found {len(upcoming)} programs, current={current_title}"
    )
    return plot, display_name, current_title


def _get_default_downloads_dir():
    if xbmc.getCondVisibility("System.Platform.Android"):
        return "/storage/emulated/0/Download/"
    return xbmcvfs.translatePath("special://profile/downloads/")


def _get_backup_folder_path():
    return os.path.join(
        xbmcvfs.translatePath(addon.getAddonInfo("profile")), "backup_folder.txt"
    )


def _read_backup_folder():
    path = _get_backup_folder_path()
    try:
        with open(path, "r", encoding="utf-8") as f:
            return f.read().strip()
    except (FileNotFoundError, OSError):
        return ""


def _write_backup_folder(path):
    file_path = _get_backup_folder_path()
    tmp = file_path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        f.write(path)
        try:
            f.flush()
            _safe_fsync(f)
        except OSError:
            pass
    os.replace(tmp, file_path)


def _get_backup_dir():
    custom = _read_backup_folder()
    if custom and os.path.isdir(custom):
        return custom
    default = _get_default_downloads_dir()
    if not os.path.isdir(default):
        try:
            os.makedirs(default, exist_ok=True)
        except Exception as e:
            _log(f"Backup default dir creation failed: {e}")
            return xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    return default


def view_backup_path():
    """Show the current backup folder path in a dialog."""
    path = _read_backup_folder() or xbmcvfs.translatePath("special://home")
    xbmcgui.Dialog().ok(_t(30042), path)


def change_backup_folder():
    dialog = xbmcgui.Dialog()
    chosen = dialog.browseSingle(3, _t(30789), "", "", False, False, "")
    if not chosen:
        return
    _write_backup_folder(chosen)
    addon.setSetting("backup_folder_display", chosen)
    xbmcgui.Dialog().notification("Xstream Pro", _t(30791))


def change_download_folder():
    dialog = xbmcgui.Dialog()
    chosen = dialog.browseSingle(3, _t(30960), "", "", False, False, "")
    if not chosen:
        return
    try:
        os.makedirs(chosen, exist_ok=True)
        test_path = os.path.join(chosen, ".xstream_write_test")
        with open(test_path, "w", encoding="utf-8") as f:
            f.write("ok")
            f.flush()
            _safe_fsync(f)
        os.remove(test_path)
    except Exception as e:
        _log(f"Download folder is not writable: {e}")
        xbmcgui.Dialog().notification("Xstream Pro", _t(30082), xbmcgui.NOTIFICATION_ERROR)
        return
    addon.setSetting("download_folder", chosen)
    addon.setSetting("download_folder_display", chosen)
    xbmcgui.Dialog().notification("Xstream Pro", _t(31075))


# Backup schema version — bump ONLY when restore logic would behave differently
# on the new format than the old one (renamed files, restructured data, remapped fields).
BACKUP_SCHEMA_VERSION = "1"
_BACKUP_MAX_FILE_SIZE = 50 * 1024 * 1024
_BACKUP_MAX_TOTAL_SIZE = 250 * 1024 * 1024
_BACKUP_MAX_FILES = 500

# Files that are safe to backup and restore (user data only, no caches or generated files)
_BACKUP_ALLOWED_PREFIXES = (
    "settings.xml",
    "favorites_global.json",
    "favorites_1.json",
    "favorites_2.json",
    "favorites_3.json",
    "favorites_4.json",
    "favorites_5.json",
    "favorites_6.json",
    "favorites_7.json",
    "favorites_8.json",
    "favorites_9.json",
    "favorites_10.json",
    "pvr_favorites_",
    "main_menu_order_",
    "hidden_subcats_",
    "hidden_items_",
    "category_prefs.json",
    "watch_history_",
    "resume_points_",
    "search_history_",
    "watched_movies_",
    "watched_episodes_",
    "backup_folder.txt",
)


def _build_backup_manifest(profile_path):
    """Build manifest dict from files present in profile dir."""
    files = []
    profiles_found = set()
    for fname in sorted(os.listdir(profile_path)):
        if not any(fname.startswith(p) for p in _BACKUP_ALLOWED_PREFIXES):
            continue
        fpath = os.path.join(profile_path, fname)
        if not os.path.isfile(fpath):
            continue
        # Detect type from filename
        ftype = "unknown"
        if fname == "settings.xml":
            ftype = "settings"
        elif fname.startswith("favorites"):
            ftype = "favorites"
        elif fname.startswith("pvr_favorites_"):
            ftype = "pvr_favorites"
            # Track which PVR profile numbers have favorites
            match = re.search(r"pvr_favorites_(\d+)", fname)
            if match:
                profiles_found.add(int(match.group(1)))
        elif fname.startswith("main_menu_order_"):
            ftype = "menu_order"
        elif fname.startswith("hidden_"):
            ftype = "hidden"
        elif fname == "category_prefs.json":
            ftype = "category_prefs"
        elif fname.startswith("watch_history_"):
            ftype = "watch_history"
        elif fname.startswith("resume_points_"):
            ftype = "resume_points"
        files.append({"path": fname, "type": ftype})
    return {
        "backup_schema_version": BACKUP_SCHEMA_VERSION,
        "addon_id": addon.getAddonInfo("id"),
        "addon_name": addon.getAddonInfo("name"),
        "addon_version": addon.getAddonInfo("version"),
        "created_at": datetime.datetime.now().isoformat(),
        "profiles": sorted(list(profiles_found)),
        "files": files,
    }


def _validate_zip_manifest(manifest, zip_entries):
    """Validate manifest integrity and compatibility before extraction.

    Args:
        manifest: dict from backup_manifest.json
        zip_entries: set of filenames from zf.namelist()
    Raises:
        ValueError if manifest is invalid, incompatible, or incomplete.
    """
    if "addon_id" not in manifest:
        raise ValueError("Backup manifest missing addon_id")
    if manifest["addon_id"] != addon.getAddonInfo("id"):
        raise ValueError(
            f"Backup addon_id mismatch: expected {addon.getAddonInfo('id')}, "
            f"got {manifest['addon_id']}"
        )
    schema = manifest.get("backup_schema_version", "0")
    if schema != BACKUP_SCHEMA_VERSION:
        raise ValueError(
            f"Unsupported backup schema version: {schema} (expected {BACKUP_SCHEMA_VERSION})"
        )
    missing = [f["path"] for f in manifest["files"] if f["path"] not in zip_entries]
    if missing:
        raise ValueError(f"Backup is incomplete, missing: {missing}")


def _has_pvr_data_in_backup(manifest):
    """Check if backup contains any PVR-related data."""
    for f in manifest["files"]:
        if f["type"] in ("settings", "pvr_favorites"):
            return True
    return False


def backup_settings():

    dialog = xbmcgui.Dialog()
    options = [_t(30794), _t(30795)]
    choice = dialog.select(_t(30682), options)
    if choice < 0:
        return

    if choice == 0:
        backup_dir = _get_default_downloads_dir()
        if not backup_dir or not os.path.isdir(backup_dir):
            xbmcgui.Dialog().notification("Xstream Pro", _t(30790))
            return
    else:
        chosen = dialog.browseSingle(3, _t(30789), "", "", False, False, "")
        if not chosen:
            return
        backup_dir = chosen
        _write_backup_folder(chosen)

    profile_path = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    version = addon.getAddonInfo("version")
    backup_path = os.path.join(
        backup_dir, f"xstream-pro-{version}-backup-{timestamp}.zip"
    )

    try:
        manifest = _build_backup_manifest(profile_path)
        with zipfile.ZipFile(backup_path, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("backup_manifest.json", json.dumps(manifest, indent=2))
            for entry in manifest["files"]:
                fname = entry["path"]
                fpath = os.path.join(profile_path, fname)
                if os.path.isfile(fpath):
                    zf.write(fpath, fname)
        _log(f"Backup created: {backup_path} ({len(manifest['files'])} files)")
        xbmcgui.Dialog().notification("Xstream Pro", _t(30089))
    except Exception as e:
        _log(f"Backup failed: {e}")
        xbmcgui.Dialog().notification("Xstream Pro", _t(30090))



def _finalize_restore(profile_path, manifest=None):
    """Common post-restore steps: credentials flag, cache clear, PVR favorites, PVR sync."""
    # Prevent first-run refresh prompt from firing on restored credentials
    flag_file = os.path.join(profile_path, "credentials_saved")
    if not os.path.exists(flag_file):
        with open(flag_file, "w", encoding="utf-8") as f:
            f.write("1")

    # Clear all caches so restored credentials don't collide with stale data
    try:
        _cache_clear_all()
        _log("All caches cleared after restore")
    except Exception as e:
        _log(f"Cache clear after restore warning: {e}")

    # Defer PVR sync until PVR-linked profile is loaded after restore.
    # Prevents duplicate prompts and ensures sync uses fresh profile data.
    defer_flag = os.path.join(profile_path, "pvr_sync_after_profile_load.flag")
    with open(defer_flag, "w", encoding="utf-8") as f:
        f.write("1")
    _log("PVR sync deferred until PVR-linked profile is loaded")

    xbmc.executebuiltin(
        "Container.Update(plugin://plugin.video.xstream-pro/,replace)"
    )


def restore_settings():
    """Restore from a Pro backup ZIP. Only manifest-based backups are accepted."""
    dialog = xbmcgui.Dialog()
    options = [_t(30794), _t(30796)]
    choice = dialog.select(_t(30683), options)
    if choice < 0:
        return

    if choice == 0:
        custom = _read_backup_folder()
        if not custom or not os.path.isdir(custom):
            xbmcgui.Dialog().notification("Xstream Pro", _t(30790))
            return
        start_dir = custom
    else:
        start_dir = ""

    zip_path = dialog.browse(1, _t(30337), "", ".zip", False, False, start_dir)
    if not zip_path:
        return

    profile_path = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    progress = xbmcgui.DialogProgress()
    progress.create("Xstream Pro", _t(30879))

    try:
        progress.update(5, _t(30880))
        with zipfile.ZipFile(zip_path, "r") as zf:
            zip_entries = set(zf.namelist())
            manifest_names = [
                n for n in zip_entries if n.endswith("backup_manifest.json")
            ]

            if len(manifest_names) != 1:
                raise ValueError("Backup must contain exactly one backup_manifest.json")

            manifest = json.loads(zf.read(manifest_names[0]).decode("utf-8"))
            _validate_zip_manifest(manifest, zip_entries)
            _restore_modern_backup(zf, manifest, profile_path, progress)

    except ValueError as e:
        _log(f"Restore validation failed: {e}")
        xbmcgui.Dialog().notification("Xstream Pro", _t(30092))
    except Exception as e:
        _log(f"Restore failed: {e}")
        xbmcgui.Dialog().notification("Xstream Pro", _t(30092))
    finally:
        try:
            progress.close()
        except Exception:
            pass


def _safe_backup_member(name):
    normalized = name.replace("\\", "/")
    parts = [p for p in normalized.split("/") if p]
    if not parts or any(p == ".." for p in parts):
        return None
    if normalized.startswith("/") or re.match(r"^[A-Za-z]:[\\/]", normalized):
        return None
    base = parts[0]
    if not any(base.startswith(p) for p in _BACKUP_ALLOWED_PREFIXES):
        return None
    return "/".join(parts)


def _restore_modern_backup(zf, manifest, profile_path, progress=None):
    """Restore a backup that has a valid manifest (schema v2+)."""
    files = manifest["files"]
    if len(files) > _BACKUP_MAX_FILES:
        raise ValueError("Backup contains too many files")
    total = len(files)
    total_bytes = 0
    profile_real = os.path.abspath(profile_path)
    for idx, entry in enumerate(files):
        fname = entry["path"]
        safe_name = _safe_backup_member(fname)
        if not safe_name:
            _log(f"Skipped unsafe backup entry: {fname}")
            continue
        info = zf.getinfo(safe_name)
        if info.file_size > _BACKUP_MAX_FILE_SIZE:
            raise ValueError(f"Backup entry too large: {safe_name}")
        total_bytes += info.file_size
        if total_bytes > _BACKUP_MAX_TOTAL_SIZE:
            raise ValueError("Backup restore size limit exceeded")
        target = os.path.abspath(os.path.normpath(os.path.join(profile_path, safe_name)))
        if os.path.commonpath([profile_real, target]) != profile_real:
            _log(f"Skipped unsafe backup entry: {safe_name}")
            continue
        os.makedirs(os.path.dirname(target), exist_ok=True)
        tmp_target = target + ".tmp"
        with zf.open(info, "r") as src, open(tmp_target, "wb") as dst:
            while True:
                chunk = src.read(1024 * 1024)
                if not chunk:
                    break
                dst.write(chunk)
            try:
                dst.flush()
                _safe_fsync(dst)
            except OSError:
                pass
        os.replace(tmp_target, target)
        if progress and total:
            pct = 10 + int(((idx + 1) / total) * 30)
            progress.update(pct, _t(30881))

    if progress:
        progress.update(45, _t(30882))
    _sync_restored_settings()

    # Infer/fix source_type for profiles (critical for M3U-with-creds)
    if progress:
        progress.update(60, _t(30883))
    try:
        for n in range(1, 11):
            has_xtream = bool(addon.getSetting(f"profile_{n}_xtream_url"))
            has_m3u = bool(addon.getSetting(f"profile_{n}_m3u"))
            source_type = addon.getSetting(f"profile_{n}_source_type")
            if has_m3u and not has_xtream:
                if source_type != "M3U":
                    addon.setSetting(f"profile_{n}_source_type", "M3U")
                    _log(f"Restored profile {n} source_type inferred as M3U")
            elif has_xtream and source_type != "Xtream Codes":
                addon.setSetting(f"profile_{n}_source_type", "Xtream Codes")
                _log(f"Restored profile {n} source_type inferred as Xtream Codes")
            if has_xtream or has_m3u:
                enabled = addon.getSetting(f"profile_{n}_enabled")
                if enabled != "true":
                    addon.setSetting(f"profile_{n}_enabled", "true")
                    _log(f"Auto-enabled profile {n} after restore")
    except Exception as e:
        _log(f"Source type inference after restore warning: {e}")

    if progress:
        progress.update(80, _t(30884))
    _finalize_restore(profile_path, manifest=manifest)

    if progress:
        progress.update(100, _t(30885))
    xbmcgui.Dialog().notification("Xstream Pro", _t(30091))


def _sync_restored_settings():
    """Force Kodi to reload restored settings.xml into its live cache.

    Validates XML before touching live settings, then syncs all non-empty
    settings to preserve the full backup state.
    """
    profile_path = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    settings_path = os.path.join(profile_path, "settings.xml")
    try:
        import xml.etree.ElementTree as ET

        # Pre-flight: validate XML is well-formed before touching live settings
        try:
            tree = ET.parse(settings_path)
        except ET.ParseError as e:
            _log(f"Restored settings.xml is malformed: {e}")
            raise ValueError(f"Corrupted settings.xml in backup: {e}")

        restored_count = 0
        for setting in tree.findall(".//setting"):
            sid = setting.get("id")
            if sid is None:
                continue
            val = (setting.text or "").strip()
            # Don't overwrite live settings with empty values from backup
            if val:
                addon.setSetting(sid, val)
                restored_count += 1
        _log(f"Synced restored settings.xml: {restored_count} restored")
    except Exception as e:
        _log(f"Settings sync warning: {e}")


def clear_provider_metadata_cache():
    """Clear provider metadata caches: vod_info and playback_duration only.
    Navigation caches (data_cache_*) are handled by Profiles cache."""
    profile_path = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    count = 0
    for fname in os.listdir(profile_path):
        if (
            fname.startswith("vod_info_")
            or fname.startswith("playback_duration_")
        ):
            try:
                os.remove(os.path.join(profile_path, fname))
                count += 1
            except Exception as e:
                _log(f"clear_provider_metadata_cache remove warning: {e}")
    xbmcgui.Dialog().notification("Xstream Pro", _t(30890, count))


def clear_cache_menu():
    options = [
        _t(30500),  # Profiles cache
        _t(30501),  # EPG cache
        _t(30891),  # Provider metadata cache
        _t(30772),  # Search history
        _t(30506),  # Kodi cache
        "Clear Trakt cache",
    ]
    dialog = xbmcgui.Dialog()
    choice = dialog.select(_t(30153), options)
    if choice < 0:
        return
    if choice == 0:
        options = [_t(30771)]  # All Profiles
        for i in range(1, 11):
            name = addon.getSetting(f"profile_{i}_name") or _t(30390, i)
            options.append(name)
        idx = dialog.select(_t(30500), options)
        if idx < 0:
            return
        if dialog.yesno(_t(30042), _t(30500)):
            if idx == 0:
                clear_all_caches()
            else:
                count = _cache_clear_profile(idx)
                xbmcgui.Dialog().notification("Xstream Pro", _t(30258, count))
    elif choice == 1:
        options = [_t(30771)]  # All Profiles
        for i in range(1, 11):
            name = addon.getSetting(f"profile_{i}_name") or _t(30390, i)
            options.append(name)
        idx = dialog.select(_t(30501), options)
        if idx < 0:
            return
        if dialog.yesno(_t(30042), _t(30501)):
            clear_epg_cache(idx)
    elif choice == 2:
        if dialog.yesno(_t(30042), _t(31133)):
            clear_provider_metadata_cache()
    elif choice == 3:
        _clear_search_history_menu()
    elif choice == 4:
        if dialog.yesno(_t(30042), _t(30506)):
            clear_kodi_cache()
    elif choice == 5:
        trakt_clear_cache_action()


def _clear_search_history_menu():
    """Submenu for clearing search history."""
    dialog = xbmcgui.Dialog()
    options = [
        _t(30773),  # Clear global search history
        _t(30774),  # Clear all profiles search history
    ]
    choice = dialog.select(_t(30772), options)
    if choice < 0:
        return
    if choice == 0:
        if dialog.yesno(_t(30770), _t(30773)):
            try:
                history_path = _search_history_path()
                if os.path.exists(history_path):
                    os.remove(history_path)
                    dialog.notification(_t(30770), _t(30093, _t(31401)))
                else:
                    dialog.notification(_t(30770), _t(30093, _t(31401)))
            except Exception as e:
                _log(f"Error clearing global search history: {e}")
                dialog.notification(_t(30770), _t(30091), xbmcgui.NOTIFICATION_ERROR)
    elif choice == 1:
        if dialog.yesno(_t(30770), _t(30774)):
            try:
                count = 0
                for i in range(1, 11):
                    history_path = _search_history_path(i)
                    if os.path.exists(history_path):
                        os.remove(history_path)
                        count += 1
                profile_count_label = _t(31415 if count == 1 else 31416, count)
                dialog.notification(_t(30770), _t(30093, profile_count_label))
            except Exception as e:
                _log(f"Error clearing profiles search history: {e}")
                dialog.notification(_t(30770), _t(30091), xbmcgui.NOTIFICATION_ERROR)


def clear_all_caches():
    count = _cache_clear_all_profiles()
    profile_path = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    for fname in os.listdir(profile_path):
        if (
            fname.startswith("epg_cache_profile_")
            or fname == "epg_cache.json"
            or fname == "view_prefs.json"
        ):
            try:
                os.remove(os.path.join(profile_path, fname))
                count += 1
            except Exception as e:
                _log(f"clear_all_caches remove warning: {e}")
    xbmcgui.Dialog().notification("Xstream Pro", _t(30258, count))


def clear_epg_cache(pnum=None):
    profile_path = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
    count = 0
    for fname in os.listdir(profile_path):
        if fname == "epg_cache.json" or fname.startswith("data_cache_epg"):
            try:
                os.remove(os.path.join(profile_path, fname))
                count += 1
            except Exception as e:
                _log(f"clear_epg_cache remove warning: {e}")
        elif pnum is not None and fname == f"epg_cache_profile_{pnum}.json":
            try:
                os.remove(os.path.join(profile_path, fname))
                count += 1
            except Exception as e:
                _log(f"clear_epg_cache remove warning: {e}")
        elif pnum is None and fname.startswith("epg_cache_profile_"):
            try:
                os.remove(os.path.join(profile_path, fname))
                count += 1
            except Exception as e:
                _log(f"clear_epg_cache remove warning: {e}")
    xbmcgui.Dialog().notification("Xstream Pro", _t(30303, count))


def clear_kodi_cache():
    """Clear Kodi's system cache (thumbnails, temp files, etc.)"""
    dialog = xbmcgui.Dialog()
    if not dialog.yesno(_t(30044), _t(30283)):
        return

    count = 0
    thumbs_path = xbmcvfs.translatePath("special://thumbnails/")
    if os.path.exists(thumbs_path):
        for root, dirs, files in os.walk(thumbs_path):
            for f in files:
                try:
                    os.remove(os.path.join(root, f))
                    count += 1
                except Exception as e:
                    _log(f"clear_kodi_cache thumbs warning: {e}")

    temp_path = xbmcvfs.translatePath("special://temp/")
    if os.path.exists(temp_path):
        for fname in os.listdir(temp_path):
            fpath = os.path.join(temp_path, fname)
            try:
                if os.path.isfile(fpath):
                    os.remove(fpath)
                    count += 1
                elif os.path.isdir(fpath):
                    shutil.rmtree(fpath)
                    count += 1
            except Exception as e:
                _log(f"clear_kodi_cache temp warning: {e}")

    cache_path = xbmcvfs.translatePath("special://cache/")
    if os.path.exists(cache_path):
        for fname in os.listdir(cache_path):
            fpath = os.path.join(cache_path, fname)
            try:
                if os.path.isfile(fpath):
                    os.remove(fpath)
                    count += 1
            except Exception as e:
                _log(f"clear_kodi_cache cache warning: {e}")

    dialog.notification("Xstream Pro", _t(30177, count))


def _extract_quality(name):
    """Extract resolution hint from stream name/category. Returns sortable int (higher = better)."""
    n = name.upper()
    if re.search(r"\b(4K|UHD|2160P?)\b", n):
        return 4
    if re.search(r"\b(1080P?|FHD|FULL\s+HD)\b", n):
        return 3
    if re.search(r"\b(720P?|HD)\b", n):
        return 2
    if re.search(r"\b(480P?|SD)\b", n):
        return 1
    return 0

def _extract_size_bytes(text):
    """Extract size hints like 1.4GB / 850 MB from source names."""
    if not text:
        return 0
    m = re.search(r"(?<!\d)(\d+(?:[.,]\d+)?)\s*(GB|GIB|G|MB|MIB)\b", str(text), re.I)
    if not m:
        return 0
    value = float(m.group(1).replace(",", "."))
    unit = m.group(2).upper()
    if unit in ("GB", "GIB", "G"):
        return int(value * 1024 * 1024 * 1024)
    return int(value * 1024 * 1024)

def _format_source_size(size_bytes):
    try:
        size_bytes = int(size_bytes or 0)
    except Exception:
        size_bytes = 0
    if size_bytes >= 1024 * 1024 * 1024:
        return "{0:.2f} GB".format(size_bytes / float(1024 * 1024 * 1024))
    if size_bytes >= 1024 * 1024:
        return "{0:.0f} MB".format(size_bytes / float(1024 * 1024))
    return ""

def _source_probe_timeout():
    try:
        return max(1, int(addon.getSetting("source_probe_timeout") or "4"))
    except Exception:
        return 4

def _is_bad_source_name(text):
    return bool(re.search(r"\b(CAM|HDCAM|HDTS|TS|TELESYNC|TC|HDTC|TELECINE|WORKPRINT|WP)\b", str(text or ""), re.I))

def _safe_probe_redirect(current_url, location):
    if not location:
        return ""
    next_url = urllib.parse.urljoin(current_url, location)
    parsed = urllib.parse.urlparse(next_url)
    if parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:
        return ""
    return next_url

def _source_size_from_headers(headers, ranged=False, status_code=0):
    content_range = headers.get("Content-Range") or headers.get("content-range") or ""
    m = re.search(r"/(\d+)\s*$", content_range)
    if m:
        try:
            return int(m.group(1))
        except Exception:
            pass
    content_length = headers.get("Content-Length") or headers.get("content-length") or ""
    if content_length:
        try:
            size = int(content_length)
            if not ranged or status_code == 200:
                return size
        except Exception:
            pass
    return 0

SOURCE_PROBE_LIMIT = 20
SOURCE_PROBE_DEADLINE_SECONDS = 2.5

def _probe_source_size_bytes(url, timeout=None, max_redirects=4):
    """Probe stream size using HEAD, then Range GET. Does not download the stream."""
    if not str(url or "").lower().startswith(("http://", "https://")):
        return 0
    if timeout is None:
        timeout = _source_probe_timeout()
    custom_ua = addon.getSetting("custom_user_agent") or "Kodi"
    base_headers = {"User-Agent": custom_ua}
    for method in ("HEAD", "GET"):
        current = url
        headers = dict(base_headers)
        ranged = method == "GET"
        if ranged:
            headers["Range"] = "bytes=0-0"
        for _hop in range(max_redirects + 1):
            resp = None
            try:
                resp = requests.request(
                    method,
                    current,
                    headers=headers,
                    stream=True,
                    allow_redirects=False,
                    timeout=timeout,
                )
                if 300 <= resp.status_code < 400:
                    next_url = _safe_probe_redirect(current, resp.headers.get("Location", ""))
                    if not next_url:
                        break
                    current = next_url
                    continue
                if resp.status_code in (200, 206):
                    size = _source_size_from_headers(resp.headers, ranged=ranged, status_code=resp.status_code)
                    if size > 0:
                        return size
                break
            except Exception as exc:
                _log("Source size probe warning: {0}".format(exc))
                break
            finally:
                try:
                    if resp is not None:
                        resp.close()
                except Exception:
                    pass
    return 0

def _probe_source_sizes(results):
    """Probe only the best candidate sources, with a hard deadline."""
    probed = []
    for pnum, pname, r in results:
        copied = dict(r)
        text = "{0} {1} {2}".format(
            copied.get("name", ""),
            copied.get("category", ""),
            copied.get("extension", ""),
        )
        parsed_size = _extract_size_bytes(text)
        copied["parsed_size_bytes"] = parsed_size
        copied["size_bytes"] = parsed_size
        probed.append([pnum, pname, copied])
    sort_order = addon.getSetting("source_sort_order") or "Quality then size"
    show_size = addon.getSetting("source_show_size").lower() == "true"
    needs_probe = show_size or sort_order in ("Size then quality", "Quality then size")
    if not needs_probe or not probed:
        return [(pnum, pname, r) for pnum, pname, r in probed]

    def preliminary_key(item):
        pnum, _pname, r = item
        quality = int(r.get("quality") or 0)
        score = int(r.get("score") or 0)
        parsed_size = int(r.get("parsed_size_bytes") or 0)
        return (-quality, -score, -parsed_size, pnum)

    candidates = sorted(probed, key=preliminary_key)[:SOURCE_PROBE_LIMIT]

    def worker(item):
        r = item[2]
        probed_size = _probe_source_size_bytes(r.get("url", ""))
        if probed_size:
            r["size_bytes"] = probed_size

    threads = []
    for item in candidates:
        t = threading.Thread(target=worker, args=(item,))
        t.daemon = True
        t.start()
        threads.append(t)

    deadline = time.monotonic() + SOURCE_PROBE_DEADLINE_SECONDS
    for t in threads:
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            break
        t.join(remaining)

    return [(pnum, pname, r) for pnum, pname, r in probed]

def _sort_source_results(results):
    sort_order = addon.getSetting("source_sort_order") or "Quality then size"
    skip_cam = addon.getSetting("source_skip_cam").lower() == "true"
    if skip_cam:
        filtered = []
        for pnum, pname, r in results:
            text = "{0} {1}".format(r.get("name", ""), r.get("category", ""))
            if not _is_bad_source_name(text):
                filtered.append((pnum, pname, r))
        if filtered:
            results = filtered
    def key(item):
        pnum, _pname, r = item
        quality = int(r.get("quality") or 0)
        score = int(r.get("score") or 0)
        size = int(r.get("size_bytes") or 0)
        if sort_order == "Size then quality":
            return (-size, -quality, -score, pnum)
        if sort_order == "Best title match":
            return (-score, -quality, -size, pnum)
        if sort_order == "Profile order":
            return (pnum, -quality, -size, -score)
        return (-quality, -size, -score, pnum)
    results.sort(key=key)
    return results

def _prepare_source_results(results):
    results = _probe_source_sizes(results)
    return _sort_source_results(results)

def _source_result_label(pname, r):
    quality_map = {4: "4K", 3: "1080p", 2: "720p", 1: "480p", 0: ""}
    parts = []
    q = quality_map.get(int(r.get("quality") or 0), "")
    if q:
        parts.append(q)
    if addon.getSetting("source_show_size").lower() == "true":
        size = _format_source_size(r.get("size_bytes", 0))
        if size:
            parts.append(size)
    if r.get("extension"):
        parts.append(r["extension"].upper())
    if r.get("category"):
        parts.append(r["category"])
    parts.append(pname)
    return "{0} | {1}".format(r["name"], " | ".join(parts))


_STOP_WORDS = {"the", "a", "an", "and", "of", "in", "on", "at", "to", "for", "with", "from", "by", "is", "it", "or"}


def _normalize_title(title):
    """Lowercase and strip punctuation for fuzzy matching. Preserves spaces for word splitting."""
    return re.sub(r"[^a-z0-9\s]", "", title.lower())


def _filter_stop_words(word_set):
    """Remove common stop words from a word set for better matching."""
    return word_set - _STOP_WORDS


def _title_match_score(search_title, candidate_name):
    """Return match score 0-100. 100 = exact normalized match."""
    s = _normalize_title(search_title)
    c = _normalize_title(candidate_name)
    if not s or not c:
        return 0
    if s == c:
        return 100

    s_words = set(s.split())
    c_words = set(c.split())

    # Substring match: only if the shorter title has at least half the
    # word count of the longer one. Prevents "Mary" matching "Project Hail Mary".
    if len(s) >= 3 and len(c) >= 3:
        shorter_words = s_words if len(s_words) <= len(c_words) else c_words
        longer_words  = c_words if len(s_words) <= len(c_words) else s_words
        word_ratio = len(shorter_words) / max(len(longer_words), 1)
        if word_ratio >= 0.5 and (s in c or c in s):
            return 80

    # Word-level overlap (stop words excluded for meaningful matching)
    s_words_filtered = _filter_stop_words(s_words)
    c_words_filtered = _filter_stop_words(c_words)
    if s_words_filtered and c_words_filtered:
        overlap = len(s_words_filtered & c_words_filtered) / max(len(s_words_filtered), len(c_words_filtered))
        return int(overlap * 60)
    return 0


def _search_xtream_vod(creds, title, year=None, tmdb_type="movie", season="", episode="", tmdb_id=""):
    """Search Xtream VOD for title match. Returns list of dicts."""
    results = []
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    if not url or not user or not pwd:
        _log("  Xtream creds missing")
        return results

    stype = "series" if tmdb_type == "tv" else "movie"
    _log(f"  Searching Xtream {stype} catalog for: {title} tmdb_id={tmdb_id}")

    try:
        streams = _get_cached_xtream_streams(url, user, pwd, stype)
    except Exception as e:
        _log(f"  Xtream {stype} fetch error: {e}")
        return results

    _log(f"  Xtream {stype} catalog: {len(streams)} items")

    # Build category ID → name map for folder display
    cat_map = {}
    try:
        cats = _get_cached_xtream_categories(url, user, pwd, stype)
        for c in cats or []:
            cat_map[str(c.get("category_id", ""))] = c.get("category_name", "")
    except Exception:
        pass

    # --- TMDB ID fast path ---
    if tmdb_id:
        _log(f"  Trying TMDB ID match: {tmdb_id}")
        for s in streams:
            sid = str(s.get("stream_id") or s.get("series_id") or "")
            # 1) Check stream list item itself
            stream_tmdb = str(s.get("tmdb_id") or s.get("tmdb") or "")
            # 2) Check cached vod_info / series_info
            if not stream_tmdb and sid:
                cname = f"vod_info_{sid}" if stype == "movie" else f"series_info_{sid}"
                cached = _cache_load(cname)
                if cached:
                    stream_tmdb = str(cached.get("info", {}).get("tmdb_id") or cached.get("info", {}).get("tmdb") or "")
            if stream_tmdb and stream_tmdb == str(tmdb_id):
                _log(f"  TMDB ID direct match: {s.get('name')} (id={sid})")
                if tmdb_type == "tv" and season and episode:
                    # Need to resolve season/episode even on TMDB match
                    info = IPTV.get_xtream_series_info(url, user, pwd, s.get("series_id", ""))
                    eps = info.get("episodes", {}).get(str(season), [])
                    if not eps and str(season).isdigit():
                        eps = info.get("episodes", {}).get(int(season), [])
                    for ep in eps:
                        if str(ep.get("episode_num", "")) == str(episode):
                            ep_title = ep.get("title", f"S{season}E{episode}")
                            stream_url = IPTV.build_xtream_stream_url(url, user, pwd, ep, "series")
                            series_info = info.get("info", {})
                            icon = (s.get("stream_icon", "")
                                    or series_info.get("cover", "")
                                    or series_info.get("movie_image", "")
                                    or series_info.get("stream_icon", ""))
                            results.append({
                                "name": f"{s.get('name', '')} - {ep_title}",
                                "stream_id": str(ep.get("id", "")),
                                "extension": ep.get("container_extension", ""),
                                "url": stream_url,
                                "score": 100,
                                "quality": _extract_quality(
                                    f"{s.get('name', '')} {ep_title} {cat_map.get(str(s.get('category_id', '')), '')}"
                                ),
                                "source": "xtream",
                                "icon": icon,
                                "category": cat_map.get(str(s.get("category_id", "")), ""),
                                "tmdb_id": str(tmdb_id),
                                "exact_tmdb_match": True,
                            })
                            return results
                else:
                    stream_url = IPTV.build_xtream_stream_url(url, user, pwd, s, "movie")
                    results.append({
                        "name": s.get("name", ""),
                        "stream_id": sid,
                        "extension": s.get("container_extension", ""),
                        "url": stream_url,
                        "score": 100,
                        "quality": _extract_quality(
                            f"{s.get('name', '')} {cat_map.get(str(s.get('category_id', '')), '')}"
                        ),
                        "source": "xtream",
                        "icon": s.get("stream_icon", ""),
                        "category": cat_map.get(str(s.get("category_id", "")), ""),
                        "tmdb_id": str(tmdb_id),
                        "exact_tmdb_match": True,
                    })
                    return results
        _log("  No TMDB ID match in catalog")

    search_words = _filter_stop_words(set(_normalize_title(title).split()))

    if tmdb_type == "tv" and season and episode:
        # Find matching series, then resolve season/episode
        matched_series = []
        for s in streams:
            name = s.get("name", "")
            name_words = _filter_stop_words(set(_normalize_title(name).split()))
            if search_words and not (search_words & name_words):
                continue
            score = _title_match_score(title, name)
            if score < 50:
                continue
            if year and str(year) in name:
                score += 10
            if year:
                found_years = re.findall(r"\b(19\d{2}|20\d{2})\b", name)
                if found_years and str(year) not in found_years:
                    continue
            if year and not re.findall(r"\b(19\d{2}|20\d{2})\b", name):
                score -= 15
            matched_series.append((score, s))
        matched_series.sort(key=lambda x: -x[0])
        _log(f"  Series title matches (score>=50): {len(matched_series)}")
        for score, s in matched_series[:3]:  # Try top 3 matches
            series_id = s.get("series_id", "")
            if not series_id:
                continue
            _log(f"  Trying series '{s.get('name')}' (id={series_id}, score={score})")
            try:
                info = IPTV.get_xtream_series_info(url, user, pwd, series_id)
            except Exception as e:
                _log(f"  Series info error: {e}")
                continue
            eps = info.get("episodes", {})
            season_key = str(season)
            if season_key not in eps:
                season_key = int(season) if season.isdigit() else season
            season_eps = eps.get(season_key, [])
            if not season_eps:
                _log(f"  Season {season} not found in series")
                continue
            _log(f"  Season {season}: {len(season_eps)} episodes")
            for ep in season_eps:
                ep_num = str(ep.get("episode_num", ""))
                if ep_num == str(episode):
                    ep_title = ep.get("title", f"S{season}E{episode}")
                    stream_url = IPTV.build_xtream_stream_url(url, user, pwd, ep, "series")
                    series_info = info.get("info", {})
                    icon = (s.get("stream_icon", "")
                            or series_info.get("cover", "")
                            or series_info.get("movie_image", "")
                            or series_info.get("stream_icon", ""))
                    results.append({
                        "name": f"{s.get('name', '')} - {ep_title}",
                        "stream_id": str(ep.get("id", "")),
                        "extension": ep.get("container_extension", ""),
                        "url": stream_url,
                        "score": score + 20,
                        "quality": _extract_quality(
                            f"{s.get('name', '')} {ep_title} {cat_map.get(str(s.get('category_id', '')), '')}"
                        ),
                        "source": "xtream",
                        "icon": icon,
                        "category": cat_map.get(str(s.get("category_id", "")), ""),
                    })
                    _log(f"  Found episode: {ep_title}")
                    return results  # Exact match found
            _log(f"  Episode {episode} not found in season {season}")
    else:
        # Movie search
        for s in streams:
            name = s.get("name", "")
            name_words = _filter_stop_words(set(_normalize_title(name).split()))
            if search_words and not (search_words & name_words):
                continue
            score = _title_match_score(title, name)
            if score < 50:
                continue
            if year and str(year) in name:
                score += 10
            if year:
                found_years = re.findall(r"\b(19\d{2}|20\d{2})\b", name)
                if found_years and str(year) not in found_years:
                    continue
            if year and not re.findall(r"\b(19\d{2}|20\d{2})\b", name):
                score -= 15
            stream_id = str(s.get("stream_id", ""))
            ext = s.get("container_extension", "")
            stream_url = IPTV.build_xtream_stream_url(url, user, pwd, s, "movie")
            results.append({
                "name": name,
                "stream_id": stream_id,
                "extension": ext,
                "url": stream_url,
                "score": score,
                "quality": _extract_quality(
                    f"{name} {cat_map.get(str(s.get('category_id', '')), '')}"
                ),
                "source": "xtream",
                "icon": s.get("stream_icon", ""),
                "category": cat_map.get(str(s.get("category_id", "")), ""),
            })
            _log(f"  Movie match: {name} (score={score})")
        return results


def _search_live_channels(creds, title, year=None):
    """Search live channels as fallback. Returns list of dicts."""
    results = []
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    if not url or not user or not pwd:
        return results
    try:
        channels = _get_cached_xtream_streams(url, user, pwd, "live")
    except Exception as e:
        _log(f"  Live channel search error: {e}")
        return results
    _log(f"  Live catalog: {len(channels)} items")
    cat_map = {}
    try:
        cats = _get_cached_xtream_categories(url, user, pwd, "live")
        for c in cats or []:
            cat_map[str(c.get("category_id", ""))] = c.get("category_name", "")
    except Exception:
        pass
    search_words = set(_normalize_title(title).split())
    for ch in channels:
        name = ch.get("name", "")
        if not (search_words & set(_normalize_title(name).split())):
            continue
        score = _title_match_score(title, name)
        if score < 40:
            continue
        if year and str(year) in name:
            score += 10
        if year:
            found_years = re.findall(r"\b(19\d{2}|20\d{2})\b", name)
            if found_years and str(year) not in found_years:
                continue
        if year and not re.findall(r"\b(19\d{2}|20\d{2})\b", name):
            score -= 15
        stream_url = IPTV.build_xtream_stream_url(url, user, pwd, ch, "live")
        results.append({
            "name": f"[Live] {name}",
            "stream_id": str(ch.get("stream_id", "")),
            "extension": "",
            "url": stream_url,
            "score": score,
            "quality": _extract_quality(
                f"{name} {cat_map.get(str(ch.get('category_id', '')), '')}"
            ),
            "source": "xtream_live",
            "category": cat_map.get(str(ch.get("category_id", "")), ""),
        })
        _log(f"  Live match: {name} (score={score})")
    return results


def _search_m3u_vod(creds, title, year=None):
    """Search M3U entries for title match (no-creds M3U). Returns list of dicts."""
    results = []
    m3u_url = creds.get("m3u_url", "")
    if not m3u_url:
        return results
    _log(f"  Searching M3U: {m3u_url}")
    try:
        channels = _get_cached_m3u_channels(m3u_url)
    except Exception as e:
        _log(f"  M3U search error for {title}: {e}")
        return results
    _log(f"  M3U items: {len(channels)}")
    for ch in channels:
        name = ch.get("name", "")
        score = _title_match_score(title, name)
        if score < 30:
            continue
        if year:
            found_years = re.findall(r"\b(19\d{2}|20\d{2})\b", name)
            if found_years and str(year) not in found_years:
                continue
            if found_years and str(year) in found_years:
                score += 10
        if year and not re.findall(r"\b(19\d{2}|20\d{2})\b", name):
            score -= 15
        results.append({
            "name": name,
            "stream_id": ch.get("tvg_id", ""),
            "extension": "",
            "url": ch.get("url", ""),
            "score": score,
            "quality": _extract_quality(f"{name} {ch.get('group', '')}"),
            "source": "m3u",
            "category": ch.get("group", ""),
        })
        _log(f"  M3U match: {name} (score={score})")
    return results


def _select_xtream_profile():
    profiles = []
    labels = []
    for n in range(1, 11):
        if addon.getSetting(f"profile_{n}_enabled") != "true":
            continue
        if not addon.getSetting(f"profile_{n}_xtream_url"):
            continue
        profiles.append(n)
        labels.append(addon.getSetting(f"profile_{n}_name") or _t(30390, n))
    if not profiles:
        xbmcgui.Dialog().notification("Xstream Pro", _t(30893))
        return None
    idx = xbmcgui.Dialog().select(_t(30788), labels)
    if idx < 0:
        return None
    return profiles[idx]


def _resolve_xtream_season(creds, showname, season, year=""):
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    streams = _get_cached_xtream_streams(url, user, pwd, "series")
    matches = []
    for s in streams:
        name = s.get("name", "")
        score = _title_match_score(showname, name)
        if score < 50:
            continue
        if year and str(year) in name:
            score += 10
        matches.append((score, s))
    matches.sort(key=lambda x: -x[0])
    if not matches:
        return None, None, []
    series = matches[0][1]
    info = IPTV.get_xtream_series_info(url, user, pwd, series.get("series_id", ""))
    eps_map = info.get("episodes", {})
    season_key = str(season)
    eps = eps_map.get(season_key, [])
    if not eps and season_key.isdigit():
        eps = eps_map.get(int(season_key), [])
    return series, info, eps


def _resolve_xtream_series(creds, showname, year="", tmdb_id=""):
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    streams = _get_cached_xtream_streams(url, user, pwd, "series")
    matches = []
    for s in streams:
        name = s.get("name", "")
        score = _title_match_score(showname, name)
        if score < 50:
            continue
        if year and str(year) in name:
            score += 10
        if year:
            found_years = re.findall(r"\b(19\d{2}|20\d{2})\b", name)
            if found_years and str(year) not in found_years:
                continue
        matches.append((score, s))
    matches.sort(key=lambda x: -x[0])
    if not matches:
        return None, None
    series = matches[0][1]
    info = IPTV.get_xtream_series_info(url, user, pwd, series.get("series_id", ""))
    return series, info


def _season_eps_from_info(info, season):
    eps_map = info.get("episodes", {})
    season_key = str(season)
    eps = eps_map.get(season_key, [])
    if not eps and season_key.isdigit():
        eps = eps_map.get(int(season_key), [])
    return eps or []


def _all_eps_from_info(info):
    eps_map = info.get("episodes", {})
    out = []
    for season_key in sorted(eps_map.keys(), key=lambda x: (0, int(x)) if str(x).isdigit() else (1, str(x))):
        for ep in eps_map.get(season_key, []) or []:
            out.append((season_key, ep))
    return out


def _find_xtream_series_sources(showname, year="", season=""):
    out = []
    for n in range(1, 11):
        if addon.getSetting(f"profile_{n}_enabled") != "true":
            continue
        if not addon.getSetting(f"profile_{n}_xtream_url"):
            continue
        creds = _get_credentials_for_profile(n)
        pname = creds.get("name") or addon.getSetting(f"profile_{n}_name") or _t(30390, n)
        url = creds.get("xtream_url", "")
        user = creds.get("xtream_username", "")
        pwd = creds.get("xtream_password", "")
        streams = _call_with_active_profile(
            n,
            _get_cached_xtream_streams,
            url,
            user,
            pwd,
            "series",
        )
        matches = []
        for s in streams:
            name = s.get("name", "")
            score = _title_match_score(showname, name)
            if score < 50:
                continue
            if year and str(year) in name:
                score += 10
            matches.append((score, s))
        matches.sort(key=lambda x: -x[0])
        for score, series in matches[:3]:
            info = IPTV.get_xtream_series_info(url, user, pwd, series.get("series_id", ""))
            items = _season_eps_from_info(info, season) if season else [ep for _, ep in _all_eps_from_info(info)]
            if items:
                out.append((n, pname, creds, series, info, score, len(items)))
    out.sort(key=lambda x: (-x[5], x[0]))
    return out


def _search_all_profiles_vod(title, year=None, tmdb_type="movie", season="", episode="", tmdb_id=""):
    """Search all enabled profiles for VOD matches. Returns list of (profile_num, profile_name, result_dict)."""
    all_results = []
    original_active = pm.active
    _log(f"Searching all profiles: title='{title}' year='{year}' type={tmdb_type} S{season}E{episode} tmdb_id={tmdb_id}")
    try:
        for n in range(1, 11):
            if addon.getSetting(f"profile_{n}_enabled") != "true":
                continue
            pm.active = str(n)
            creds = _get_credentials_for_profile(n)
            profile_name = creds.get("name", f"Profile {n}")
            _log(f"Profile {n} ({profile_name}):")
            try:
                matches = []
                if creds.get("xtream_url"):
                    matches = _search_xtream_vod(creds, title, year, tmdb_type, season, episode, tmdb_id)
                    if not matches and tmdb_type == "movie":
                        # Live fallback for movies only (TV live fallback is trickier)
                        live = _search_live_channels(creds, title, year)
                        matches.extend(live)
                elif creds.get("m3u_url"):
                    matches = _search_m3u_vod(creds, title, year=year)
                else:
                    _log("  No credentials configured")
                _log(f"  Total matches: {len(matches)}")
                for m in matches:
                    all_results.append((n, profile_name, m))
            except Exception as e:
                _log(f"Profile {n} VOD search error: {e}")
    finally:
        pm.active = original_active
    all_results = _prepare_source_results(all_results)
    _log(f"Overall results: {len(all_results)}")
    return all_results


def _tmdb_return_url(tmdb_type):
    mode_name = "xstream_tmdb_tvshows_menu" if tmdb_type == "tv" else "xstream_tmdb_movies_menu"
    return build_url({"mode": mode_name})


def _return_to_xstream_tmdb_menu(tmdb_type):
    try:
        xbmc.executebuiltin(
            "Container.Update({0},replace)".format(_tmdb_return_url(tmdb_type))
        )
    except Exception as e:
        _log(f"TMDB return navigation warning: {e}")


def _select_source_result(results):
    labels = [_source_result_label(pname, r) for _pnum, pname, r in results]
    xbmc.sleep(_tvos_sleep(300))
    idx = xbmcgui.Dialog().select(_t(31065), labels)
    if idx < 0:
        return None
    return results[idx]


def _source_auto_play_first_enabled():
    return addon.getSetting("source_auto_play_first").lower() == "true"


def _source_result_exact_tmdb_match(result, tmdb_id):
    if not tmdb_id:
        return False
    if result.get("exact_tmdb_match"):
        return True
    return str(result.get("tmdb_id") or "").strip() == str(tmdb_id).strip()


def _source_result_play_url(pnum, r, title, tmdb_type, tmdb_id):
    stype = "series" if tmdb_type == "tv" else "movie"
    icon = r.get("icon", "") or _local_icon("xstream_manager")
    return build_url({
        "mode": "play_stream",
        "url": r.get("url", ""),
        "name": r["name"],
        "title": title,
        "icon": icon,
        "stype": stype,
        "profile_num": pnum,
        "tmdb_id": tmdb_id,
    })


def _play_source_result(selected, title, tmdb_type, tmdb_id):
    pnum, pname, r = selected
    _log(f"Playing source: {r.get('name', '')} ({pname})")
    play_url = _source_result_play_url(pnum, r, title, tmdb_type, tmdb_id)
    xbmc.executebuiltin("PlayMedia({0})".format(play_url))
    xbmcplugin.endOfDirectory(addon_handle, succeeded=True)


def tmdb_source_action():
    item_title = args.get("title", [""])[0]
    showname = args.get("showname", [""])[0]
    tmdb_type = _normalize_tmdb_type(args.get("tmdb_type", ["movie"])[0]) or "movie"
    title = showname if tmdb_type == "tv" and showname else item_title
    year = args.get("year", [""])[0]
    tmdb_id = args.get("tmdb_id", [""])[0]
    season = args.get("season", [""])[0]
    episode = args.get("episode", [""])[0]
    intent = args.get("intent", ["play"])[0]
    if tmdb_id == "_":
        tmdb_id = ""
    results = _search_all_profiles_vod(title, year, tmdb_type, season, episode, tmdb_id)
    if not results:
        xbmcgui.Dialog().notification("Xstream Pro", _t(31064))
        return
    selected = _select_source_result(results)
    if not selected:
        return
    pnum, pname, r = selected
    if intent == "download":
        icon = r.get("icon", "") or _local_icon("xstream_manager")
        if start_download(
            r["url"],
            title if tmdb_type == "tv" else r["name"],
            year=year,
            media_type="episode" if tmdb_type == "tv" else "movie",
            season=season,
            episode=episode,
            poster=icon,
            profile_num=pnum,
        ):
            xbmcgui.Dialog().notification("Xstream Pro", _t(30942))
        else:
            xbmcgui.Dialog().notification("Xstream Pro", _t(30949))
        return
    _play_source_result(selected, title, tmdb_type, tmdb_id)


def source_select():
    item_title = args.get("title", [""])[0]
    showname = args.get("showname", [""])[0]
    title = showname if args.get("tmdb_type", ["movie"])[0] == "tv" and showname else item_title
    year = args.get("year", [""])[0]
    tmdb_type = _normalize_tmdb_type(args.get("tmdb_type", ["movie"])[0]) or "movie"
    tmdb_id = args.get("tmdb_id", [""])[0]
    season = args.get("season", [""])[0]
    episode = args.get("episode", [""])[0]
    intent = args.get("intent", ["play"])[0]
    if tmdb_id == "_":
        tmdb_id = ""
    if not title:
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
        return
    _set_tmdb_content(tmdb_type, season, episode)
    xbmc.executebuiltin("ActivateWindow(busydialognocancel)")
    xbmc.sleep(150)
    try:
        results = _search_all_profiles_vod(title, year, tmdb_type, season, episode, tmdb_id)
    finally:
        xbmc.executebuiltin("Dialog.Close(busydialognocancel)")
    if not results:
        xbmcgui.Dialog().notification("Xstream Pro", _t(31064))
        li = _new_listitem(_t(31064))
        xbmcplugin.addDirectoryItems(addon_handle, [("", li, False)])
        xbmcplugin.endOfDirectory(addon_handle)
        _log_route_timing("source_select_empty")
        return
    if intent == "play" and _source_auto_play_first_enabled():
        pnum, pname, r = results[0]
        if tmdb_id and not _source_result_exact_tmdb_match(r, tmdb_id):
            _log("Auto-play skipped: no exact TMDB ID source match for {0}".format(tmdb_id))
        else:
            stype = "series" if tmdb_type == "tv" else "movie"
            _log(f"Auto-playing source: {r.get('name', '')} ({pname})")
            play_stream(
                r["url"],
                r["name"],
                title=title,
                icon=r.get("icon", "") or _local_icon("xstream_manager"),
                stype=stype,
                profile_num=pnum,
                tmdb_id=tmdb_id,
            )
            return
    stype = "series" if tmdb_type == "tv" else "movie"
    directory_items = []
    for pnum, pname, r in results:
        label = _source_result_label(pname, r)
        icon = r.get("icon", "") or _local_icon("xstream_manager")
        url = r.get("url", "")
        li = _new_listitem(label)
        art = {"poster": icon, "fanart": ""}
        _set_tmdb_item_info(
            li,
            r["name"],
            icon,
            art,
            tmdb_type,
            year,
            "",
            season,
            episode,
            0,
        )
        li.setProperty("IsPlayable", "true")
        play_url = _source_result_play_url(pnum, r, title, tmdb_type, tmdb_id)
        download_url = build_url({
            "mode": "download",
            "url": url,
            "title": title if tmdb_type == "tv" else r["name"],
            "year": year,
            "stype": stype,
            "tmdb_type": tmdb_type,
            "season": season,
            "episode": episode,
            "profile_num": pnum,
            "poster": icon,
        })
        fav_id = f"source:{pnum}:{r.get('source', '')}:{r.get('stream_id', '')}:{r['name']}"
        ctx = [
            (
                _t(31073),
                "PlayMedia({0})".format(play_url),
            )
        ]
        if is_url_downloadable(url):
            ctx.append((_t(30941), f"RunPlugin({download_url})"))
        ctx.extend(_build_favorite_ctx(
            fav_id,
            r["name"],
            stype,
            icon,
            play_url,
            profile_num=None,
            include_download=False,
        ))
        li.addContextMenuItems(ctx)
        item_url = download_url if intent == "download" else play_url
        directory_items.append((item_url, li, False))
    xbmcplugin.addDirectoryItems(addon_handle, directory_items)
    xbmcplugin.endOfDirectory(addon_handle)
    _log_route_timing("source_select")


def resolve_source():
    """Auto-play fallback for cached play_movie/play_episode configs.

    If TMDB Helper still has an old cached player config using play_movie/
    play_episode, this auto-plays the first source without any dialogs.
    """
    title = args.get("showname", [""])[0] or args.get("title", [""])[0]
    year = args.get("year", [""])[0]
    tmdb_type = _normalize_tmdb_type(args.get("tmdb_type", ["movie"])[0]) or "movie"
    tmdb_id = args.get("tmdb_id", [""])[0]
    season = args.get("season", [""])[0]
    episode = args.get("episode", [""])[0]

    _log(f"Resolve source (auto-play fallback): title={title}, year={year}, type={tmdb_type} tmdb_id={tmdb_id}")

    if not title:
        _return_to_xstream_tmdb_menu(tmdb_type)
        xbmcplugin.setResolvedUrl(addon_handle, False, xbmcgui.ListItem())
        return

    xbmc.executebuiltin("ActivateWindow(busydialognocancel)")
    xbmc.sleep(_tvos_sleep(600))
    try:
        results = _search_all_profiles_vod(title, year, tmdb_type, season, episode, tmdb_id)
    finally:
        xbmc.executebuiltin("Dialog.Close(busydialognocancel)")

    if not results:
        xbmcgui.Dialog().notification("Xstream Pro", _t(31064))
        _return_to_xstream_tmdb_menu(tmdb_type)
        xbmcplugin.setResolvedUrl(addon_handle, False, xbmcgui.ListItem())
        return

    pnum, pname, r = results[0]
    stype = "series" if tmdb_type == "tv" else "movie"
    play_stream(
        r["url"],
        r["name"],
        title=title,
        icon=r.get("icon", "") or _local_icon("xstream_manager"),
        stype=stype,
        profile_num=pnum,
    )


def tmdb_series_action():
    showname = args.get("showname", [""])[0] or args.get("title", [""])[0]
    year = args.get("year", [""])[0]
    season = args.get("season", [""])[0]
    intent = args.get("intent", ["play"])[0]
    tmdb_id = args.get("tmdb_id", [""])[0]
    if not showname:
        xbmcgui.Dialog().notification("Xstream Pro", _t(31064))
        return
    sources = _find_xtream_series_sources(showname, year, season)
    if not sources:
        xbmcgui.Dialog().notification("Xstream Pro", _t(31064))
        return
    labels = []
    for pnum, pname, creds, series, info, score, count in sources:
        labels.append(f"{series.get('name', showname)} | {count} episodes | {pname}")
    xbmc.sleep(_tvos_sleep(300))
    idx = xbmcgui.Dialog().select(_t(31065), labels)
    if idx < 0:
        return
    pnum, pname, creds, series, info, score, count = sources[idx]
    if season:
        items = [(season, ep) for ep in _season_eps_from_info(info, season)]
    else:
        items = _all_eps_from_info(info)
    if not items:
        xbmcgui.Dialog().notification("Xstream Pro", _t(31064))
        return
    url = creds.get("xtream_url", "")
    user = creds.get("xtream_username", "")
    pwd = creds.get("xtream_password", "")
    if intent == "download":
        count = 0
        for season_num, ep in items:
            ep_num = ep.get("episode_num", "")
            play_url = IPTV.build_xtream_stream_url(url, user, pwd, ep, "series")
            if not is_url_downloadable(play_url):
                continue
            if start_download(
                play_url,
                showname,
                year=year,
                media_type="episode",
                season=season_num,
                episode=ep_num,
                poster=ep.get("info", {}).get("movie_image", ""),
                plot=ep.get("info", {}).get("plot", ""),
                profile_num=pnum,
            ):
                count += 1
        xbmcgui.Dialog().notification("Xstream Pro", f"{_t(30953)}: {count}")
        return
    playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
    playlist.clear()
    for season_num, ep in items:
        ep_id = str(ep.get("id", ""))
        ep_num = ep.get("episode_num", "")
        ep_title = ep.get("title") or _t(30542, ep_num or "?")
        play_url = IPTV.build_xtream_stream_url(url, user, pwd, ep, "series")
        plugin_url = build_url({
            "mode": "play_stream",
            "url": play_url,
            "name": ep_title,
            "title": showname,
            "icon": ep.get("info", {}).get("movie_image", ""),
            "stype": "series",
            "series_id": series.get("series_id", ""),
            "season_num": season_num,
            "ep_id": ep_id,
            "profile_num": pnum,
        })
        li = xbmcgui.ListItem(label=ep_title)
        playlist.add(plugin_url, li)
    xbmc.Player().play(playlist)


if mode is None:
    main_menu()
elif mode == "live_menu":
    live_menu(args.get("profile_num", [None])[0])
elif mode == "m3u_group":
    try:
        page = int(args.get("page", ["1"])[0])
    except ValueError:
        page = 1
    try:
        pnum = int(args.get("pnum", [0])[0])
    except (ValueError, TypeError):
        pnum = None
    m3u_group(args.get("group", [""])[0], pnum, page)
elif mode == "m3u_simple_section":
    try:
        page = int(args.get("page", ["1"])[0])
    except ValueError:
        page = 1
    try:
        pnum = int(args.get("pnum", [0])[0])
    except (ValueError, TypeError):
        pnum = None
    _show_all_m3u_channels(
        pnum,
        page,
        args.get("stype", [""])[0],
    )
elif mode == "m3u_all_channels":
    try:
        page = int(args.get("page", ["1"])[0])
    except ValueError:
        page = 1
    try:
        pnum = int(args.get("pnum", [0])[0])
    except (ValueError, TypeError):
        pnum = None
    _show_all_m3u_channels(pnum, page)
elif mode == "xtream_categories":
    xtream_categories(args.get("type", ["live"])[0], args.get("profile_num", [None])[0])
elif mode == "xtream_streams":
    try:
        page = int(args.get("page", ["1"])[0])
    except ValueError:
        page = 1
    try:
        pnum = int(args.get("profile_num", [0])[0])
    except (ValueError, TypeError):
        pnum = None
    xtream_streams(
        args.get("type", ["live"])[0],
        args.get("cat_id", [""])[0],
        page,
        args.get("adult", ["0"])[0],
        pnum,
    )
elif mode == "xtream_series":
    try:
        pnum = int(args.get("profile_num", [0])[0])
    except (ValueError, TypeError):
        pnum = None
    xtream_series(args.get("series_id", [""])[0], pnum)
elif mode == "xtream_season":
    try:
        pnum = int(args.get("profile_num", [0])[0])
    except (ValueError, TypeError):
        pnum = None
    xtream_season(args.get("series_id", [""])[0], args.get("season_num", [""])[0], pnum)
elif mode == "movies_menu":
    movies_menu(args.get("profile_num", [None])[0])
elif mode == "series_menu":
    series_menu(args.get("profile_num", [None])[0])
elif mode == "replay_menu":
    replay_menu(args.get("profile_num", [None])[0])
elif mode == "replay_channels":
    try:
        page = int(args.get("page", ["1"])[0])
    except ValueError:
        page = 1
    try:
        pnum = int(args.get("profile_num", [0])[0])
    except (ValueError, TypeError):
        pnum = None
    replay_channels(
        args.get("cat_id", [""])[0],
        page,
        args.get("adult", ["0"])[0],
        pnum,
    )
elif mode == "replay_channel":
    try:
        pnum = int(args.get("profile_num", [0])[0])
    except (ValueError, TypeError):
        pnum = None
    replay_channel(
        args.get("stream_id", [""])[0],
        args.get("epg_id", [""])[0],
        args.get("name", [""])[0],
        pnum,
    )
elif mode == "replay_play":
    try:
        pnum = int(args.get("profile_num", [0])[0])
    except (ValueError, TypeError):
        pnum = None
    replay_play(
        args.get("stream_id", [""])[0],
        args.get("start", [""])[0],
        args.get("duration", [""])[0],
        pnum,
    )
elif mode == "replay_channel_m3u":
    try:
        pnum = int(args.get("profile_num", [0])[0])
    except (ValueError, TypeError):
        pnum = None
    replay_channel_m3u(
        args.get("channel_name", [""])[0],
        args.get("epg_id", [""])[0],
        args.get("channel_url", [""])[0],
        args.get("catchup", [""])[0],
        args.get("catchup_source", [""])[0],
        args.get("logo", [""])[0],
        pnum,
    )
elif mode == "replay_play_m3u":
    try:
        pnum = int(args.get("profile_num", [0])[0])
    except (ValueError, TypeError):
        pnum = None
    replay_play_m3u(
        args.get("channel_name", [""])[0],
        args.get("channel_url", [""])[0],
        args.get("catchup", [""])[0],
        args.get("catchup_source", [""])[0],
        args.get("logo", [""])[0],
        args.get("start_ts", [""])[0],
        args.get("end_ts", [""])[0],
        pnum,
    )
elif mode == "search_global":
    try:
        pnum_raw = args.get("profile_num", [None])[0]
        pnum = int(pnum_raw) if pnum_raw is not None else None
    except (ValueError, TypeError):
        pnum = None
    stype = args.get("stype", [None])[0]
    search_global(args.get("query", [None])[0], profile_num=pnum, stype=stype)
elif mode == "search_all_combined":
    search_all_profiles_combined(args.get("query", [""])[0])
elif mode == "search_m3u":
    stype = args.get("stype", [None])[0]
    search_m3u(args.get("query", [None])[0], stype=stype)
elif mode == "refresh_data":
    refresh_data()
elif mode == "refresh_profile_menu":
    refresh_profile_menu()
elif mode == "profile_menu":
    profile_menu(int(args.get("pnum", [1])[0]))
elif mode == "open_pvr":
    open_pvr()
elif mode == "pvr_favorites_manager":
    pvr_favorites_manager(args.get("group", [None])[0])
elif mode == "pvr_favs_manage_group":
    pvr_favs_manage_group(args.get("group", [_t(30703) or "Favorites"])[0])
elif mode == "pvr_favs_group_current":
    pvr_favs_group_current(args.get("group", [_t(30703) or "Favorites"])[0])
elif mode == "pvr_favs_group_search":
    pvr_favs_group_search(args.get("group", [_t(30703) or "Favorites"])[0])
elif mode == "pvr_favs_manage_cat":
    pvr_favs_manage_cat(
        args.get("cat_id", [""])[0],
        args.get("cat_name", [""])[0],
        args.get("group", [_t(30703) or "Favorites"])[0],
    )
elif mode == "pvr_favs_new_group":
    dialog = xbmcgui.Dialog()
    name = dialog.input(_t(30147))
    if name:
        groups = _pvr_favs_load_all()
        if name in groups:
            dialog.notification("Xstream Pro", _t(30125))
        else:
            groups[name] = []
            _pvr_favs_save_all(groups)
            dialog.notification("Xstream Pro", _t(30126, name))
            xbmc.sleep(_tvos_sleep(1500))
            dialog.ok("Xstream Pro", _t(30727))
            xbmc.executebuiltin("Container.Refresh")
elif mode == "pvr_favs_rename_group":
    old_name = args.get("group", [""])[0]
    dialog = xbmcgui.Dialog()
    new_name = dialog.input(_t(30148), defaultt=old_name)
    if new_name and new_name != old_name:
        groups = _pvr_favs_load_all()
        if new_name in groups:
            dialog.notification("Xstream Pro", _t(30125))
        elif old_name in groups:
            items = groups.pop(old_name)
            groups[new_name] = items
            _pvr_favs_save_all(groups)
            _sync_pvr_favorites_safe()
            dialog.notification("Xstream Pro", _t(30178, new_name))
            xbmc.executebuiltin("Container.Refresh")
            options = [
                _t(30720),
                _t(30165),
                _t(30403),
            ]  # Reload PVR, Restart Kodi, Cancel
            choice = dialog.select(_t(30732, new_name), options)
            if choice == 0:
                try:
                    _sync_pvr_force()
                except Exception as e:
                    _log(f"PVR reload error: {e}")

                    _log(f"Traceback: {traceback.format_exc()}")
                dialog.notification("Xstream Pro", _t(30281))
            elif choice == 1:
                try:
                    xbmc.executeJSONRPC(
                        '{"jsonrpc":"2.0","method":"PVR.Manager.Stop","id":1}'
                    )
                    xbmc.sleep(_tvos_sleep(1000))
                except Exception as e:
                    _log(f"PVR stop before restart warning: {e}")
                _restart_or_prompt()
elif mode == "pvr_favs_delete_group":
    group_name = args.get("group", [""])[0]
    dialog = xbmcgui.Dialog()
    if dialog.yesno(_t(30038), _t(30252, group_name)):
        groups = _pvr_favs_load_all()
        if group_name in groups:
            del groups[group_name]
            _pvr_favs_save_all(groups)
            _sync_pvr_favorites_safe()
            dialog.notification("Xstream Pro", _t(30179, group_name))
            xbmc.executebuiltin("Container.Refresh")
elif mode == "pvr_fav_add":
    sid = args.get("stream_id", [""])[0]
    name = args.get("name", [""])[0]
    icon = args.get("icon", [""])[0]
    group = args.get("group", [_t(30703) or "Favorites"])[0]
    channel = {"stream_id": sid, "name": name, "stream_icon": icon}
    try:
        creds = _get_pvr_credentials()
        streams = _get_cached_xtream_streams(
            creds.get("xtream_url", ""),
            creds.get("xtream_username", ""),
            creds.get("xtream_password", ""),
            "live",
        )
        for s in streams or []:
            if str(s.get("stream_id", "")) == sid:
                channel.update(s)
                break
    except Exception as e:
        _log(f"PVR favorite metadata lookup warning: {e}")
    if _pvr_favs_add(channel, group):
        _sync_pvr_favorites_safe()
        xbmcgui.Dialog().notification("Xstream Pro", _t(30084).format(group))
        xbmc.sleep(_tvos_sleep(1500))
        xbmcgui.Dialog().ok("Xstream Pro", _t(30727))
        xbmc.executebuiltin("Container.Refresh")
elif mode == "pvr_fav_remove":
    sid = args.get("stream_id", [""])[0]
    group = args.get("group", [_t(30703) or "Favorites"])[0]
    if _pvr_favs_remove(sid, group):
        _sync_pvr_favorites_safe()
        xbmcgui.Dialog().notification("Xstream Pro", _t(30085).format(group))
        xbmc.executebuiltin("Container.Refresh")
elif mode == "open_pvr_guide":
    open_pvr_guide()
elif mode == "sports_menu":
    if _setup_sports_routes():
        _sports_routes.sports_menu()
elif mode == "sports_open_pvr":
    if _setup_sports_routes():
        _sports_routes.open_pvr(args)
elif mode == "sports_open_pvr_guide":
    if _setup_sports_routes():
        _sports_routes.open_pvr_guide(args)
elif mode == "sync_sports_pvr":
    sync_sports_pvr()
elif mode == "switch_sports_pvr_profile":
    switch_sports_pvr_profile()
elif mode == "sports_pvr_favs_manage_group":
    sports_pvr_favs_manage_group(args.get("group", ["Favorites"])[0])
elif mode == "sports_pvr_favs_group_current":
    sports_pvr_favs_group_current(args.get("group", ["Favorites"])[0])
elif mode == "sports_pvr_favs_group_search":
    sports_pvr_favs_group_search(args.get("group", ["Favorites"])[0])
elif mode == "sports_pvr_favs_manage_country":
    sports_pvr_favs_manage_country(
        args.get("country", ["Other Countries"])[0],
        args.get("group", ["Favorites"])[0],
    )
elif mode == "sports_pvr_favs_new_group":
    sports_pvr_favs_new_group()
elif mode == "sports_pvr_favs_rename_group":
    sports_pvr_favs_rename_group()
elif mode == "sports_pvr_favs_delete_group":
    sports_pvr_favs_delete_group()
elif mode == "sports_pvr_favorites_manager":
    sports_pvr_favorites_manager(args.get("group", [None])[0])
elif mode == "sports_pvr_favs_manage":
    sports_pvr_favs_manage(args.get("group", ["Favorites"])[0])
elif mode == "sports_pvr_fav_remove":
    sports_pvr_fav_remove(
        args.get("stream_id", [""])[0],
        args.get("group", ["Favorites"])[0],
    )
elif mode == "sports_events_ppv":
    if _setup_sports_routes():
        _sports_routes.sports_events_ppv_menu()
elif mode == "sports_news":
    if _setup_sports_routes():
        _sports_routes.sports_news_menu()
elif mode == "sports_profiles":
    if _setup_sports_routes():
        _sports_routes.sports_profiles_menu()
elif mode == "sports_profile":
    if _setup_sports_routes():
        _sports_routes.sports_profile_menu(args)
elif mode == "sports_profiles_by_profile":
    if _setup_sports_routes():
        _sports_routes.sports_profiles_by_profile()
elif mode == "sports_provider_groups":
    if _setup_sports_routes():
        _sports_routes.sports_provider_groups(args)
elif mode == "sports_countries":
    if _setup_sports_routes():
        _sports_routes.sports_countries(args)
elif mode == "sports_channels":
    if _setup_sports_routes():
        _sports_routes.sports_channels(args)
elif mode == "sports_replay":
    if _setup_sports_routes():
        _sports_routes.sports_replay_menu()
elif mode == "sports_replay_profile":
    if _setup_sports_routes():
        _sports_routes.sports_replay_profile(args)
elif mode == "sports_replay_provider_groups":
    if _setup_sports_routes():
        _sports_routes.sports_replay_provider_groups(args)
elif mode == "sports_replay_countries":
    if _setup_sports_routes():
        _sports_routes.sports_replay_countries(args)
elif mode == "sports_replay_channels":
    if _setup_sports_routes():
        _sports_routes.sports_replay_channels(args)
elif mode == "sports_search_menu":
    if _setup_sports_routes():
        _sports_routes.sports_search_menu()
elif mode == "sports_search":
    if _setup_sports_routes():
        _sports_routes.sports_search(args)
elif mode == "sports_favorites":
    if _setup_sports_routes():
        _sports_routes.sports_favorites()
elif mode == "sports_loaded_profiles":
    if _setup_sports_routes():
        _sports_routes.sports_loaded_profiles()
elif mode == "sports_pvr_status":
    if _setup_sports_routes():
        _sports_routes.sports_pvr_status()
elif mode == "sports_review_detected":
    if _setup_sports_routes():
        _sports_routes.sports_review_detected(args)
elif mode == "sports_clear_cache":
    if _setup_sports_routes():
        _sports_routes.sports_clear_cache()
elif mode == "sports_tools":
    if _setup_sports_routes():
        _sports_routes.sports_tools()
elif mode == "sports_manage_detected_channels":
    if _setup_sports_routes():
        _sports_routes.sports_manage_detected_channels()
elif mode == "sports_manage_detected_review_countries":
    if _setup_sports_routes():
        _sports_routes.sports_manage_detected_review_countries()
elif mode == "sports_manage_detected_add_countries":
    if _setup_sports_routes():
        _sports_routes.sports_manage_detected_add_countries()
elif mode == "sports_manage_detected_remove_countries":
    if _setup_sports_routes():
        _sports_routes.sports_manage_detected_remove_countries()
elif mode == "sports_manage_detected_review_channels":
    if _setup_sports_routes():
        _sports_routes.sports_manage_detected_review_channels(args)
elif mode == "sports_manage_detected_add_channels":
    if _setup_sports_routes():
        _sports_routes.sports_manage_detected_add_channels(args)
elif mode == "sports_manage_detected_remove_channels":
    if _setup_sports_routes():
        _sports_routes.sports_manage_detected_remove_channels(args)
elif mode == "sports_manage_detected_review_action":
    if _setup_sports_routes():
        _sports_routes.sports_manage_detected_review_action(args)
elif mode == "sports_manage_detected_add_action":
    if _setup_sports_routes():
        _sports_routes.sports_manage_detected_add_action(args)
elif mode == "sports_manage_detected_remove_action":
    if _setup_sports_routes():
        _sports_routes.sports_manage_detected_remove_action(args)
elif mode == "sports_results_menu":
    if _setup_sports_results_routes():
        _sports_results_routes.results_menu()
elif mode == "sports_results_live":
    if _setup_sports_results_routes():
        _sports_results_routes.live_menu()
elif mode == "sports_results_today":
    if _setup_sports_results_routes():
        _sports_results_routes.today_menu(args)
elif mode == "sports_results_yesterday":
    if _setup_sports_results_routes():
        _sports_results_routes.yesterday_menu(args)
elif mode == "sports_results_upcoming":
    if _setup_sports_results_routes():
        _sports_results_routes.upcoming_menu(args)
elif mode == "sports_results_leagues":
    if _setup_sports_results_routes():
        _sports_results_routes.leagues_menu(args)
elif mode == "sports_results_teams":
    if _setup_sports_results_routes():
        _sports_results_routes.teams_menu(args)
elif mode == "sports_results_players":
    if _setup_sports_results_routes():
        _sports_results_routes.players_menu(args)
elif mode == "sports_results_events":
    if _setup_sports_results_routes():
        _sports_results_routes.events_menu(args)
elif mode == "sports_results_venues":
    if _setup_sports_results_routes():
        _sports_results_routes.venues_menu(args)
elif mode == "sports_results_tv":
    if _setup_sports_results_routes():
        _sports_results_routes.tv_listings_menu(args)
elif mode == "sports_results_highlights":
    if _setup_sports_results_routes():
        _sports_results_routes.highlights_menu(args)
elif mode == "sports_results_catalog":
    if _setup_sports_results_routes():
        _sports_results_routes.api_catalog_menu(args)
elif mode == "sports_results_search":
    if _setup_sports_results_routes():
        _sports_results_routes.search_menu(args)
elif mode == "sports_results_tools":
    if _setup_sports_results_routes():
        _sports_results_routes.tools_menu()
elif mode == "sports_results_endpoint":
    if _setup_sports_results_routes():
        _sports_results_routes.generic_endpoint(args)
elif mode == "sports_results_details":
    if _setup_sports_results_routes():
        _sports_results_routes.entity_details(args)
elif mode == "sports_results_clear_cache":
    if _setup_sports_results_routes():
        _sports_results_routes.clear_cache()
elif mode == "sports_results_api_status":
    if _setup_sports_results_routes():
        _sports_results_routes.api_status()
elif mode == "user_playlists_menu":
    user_playlists_menu()
elif mode == "profiles_search_menu":
    profiles_search_menu()
elif mode == "main_tools_menu":
    main_tools_menu()
elif mode == "trakt_restore_credentials":
    trakt_restore_credentials_action()
elif mode == "trakt_clear_cache":
    trakt_clear_cache_action()
elif mode == "tools_menu":
    tools_menu()
elif mode == "manage_active_profiles":
    manage_active_profiles()
elif mode == "manage_main_menu_items":
    manage_main_menu_items()
elif mode == "play_stream":
    try:
        pnum = int(args.get("profile_num", [pm.active])[0])
    except (ValueError, TypeError):
        pnum = pm.active
    _raw_kodi_props = args.get("kodi_props", [""])[0]
    _kodi_props = None
    if _raw_kodi_props:
        try:
            import json as _json
            _kodi_props = _json.loads(_raw_kodi_props)
        except Exception:
            pass  # Malformed JSON in external URL; ignore extra props
    play_stream(
        args.get("url", [""])[0],
        args.get("name", [""])[0],
        args.get("title", [""])[0],
        args.get("plot", [""])[0],
        args.get("icon", [""])[0],
        args.get("stype", ["live"])[0],
        args.get("series_id", [""])[0],
        args.get("season_num", [""])[0],
        args.get("ep_id", [""])[0],
        profile_num=pnum,
        kodi_props=_kodi_props,
        stream_ua=args.get("stream_ua", [""])[0],
        resume_pos=int(float(args.get("resume_pos", [0])[0] or 0)),
        resume_total=int(float(args.get("resume_total", [0])[0] or 0)),
        tmdb_id=args.get("tmdb_id", [""])[0],
        stream_ref=args.get("stream_ref", [""])[0],
    )
elif mode == "pvr_osd_left":
    _pvr_osd_key_action("left")
elif mode == "pvr_osd_right":
    _pvr_osd_key_action("right")
elif mode == "settings":
    settings()
elif mode == "switch_profile":
    switch_profile()
elif mode == "view_changelog":
    view_changelog()
elif mode == "test_connection":
    test_connection()
elif mode == "account_info":
    account_info()
elif mode == "check_update":
    from updater import check_and_install_update

    check_and_install_update()
elif mode == "revert_version":
    from updater import revert_version_menu

    revert_version_menu()
elif mode == "toggle_setting":
    key = args.get("key", [""])[0]
    if key:
        current = addon.getSetting(key).lower() == "true"
        addon.setSetting(key, "false" if current else "true")
        xbmcgui.Dialog().notification(
            "Xstream Pro",
            _t(30306, key.replace("_", " ").title(), _t(30371 if current else 30370)),
        )
        xbmc.executebuiltin("Container.Refresh")
elif mode == "manage_content_dialog":
    manage_content_dialog(args.get("stype", ["live"])[0], args.get("pnum", [None])[0])
elif mode == "manage_load_content":
    # Settings button - manage load TV/Movies/Series toggles
    try:
        pnum = int(args.get("pnum", [1])[0])
    except (ValueError, TypeError):
        pnum = 1
    _manage_load_content_toggles(pnum)
elif mode == "manage_content_select":
    # Settings button - select which content type to manage
    try:
        pnum = int(args.get("pnum", [1])[0])
    except (ValueError, TypeError):
        pnum = 1
    _manage_content_select(pnum)
elif mode == "hidden_items_all":
    stype = args.get("stype", ["live"])[0]
    pnum = args.get("pnum", [None])[0] or pm.active
    hidden_items = _get_hidden_items(stype, pnum)
    if not hidden_items:
        xbmcgui.Dialog().notification("Xstream Pro", _t(30075))
    else:
        creds = _get_credentials_for_profile(pnum)
        url = creds.get("xtream_url", "")
        m3u_url = creds.get("m3u_url", "")

        if not url and m3u_url:
            # M3U without credentials
            channels = _get_cached_m3u_channels(m3u_url)
            hidden_channels = [
                ch
                for ch in channels
                if str(ch.get("tvg_id") or ch.get("url") or ch.get("name", ""))
                in hidden_items
            ]
            if not hidden_channels:
                xbmcgui.Dialog().notification("Xstream Pro", _t(30135))
            else:
                h_names = [ch.get("name", "Unknown") for ch in hidden_channels]
                h_ids = [
                    str(ch.get("tvg_id") or ch.get("url") or ch.get("name", ""))
                    for ch in hidden_channels
                ]
                preselect = list(range(len(hidden_channels)))
                result = xbmcgui.Dialog().multiselect(
                    _t(30339), h_names, preselect=preselect
                )
                if result is not None:
                    new_hidden = {h_ids[i] for i in result} if result else set()
                    orphan_ids = hidden_items - set(h_ids)
                    _set_hidden_items(stype, new_hidden | orphan_ids, pnum)
                    unhidden = len(hidden_channels) - (len(result) if result else 0)
                    if unhidden:
                        xbmcgui.Dialog().notification(
                            "Xstream Pro", _t(30314, unhidden)
                        )
                    xbmc.executebuiltin("Container.Refresh")
        else:
            # Xtream/M3U with credentials
            streams = _get_cached_xtream_streams(
                url,
                creds.get("xtream_username", ""),
                creds.get("xtream_password", ""),
                stype,
            )
            hidden_streams = [
                s
                for s in (streams or [])
                if str(s.get("stream_id", "")) in hidden_items
            ]
            if not hidden_streams:
                xbmcgui.Dialog().notification("Xstream Pro", _t(30135))
            else:
                h_names = [s.get("name", "Unknown") for s in hidden_streams]
                h_ids = [str(s.get("stream_id", "")) for s in hidden_streams]
                preselect = list(range(len(hidden_streams)))
                result = xbmcgui.Dialog().multiselect(
                    _t(30339), h_names, preselect=preselect
                )
                if result is not None:
                    new_hidden = {h_ids[i] for i in result} if result else set()
                    orphan_ids = hidden_items - set(h_ids)
                    _set_hidden_items(stype, new_hidden | orphan_ids, pnum)
                    unhidden = len(hidden_streams) - (len(result) if result else 0)
                    if unhidden:
                        xbmcgui.Dialog().notification(
                            "Xstream Pro", _t(30314, unhidden)
                        )
                    xbmc.executebuiltin("Container.Refresh")
elif mode == "manage_hidden_subcats":
    manage_hidden_subcats(args.get("stype", ["live"])[0], args.get("pnum", [None])[0])
elif mode == "hide_m3u_item_action":
    hide_m3u_item_action(
        args.get("stype", ["live"])[0],
        args.get("item_id", [""])[0],
        args.get("item_name", [""])[0],
        args.get("pnum", [None])[0],
    )
elif mode == "hide_subcat_action":
    hide_subcat_action(
        args.get("stype", ["live"])[0],
        args.get("cat_id", [""])[0],
        args.get("cat_name", [""])[0],
        args.get("pnum", [None])[0],
    )
elif mode == "hide_all_subcats":
    hide_all_subcats(
        args.get("stype", ["live"])[0],
        args.get("action", ["hide"])[0],
        args.get("pnum", [None])[0],
    )
elif mode == "clear_all_caches":
    try:
        count = _cache_clear_all()
        xbmcgui.Dialog().notification(
            "Xstream Pro",
            _t(30832) + " ({0} files)".format(count),
            xbmcgui.NOTIFICATION_INFO,
            3000,
        )
    except Exception as e:
        _log("Clear cache error: {0}".format(e))
        xbmcgui.Dialog().notification(
            "Xstream Pro",
            "Clear cache failed",
            xbmcgui.NOTIFICATION_WARNING,
            3000,
        )
elif mode == "tmdb_rebuild_provider_cache":
    try:
        _rebuild_provider_availability_cache(show_progress=True)
        xbmcgui.Dialog().notification(
            "Xstream Pro",
            "Provider cache rebuilt",
            xbmcgui.NOTIFICATION_INFO,
            3000,
        )
    except Exception as e:
        _log("Provider cache rebuild error: {0}".format(e))
        xbmcgui.Dialog().notification(
            "Xstream Pro",
            "Provider cache rebuild failed",
            xbmcgui.NOTIFICATION_WARNING,
            3000,
        )
elif mode == "clear_cache":
    try:
        import cache
        cache.clear_namespace("tmdb_http")
        cache.clear_namespace("tmdb_titles")
        cache.clear_namespace("tmdb_collections")
        cache.clear_namespace("tmdb_company_logo")
        cache.clear_namespace("provider_exact_pages")
        cache.clear_namespace("provider_filter")
        cache.clear_namespace("trakt_auth")
        cache.clear_namespace("trakt_public")
        try:
            profile_path = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
            pa_path = os.path.join(profile_path, "tmdb_provider_availability.json")
            if os.path.exists(pa_path):
                os.remove(pa_path)
        except Exception:
            pass
        c("_log")("Cleared TMDb/Trakt cache")
    except Exception as e:
        _log("Clear direct cache error: {0}".format(e))
    try:
        xbmcgui.Dialog().notification(
            "Xstream Pro",
            _t(30832),
            xbmcgui.NOTIFICATION_INFO,
            3000,
        )
    except Exception:
        pass
elif mode == "clear_background":
    try:
        if _clear_background():
            xbmcgui.Dialog().notification(
                "Xstream Pro",
                "Background cleared",
                xbmcgui.NOTIFICATION_INFO,
                3000,
            )
        else:
            xbmcgui.Dialog().notification(
                "Xstream Pro",
                "Failed to clear background",
                xbmcgui.NOTIFICATION_WARNING,
                3000,
            )
    except Exception as e:
        _log("Clear background error: {0}".format(e))
        xbmcgui.Dialog().notification(
            "Xstream Pro",
            "Clear background failed",
            xbmcgui.NOTIFICATION_WARNING,
            3000,
        )
elif mode == "restore_background":
    try:
        if _restore_background():
            xbmcgui.Dialog().notification(
                "Xstream Pro",
                "Addon background restored",
                xbmcgui.NOTIFICATION_INFO,
                3000,
            )
        else:
            xbmcgui.Dialog().notification(
                "Xstream Pro",
                "Failed to restore background",
                xbmcgui.NOTIFICATION_WARNING,
                3000,
            )
    except Exception as e:
        _log("Restore background error: {0}".format(e))
        xbmcgui.Dialog().notification(
            "Xstream Pro",
            "Restore background failed",
            xbmcgui.NOTIFICATION_WARNING,
            3000,
        )
elif mode == "backup_settings":
    try:
        backup_settings()
    except Exception as e:
        _log(f"Backup settings error: {e}")
        xbmcgui.Dialog().notification(
            "Xstream Pro", _t(30090), xbmcgui.NOTIFICATION_ERROR
        )
elif mode == "restore_settings":
    try:
        restore_settings()
    except Exception as e:
        _log(f"Restore settings error: {e}")
        xbmcgui.Dialog().notification(
            "Xstream Pro", _t(30092), xbmcgui.NOTIFICATION_ERROR
        )
elif mode == "view_backup_path":
    try:
        view_backup_path()
    except Exception as e:
        _log(f"View backup path error: {e}")
elif mode == "change_backup_folder":
    try:
        change_backup_folder()
    except Exception as e:
        _log(f"Change backup folder error: {e}")
        xbmcgui.Dialog().notification("Xstream Pro", _t(30790))
elif mode == "clear_cache_menu":
    try:
        clear_cache_menu()
    except Exception as e:
        _log(f"Clear cache menu error: {e}")
elif mode == "clear_all_caches":
    try:
        clear_all_caches()
    except Exception as e:
        _log(f"Clear all caches error: {e}")
elif mode == "clear_epg_cache":
    try:
        clear_epg_cache()
    except Exception as e:
        _log(f"Clear EPG cache error: {e}")
elif mode == "reset_pvr_epg_db":
    try:
        reset_pvr_epg_db_action()
    except Exception as e:
        _log(f"Reset PVR EPG DB error: {e}")

        _log(f"Traceback: {traceback.format_exc()}")
elif mode == "force_reload_epg":
    try:
        selected = _select_profile_or_all(allow_all=True)
        if selected is None:
            pass
        elif selected == "all":
            pd = xbmcgui.DialogProgress()
            pd.create("Xstream Pro", _t(30289))
            try:
                enabled = [i for i in range(1, 11) if addon.getSetting(f"profile_{i}_enabled") == "true"]
                total = len(enabled) if enabled else 1
                for idx, i in enumerate(enabled):
                    if pd.iscanceled():
                        break
                    pct = int((idx / total) * 90)
                    pd.update(pct, _t(31442, i))
                    EPG(addon, profile_num=i).fetch()
                    if str(i) == str(_get_pvr_profile_num()):
                        pd.update(pct + 5, _t(31443))
                        _export_pvr_epg(force_fetch=False)
                pd.update(100, _t(30563))
            finally:
                pd.close()
            _trigger_pvr_epg_reload()
            xbmcgui.Dialog().notification("Xstream Pro", _t(30280))
        else:
            pd = xbmcgui.DialogProgress()
            pd.create("Xstream Pro", _t(30289))
            try:
                pd.update(30, _t(31444))
                EPG(addon, profile_num=int(selected)).fetch()
                if str(selected) == str(_get_pvr_profile_num()):
                    pd.update(60, _t(31445))
                    _export_pvr_epg(force_fetch=False)
                    _trigger_pvr_epg_reload()
                pd.update(100, _t(30563))
            finally:
                pd.close()
            xbmcgui.Dialog().notification("Xstream Pro", _t(30280))
    except Exception as e:
        _log(f"Force reload EPG error: {e}")

        _log(f"Traceback: {traceback.format_exc()}")
elif mode == "force_reload_pvr":
    try:
        if xbmcgui.Dialog().yesno("Xstream Pro", _t(30820)):
            ok = _sync_pvr_force()
            xbmcgui.Dialog().notification(
                "Xstream Pro",
                _t(30281) if ok else _t(30082)
            )
    except Exception as e:
        _log(f"Force reload PVR error: {e}")

        _log(f"Traceback: {traceback.format_exc()}")
    xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
elif mode == "reset_reload_menu":
    reset_reload_menu()
elif mode == "account_iptv_menu":
    account_iptv_menu()
elif mode == "loaded_profiles_view":
    try:
        loaded_profiles_view()
    except Exception as e:
        _log(f"Loaded profiles view error: {e}")
elif mode == "relaunch_kodi":
    try:
        relaunch_kodi_action()
    except Exception as e:
        _log(f"Relaunch Kodi error: {e}")
elif mode == "install_upnext":
    try:
        install_upnext()
    except Exception as e:
        _log(f"Install Up Next error: {e}")
elif mode == "open_upnext_settings":
    try:
        open_upnext_settings()
    except Exception as e:
        _log(f"Open Up Next settings error: {e}")
elif mode == "xstream_tmdb_movies_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.movies_menu()
elif mode == "xstream_tmdb_tvshows_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.tvshows_menu()
elif mode == "xstream_tmdb_animation_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.animation_menu()
elif mode == "xstream_tmdb_animation_anime_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.animation_anime_menu()
elif mode == "xstream_tmdb_animation_movies_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.animation_movies_menu()
elif mode == "xstream_tmdb_animation_tv_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.animation_tv_menu()
elif mode == "xstream_tmdb_animation_kids_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.animation_kids_menu()
elif mode == "xstream_tmdb_animation_studios_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.animation_studios_menu(args)
elif mode == "xstream_tmdb_animation_collections_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.animation_collections_menu()
elif mode == "xstream_tmdb_animation_search_root":
    if _setup_tmdb_routes():
        _tmdb_routes.animation_search_root()
elif mode == "xstream_tmdb_animation_random_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.animation_random_menu()
elif mode == "xstream_tmdb_animation_list":
    if _setup_tmdb_routes():
        _tmdb_routes.animation_list_route(args)
elif mode == "xstream_tmdb_animation_company":
    if _setup_tmdb_routes():
        _tmdb_routes.animation_company_route(args)
elif mode == "xstream_tmdb_animation_network":
    if _setup_tmdb_routes():
        _tmdb_routes.animation_network_route(args)
elif mode == "xstream_tmdb_animation_movie_collections":
    if _setup_tmdb_routes():
        _tmdb_routes.animation_movie_collections_menu(args)
elif mode == "xstream_tmdb_animation_collection_search":
    if _setup_tmdb_routes():
        _tmdb_routes.animation_collection_search_route(args)
elif mode == "xstream_tmdb_animation_collection":
    if _setup_tmdb_routes():
        _tmdb_routes.animation_collection_route(args)
elif mode == "xstream_tmdb_animation_search":
    if _setup_tmdb_routes():
        _tmdb_routes.animation_search_route(args)
elif mode == "xstream_tmdb_animation_random":
    if _setup_tmdb_routes():
        _tmdb_routes.animation_random_route(args)
elif mode == "xstream_tmdb_people_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.people_menu()
elif mode == "xstream_tmdb_lists_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.lists_menu()
elif mode == "xstream_tmdb_trakt_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.trakt_menu()
elif mode == "xstream_trakt_authorize":
    if _setup_tmdb_routes():
        _tmdb_routes.trakt_authorize_route(args)
elif mode == "xstream_trakt_revoke":
    if _setup_tmdb_routes():
        _tmdb_routes.trakt_revoke_route(args)
elif mode == "xstream_trakt_list":
    if _setup_tmdb_routes():
        _tmdb_routes.trakt_list_route(args)
elif mode == "xstream_trakt_playback":
    if _setup_tmdb_routes():
        _tmdb_routes.trakt_playback_route(args)
elif mode == "xstream_trakt_lists":
    if _setup_tmdb_routes():
        _tmdb_routes.trakt_lists_route(args)
elif mode == "xstream_trakt_list_items":
    if _setup_tmdb_routes():
        _tmdb_routes.trakt_list_items_route(args)
elif mode == "xstream_trakt_calendar":
    if _setup_tmdb_routes():
        _tmdb_routes.trakt_calendar_route(args)
elif mode == "xstream_trakt_calendar_endpoint":
    if _setup_tmdb_routes():
        _tmdb_routes.trakt_calendar_endpoint_route(args)
elif mode == "xstream_trakt_next_episodes":
    if _setup_tmdb_routes():
        _tmdb_routes.trakt_next_episodes_route(args)
elif mode == "xstream_trakt_public":
    if _setup_tmdb_routes():
        _tmdb_routes.trakt_public_route(args)
elif mode == "xstream_trakt_item_action":
    if _setup_tmdb_routes():
        _tmdb_routes.trakt_item_action_route(args)
elif mode == "xstream_tmdb_collection_search":
    if _setup_tmdb_routes():
        _tmdb_routes.collection_search_route(args)
elif mode == "xstream_tmdb_collections_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.collections_menu()
elif mode == "xstream_tmdb_collection_list":
    if _setup_tmdb_routes():
        _tmdb_routes.collection_list_route(args)
elif mode == "xstream_tmdb_collection_browse":
    if _setup_tmdb_routes():
        _tmdb_routes.collection_browse_route(args)
elif mode == "xstream_tmdb_saved_collections":
    if _setup_tmdb_routes():
        _tmdb_routes.saved_collections_menu()
elif mode == "xstream_tmdb_save_collection":
    if _setup_tmdb_routes():
        _tmdb_routes.save_collection_action(args)
elif mode == "xstream_tmdb_unsave_collection":
    if _setup_tmdb_routes():
        _tmdb_routes.unsave_collection_action(args)
elif mode == "xstream_tmdb_tv_collections_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.tv_collections_menu()
elif mode == "xstream_tmdb_tv_universes_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.tv_universes_menu(args)
elif mode == "xstream_tmdb_tv_collection":
    if _setup_tmdb_routes():
        _tmdb_routes.tv_collection_route(args)
elif mode == "xstream_tmdb_company_search":
    if _setup_tmdb_routes():
        _tmdb_routes.company_search_route(args)
elif mode == "xstream_tmdb_keyword_search":
    if _setup_tmdb_routes():
        _tmdb_routes.keyword_search_route(args)
elif mode == "xstream_tmdb_randomised_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.randomised_menu()
elif mode == "xstream_tmdb_random_genre":
    if _setup_tmdb_routes():
        _tmdb_routes.random_genre_route(args)
elif mode == "xstream_tmdb_random_keyword":
    if _setup_tmdb_routes():
        _tmdb_routes.random_keyword_route(args)
elif mode == "xstream_trakt_recommendations":
    if _setup_tmdb_routes():
        _tmdb_routes.trakt_recommendations_route(args)
elif mode == "xstream_trakt_random_public":
    if _setup_tmdb_routes():
        _tmdb_routes.trakt_random_public_route(args)
elif mode == "xstream_trakt_random_list":
    if _setup_tmdb_routes():
        _tmdb_routes.trakt_random_list_route(args)
elif mode == "xstream_tmdb_discover_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.discover_menu()
elif mode == "xstream_tmdb_discover_builder":
    if _setup_tmdb_routes():
        _tmdb_routes.discover_builder_menu(args)
elif mode == "xstream_tmdb_discover_builder_set":
    if _setup_tmdb_routes():
        _tmdb_routes.discover_builder_set_route(args)
elif mode == "xstream_tmdb_discover_builder_add":
    if _setup_tmdb_routes():
        _tmdb_routes.discover_builder_add_route(args)
elif mode == "xstream_tmdb_discover_builder_quick":
    if _setup_tmdb_routes():
        _tmdb_routes.discover_builder_quick_route(args)
elif mode == "xstream_tmdb_discover_builder_run":
    if _setup_tmdb_routes():
        _tmdb_routes.discover_builder_run_route(args)
elif mode == "xstream_tmdb_discover_builder_show":
    if _setup_tmdb_routes():
        _tmdb_routes.discover_builder_show_route(args)
elif mode == "xstream_tmdb_discover_builder_save":
    if _setup_tmdb_routes():
        _tmdb_routes.discover_builder_save_route(args)
elif mode == "xstream_tmdb_discover_history":
    if _setup_tmdb_routes():
        _tmdb_routes.discover_history_menu()
elif mode == "xstream_tmdb_discover_history_load":
    if _setup_tmdb_routes():
        _tmdb_routes.discover_history_load_route(args)
elif mode == "xstream_tmdb_discover_history_delete":
    if _setup_tmdb_routes():
        _tmdb_routes.discover_history_delete_route(args)
elif mode == "xstream_tmdb_search_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.search_menu()
elif mode == "xstream_tmdb_list":
    if _setup_tmdb_routes():
        _tmdb_routes.list_route(args)
elif mode == "xstream_tmdb_search":
    if _setup_tmdb_routes():
        _tmdb_routes.search_route(args)
elif mode == "xstream_tmdb_discover":
    if _setup_tmdb_routes():
        _tmdb_routes.discover_route(args)
elif mode == "xstream_tmdb_genres":
    if _setup_tmdb_routes():
        _tmdb_routes.genres_route(args)
elif mode == "xstream_tmdb_watch_providers":
    if _setup_tmdb_routes():
        _tmdb_routes.watch_providers_route(args)
elif mode == "xstream_tmdb_years_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.years_menu(args.get("tmdb_type", ["movie"])[0])
elif mode == "xstream_tmdb_decades_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.decades_menu(args.get("tmdb_type", ["movie"])[0])
elif mode == "xstream_tmdb_seasons":
    if _setup_tmdb_routes():
        _tmdb_routes.seasons_route(args)
elif mode == "xstream_tmdb_episodes":
    if _setup_tmdb_routes():
        _tmdb_routes.episodes_route(args)
elif mode == "xstream_tmdb_similar":
    if _setup_tmdb_routes():
        _tmdb_routes.related_route(args)
elif mode == "xstream_tmdb_recommendations":
    if _setup_tmdb_routes():
        _tmdb_routes.related_route(args)
elif mode == "xstream_tmdb_collection":
    if _setup_tmdb_routes():
        _tmdb_routes.collection_route(args)
elif mode == "xstream_tmdb_collection_from_movie":
    if _setup_tmdb_routes():
        _tmdb_routes.collection_from_movie_route(args)
elif mode == "xstream_tmdb_trailer":
    if _setup_tmdb_routes():
        _tmdb_routes.trailer_route(args)
elif mode == "xstream_tmdb_people":
    if _setup_tmdb_routes():
        _tmdb_routes.people_route(args)
elif mode == "xstream_tmdb_person_credits":
    if _setup_tmdb_routes():
        _tmdb_routes.person_credits_route(args)
elif mode == "xstream_tmdb_because_watched":
    if _setup_tmdb_routes():
        _tmdb_routes.because_watched_route(args)
elif mode == "tmdb_series_action":
    tmdb_series_action()
elif mode == "tmdb_season_action":
    tmdb_series_action()
elif mode == "tmdb_source_action":
    tmdb_source_action()
elif mode == "source_select":
    source_select()
elif mode == "resolve_source":
    resolve_source()
elif mode == "download":
    try:
        url = args.get("url", [""])[0]
        title = args.get("title", [""])[0] or args.get("name", [""])[0]
        year = args.get("year", [""])[0]
        stype = args.get("stype", ["movie"])[0]
        pnum = args.get("profile_num", [pm.active])[0]
        tmdb_type = _normalize_tmdb_type(args.get("tmdb_type", ["movie"])[0]) or "movie"
        season = args.get("season", [""])[0]
        episode = args.get("episode", [""])[0]
        media_type = "episode" if tmdb_type == "tv" or stype == "series" else "movie"
        poster = args.get("poster", [""])[0]
        plot = args.get("plot", [""])[0]
        dl_id = start_download(
            url=url,
            title=title,
            year=year,
            media_type=media_type,
            season=season,
            episode=episode,
            poster=poster,
            plot=plot,
            profile_num=pnum,
        )
        if dl_id:
            xbmcgui.Dialog().notification("Xstream Pro", _t(30942))
        else:
            xbmcgui.Dialog().notification("Xstream Pro", _t(30949))
    except Exception as e:
        _log(f"Download start error: {e}")
        xbmcgui.Dialog().notification("Xstream Pro", _t(30944))
    finally:
        xbmc.executebuiltin("Container.Refresh")
elif mode == "downloads_manager":
    try:
        pnum = args.get("pnum", [pm.active])[0]
        downloads_menu(addon_handle, pnum)
    except Exception as e:
        _log(f"Downloads manager error: {e}")
        _log(f"Traceback: {traceback.format_exc()}")
        xbmcplugin.endOfDirectory(addon_handle, succeeded=False)
elif mode == "downloads_refresh":
    xbmc.executebuiltin("Container.Refresh")
elif mode == "download_cancel":
    try:
        dl_id = args.get("id", [""])[0]
        pnum = args.get("pnum", [pm.active])[0]
        if dl_id and xbmcgui.Dialog().yesno("Xstream Pro", _t(31140)):
            if cancel_download(dl_id, pnum):
                xbmcgui.Dialog().notification("Xstream Pro", _t(30945))
                xbmc.executebuiltin("Container.Refresh")
    except Exception as e:
        _log(f"Download cancel error: {e}")
elif mode == "download_retry":
    try:
        dl_id = args.get("id", [""])[0]
        pnum = args.get("pnum", [pm.active])[0]
        if dl_id and xbmcgui.Dialog().yesno("Xstream Pro", _t(31143)):
            if retry_download(dl_id, pnum):
                xbmcgui.Dialog().notification("Xstream Pro", _t(30948))
                xbmc.executebuiltin("Container.Refresh")
    except Exception as e:
        _log(f"Download retry error: {e}")
elif mode == "download_remove_from_list":
    try:
        dl_id = args.get("id", [""])[0]
        pnum = args.get("pnum", [pm.active])[0]
        if dl_id and xbmcgui.Dialog().yesno("Xstream Pro", _t(31142)):
            if remove_download_from_list(dl_id, pnum):
                xbmcgui.Dialog().notification("Xstream Pro", _t(30947))
                xbmc.executebuiltin("Container.Refresh")
    except Exception as e:
        _log(f"Download remove from list error: {e}")
elif mode == "download_delete":
    try:
        dl_id = args.get("id", [""])[0]
        pnum = args.get("pnum", [pm.active])[0]
        if dl_id and xbmcgui.Dialog().yesno("Xstream Pro", _t(31141)):
            if delete_download(dl_id, pnum):
                xbmcgui.Dialog().notification("Xstream Pro", _t(30947))
                xbmc.executebuiltin("Container.Refresh")
    except Exception as e:
        _log(f"Download delete error: {e}")
elif mode == "change_download_folder":
    try:
        change_download_folder()
    except Exception as e:
        _log(f"Change download folder error: {e}")
elif mode == "download_pause":
    try:
        dl_id = args.get("id", [""])[0]
        pnum = args.get("pnum", [pm.active])[0]
        if dl_id and xbmcgui.Dialog().yesno("Xstream Pro", _t(31138)):
            if pause_download(dl_id, pnum):
                xbmcgui.Dialog().notification("Xstream Pro", _t(31076))
                xbmc.executebuiltin("Container.Refresh")
    except Exception as e:
        _log(f"Download pause error: {e}")
elif mode == "download_resume":
    try:
        dl_id = args.get("id", [""])[0]
        pnum = args.get("pnum", [pm.active])[0]
        if dl_id and xbmcgui.Dialog().yesno("Xstream Pro", _t(31139)):
            if resume_download(dl_id, pnum):
                xbmcgui.Dialog().notification("Xstream Pro", _t(31077))
                xbmc.executebuiltin("Container.Refresh")
    except Exception as e:
        _log(f"Download resume error: {e}")
elif mode == "downloads_tools":
    try:
        pnum = args.get("pnum", [pm.active])[0]
        downloads = get_downloads_list(pnum)
        if downloads:
            has_active_queued = any(d.get("status") in ("active", "queued") for d in downloads)
            has_paused_failed_cancelled = any(d.get("status") in ("paused", "failed", "cancelled") for d in downloads)
            has_active_queued_paused = any(d.get("status") in ("active", "queued", "paused") for d in downloads)
            has_completed_failed_cancelled = any(d.get("status") in ("completed", "failed", "cancelled") for d in downloads)
            has_missing = count_missing_downloads(pnum) > 0

            labels = []
            actions = []
            if has_active_queued:
                labels.append(_t(31132))
                actions.append(build_url({"mode": "download_pause_all", "pnum": pnum}))
            if has_paused_failed_cancelled:
                labels.append(_t(31087))
                actions.append(build_url({"mode": "download_start_all", "pnum": pnum}))
            if has_active_queued_paused:
                labels.append(_t(31088))
                actions.append(build_url({"mode": "download_cancel_all", "pnum": pnum}))
            if has_completed_failed_cancelled:
                labels.append(_t(31089))
                actions.append(build_url({"mode": "download_delete_all", "pnum": pnum}))
            if has_missing:
                labels.append(_t(31104))
                actions.append(build_url({"mode": "download_clean_missing", "pnum": pnum}))

            if labels:
                idx = xbmcgui.Dialog().select(_t(30010), labels)
                if 0 <= idx < len(actions):
                    xbmc.executebuiltin("RunPlugin({0})".format(actions[idx]))
            else:
                xbmcgui.Dialog().notification("Xstream Pro", _t(31144))
        else:
            xbmcgui.Dialog().notification("Xstream Pro", _t(31144))
    except Exception as e:
        _log(f"Downloads tools error: {e}")
elif mode == "download_pause_all":
    try:
        pnum = args.get("pnum", [pm.active])[0]
        if xbmcgui.Dialog().yesno("Xstream Pro", _t(31136)):
            count = pause_all_downloads(pnum)
            if count:
                xbmcgui.Dialog().notification("Xstream Pro", _t(31137, count))
                xbmc.executebuiltin("Container.Refresh")
            else:
                xbmcgui.Dialog().notification("Xstream Pro", _t(31090))
    except Exception as e:
        _log(f"Download pause all error: {e}")
elif mode == "download_start_all":
    try:
        pnum = args.get("pnum", [pm.active])[0]
        if xbmcgui.Dialog().yesno("Xstream Pro", _t(31095)):
            count = start_all_downloads(pnum)
            if count:
                xbmcgui.Dialog().notification("Xstream Pro", _t(31099, count))
                xbmc.executebuiltin("Container.Refresh")
            else:
                xbmcgui.Dialog().notification("Xstream Pro", _t(31091))
    except Exception as e:
        _log(f"Download start all error: {e}")
elif mode == "download_cancel_all":
    try:
        pnum = args.get("pnum", [pm.active])[0]
        if xbmcgui.Dialog().yesno("Xstream Pro", _t(31096)):
            count = cancel_all_downloads(pnum)
            if count:
                xbmcgui.Dialog().notification("Xstream Pro", _t(31100, count))
                xbmc.executebuiltin("Container.Refresh")
            else:
                xbmcgui.Dialog().notification("Xstream Pro", _t(31092))
    except Exception as e:
        _log(f"Download cancel all error: {e}")
elif mode == "download_delete_all":
    try:
        pnum = args.get("pnum", [pm.active])[0]
        if xbmcgui.Dialog().yesno("Xstream Pro", _t(31097)):
            count = delete_all_downloads(pnum)
            if count:
                xbmcgui.Dialog().notification("Xstream Pro", _t(31101, count))
                xbmc.executebuiltin("Container.Refresh")
            else:
                xbmcgui.Dialog().notification("Xstream Pro", _t(31093))
    except Exception as e:
        _log(f"Download delete all error: {e}")
elif mode == "download_clean_missing":
    try:
        pnum = args.get("pnum", [pm.active])[0]
        missing = count_missing_downloads(pnum)
        if not missing:
            xbmcgui.Dialog().notification("Xstream Pro", _t(31107))
        elif xbmcgui.Dialog().yesno("Xstream Pro", _t(31105, missing)):
            count = clean_missing_downloads(pnum)
            if count:
                xbmcgui.Dialog().notification("Xstream Pro", _t(31106, count))
                xbmc.executebuiltin("Container.Refresh")
            else:
                xbmcgui.Dialog().notification("Xstream Pro", _t(31107))
    except Exception as e:
        _log(f"Download clean missing error: {e}")
elif mode == "favorites_menu":
    favorites_menu(args.get("folder", [None])[0], args.get("stype_filter", [None])[0])
elif mode == "toggle_fav":
    toggle_favorite(
        args.get("id", [""])[0],
        args.get("name", [""])[0],
        args.get("stype", ["live"])[0],
        args.get("icon", [""])[0],
        args.get("url", [""])[0],
        args.get("epg_id", [""])[0],
        args.get("folder", [_t(30009) or "Favorites"])[0],
        args.get("catchup", [""])[0],
        args.get("catchup_source", [""])[0],
        args.get("catchup_days", [""])[0],
    )
elif mode == "toggle_profile_fav":
    pnum = int(args.get("pnum", [1])[0])
    profile_fav = _get_profile_fav(pnum)
    item = {
        "id": args.get("id", [""])[0],
        "name": args.get("name", [""])[0],
        "stype": args.get("stype", ["live"])[0],
        "icon": args.get("icon", [""])[0],
        "url": args.get("url", [""])[0],
        "epg_id": args.get("epg_id", [""])[0],
    }
    if profile_fav.is_favorite(item["id"]):
        profile_fav.remove(item["id"])
        xbmcgui.Dialog().notification("Xstream Pro", _t(30747))
    else:
        profile_fav.add(item)
        xbmcgui.Dialog().notification("Xstream Pro", _t(30746))
    _invalidate_profile_fav(pnum)
    xbmc.executebuiltin("Container.Refresh")
elif mode == "fav_new_folder":
    name = xbmcgui.Dialog().input(_t(30143))
    if name:
        fav.create_folder(name)
        xbmcgui.Dialog().notification("Xstream Pro", _t(30094).format(name))
        xbmc.executebuiltin("Container.Refresh")
elif mode == "fav_rename_folder":
    fname = args.get("folder", [""])[0]
    if fname:
        new_name = xbmcgui.Dialog().input(_t(30144), defaultt=fname)
        if new_name and new_name != fname:
            fav.rename_folder(fname, new_name)
            xbmcgui.Dialog().notification("Xstream Pro", _t(30095).format(new_name))
            xbmc.executebuiltin("Container.Refresh")
elif mode == "fav_delete_folder":
    fname = args.get("folder", [""])[0]
    protected_folders = {"Favorites", _t(30009) or "Favorites"}
    if fname and fname not in protected_folders:
        if xbmcgui.Dialog().yesno(_t(30038), _t(30066).format(fname)):
            fav.delete_folder(fname)
            xbmcgui.Dialog().notification("Xstream Pro", _t(30096).format(fname))
            xbmc.executebuiltin("Container.Refresh")
elif mode == "fav_move":
    item_id = args.get("id", [""])[0]
    from_folder = args.get("from_folder", [_t(30009) or "Favorites"])[0]
    folders = [f for f in fav.get_folders() if f != from_folder]
    if not folders:
        xbmcgui.Dialog().notification("Xstream Pro", _t(30124))
    else:
        idx = xbmcgui.Dialog().select(_t(30145), folders)
        if idx >= 0:
            source_items = fav.get_all(from_folder)
            item_data = next((i for i in source_items if i.get("id") == item_id), None)
            if item_data:
                fav.remove(item_id, from_folder)
                fav.add(item_data, folders[idx])
                xbmcgui.Dialog().notification(
                    "Xstream Pro", _t(30097).format(folders[idx])
                )
                xbmc.executebuiltin("Container.Refresh")
elif mode == "export_favorites":
    fname = args.get("folder", [None])[0]
    path = xbmcgui.Dialog().browseSingle(3, _t(30338), "files")
    if path:
        export_name = f"favorites_{fname}.m3u" if fname else "favorites.m3u"
        full_path = os.path.join(path, export_name)
        count = fav.export_m3u(full_path, fname)
        xbmcgui.Dialog().notification("Xstream Pro", _t(30116).format(count))
elif mode == "fav_remove":
    _fav_item_id = args.get("id", [""])[0]
    _fav_folder = args.get("folder", ["__all__"])[0]
    if _fav_folder == "__all__":
        fav.remove(_fav_item_id)
    else:
        fav.remove(_fav_item_id, _fav_folder)
        xbmcgui.Dialog().notification("Xstream Pro", _t(30085).format(_fav_folder))
    xbmc.executebuiltin("Container.Refresh")
elif mode == "fav_remove_by_type":
    _fav_folder = args.get("folder", [""])[0]
    _fav_stype = args.get("stype", [""])[0]
    if _fav_folder and _fav_stype:
        stype_labels = {
            "live": _t(30002),
            "movie": _t(30004),
            "series": _t(30005),
            "folder": _t(30808),
        }
        label = stype_labels.get(_fav_stype, _fav_stype)
        if xbmcgui.Dialog().yesno(_t(30813).format(label), _t(30815)):
            removed = fav.remove_by_type(_fav_folder, _fav_stype)
            if removed:
                xbmcgui.Dialog().notification("Xstream Pro", _t(30814))
            xbmc.executebuiltin("Container.Refresh")
elif mode == "toggle_tmdb_watched":
    _tw_tmdb_id = args.get("tmdb_id", [""])[0]
    _tw_tmdb_type = args.get("tmdb_type", [""])[0]
    _tw_season = args.get("season", [""])[0]
    _tw_episode = args.get("episode", [""])[0]
    _tw_pnum = int(pm.active) if pm.active and str(pm.active).isdigit() else 1
    if _tw_tmdb_id and _tw_tmdb_type == "movie":
        wm = WatchedMovies(addon, profile_num=_tw_pnum)
        if wm.is_watched(_tw_tmdb_id):
            wm.mark_unwatched(_tw_tmdb_id)
            xbmcgui.Dialog().notification("Xstream Pro", _t(31121))
        else:
            wm.mark_watched(_tw_tmdb_id)
            xbmcgui.Dialog().notification("Xstream Pro", _t(31120))
    elif _tw_tmdb_id and _tw_tmdb_type == "tv" and _tw_season and _tw_episode:
        we = WatchedEpisodes(addon, profile_num=_tw_pnum)
        if we.is_watched(_tw_tmdb_id, _tw_season, _tw_episode):
            we.mark_unwatched(_tw_tmdb_id, _tw_season, _tw_episode)
            xbmcgui.Dialog().notification("Xstream Pro", _t(31121))
        else:
            we.mark_watched(_tw_tmdb_id, _tw_season, _tw_episode)
            xbmcgui.Dialog().notification("Xstream Pro", _t(31120))
    elif _tw_tmdb_id and _tw_tmdb_type == "tv" and _tw_season:
        # Season watched toggle
        wm = WatchedMovies(addon, profile_num=_tw_pnum)
        _s_key = "season:{0}:{1}".format(_tw_tmdb_id, _tw_season)
        if wm.is_watched(_s_key):
            wm.mark_unwatched(_s_key)
            xbmcgui.Dialog().notification("Xstream Pro", _t(31121))
        else:
            wm.mark_watched(_s_key)
            xbmcgui.Dialog().notification("Xstream Pro", _t(31120))
    elif _tw_tmdb_id and _tw_tmdb_type == "tv":
        # TV show watched toggle
        wm = WatchedMovies(addon, profile_num=_tw_pnum)
        _sh_key = "tvshow:{0}".format(_tw_tmdb_id)
        if wm.is_watched(_sh_key):
            wm.mark_unwatched(_sh_key)
            xbmcgui.Dialog().notification("Xstream Pro", _t(31121))
        else:
            wm.mark_watched(_sh_key)
            xbmcgui.Dialog().notification("Xstream Pro", _t(31120))
    xbmc.executebuiltin("Container.Refresh")
elif mode == "tmdb_local_recent":
    tmdb_local_recent(args.get("tmdb_type", ["movie"])[0])
elif mode == "tmdb_local_in_progress":
    tmdb_local_in_progress(args.get("tmdb_type", ["movie"])[0])
elif mode == "tmdb_discover_menu":
    if _setup_tmdb_routes():
        _tmdb_routes.discover_menu()
# One-time startup bootstrap (must run after all helpers are defined)
if not mode:
    _run_one_time_bootstrap()
