#!/usr/bin/env python3
"""Update- und Versionsverwaltung fuer ViewIT.

Dieses Modul kuemmert sich um:
- Update-Kanaele (Main, Nightly, Dev, Custom)
- Versions-Abfrage und -Installation aus Repositories
- Changelog-Abruf
- Repository-Quellen-Verwaltung
- ResolveURL Auto-Installation
"""

from __future__ import annotations

import io
import json
import os
import re
import time
import xml.etree.ElementTree as ET
import zipfile
from urllib.error import URLError
from urllib.request import Request, urlopen

try:  # pragma: no cover - Kodi runtime
    import xbmc  # type: ignore[import-not-found]
    import xbmcaddon  # type: ignore[import-not-found]
    import xbmcgui  # type: ignore[import-not-found]
    import xbmcvfs  # type: ignore[import-not-found]
except ImportError:  # pragma: no cover - allow importing outside Kodi
    xbmc = None
    xbmcaddon = None
    xbmcgui = None
    xbmcvfs = None

from plugin_helpers import show_error, show_notification

# ---------------------------------------------------------------------------
# Konstanten
# ---------------------------------------------------------------------------

UPDATE_CHANNEL_MAIN = 0
UPDATE_CHANNEL_NIGHTLY = 1
UPDATE_CHANNEL_CUSTOM = 2
UPDATE_CHANNEL_DEV = 3
AUTO_UPDATE_INTERVAL_SEC = 6 * 60 * 60
UPDATE_HTTP_TIMEOUT_SEC = 8
UPDATE_ADDON_ID = "plugin.video.viewit"
RESOLVEURL_ADDON_ID = "script.module.resolveurl"
RESOLVEURL_AUTO_INSTALL_INTERVAL_SEC = 6 * 60 * 60


# ---------------------------------------------------------------------------
# Hilfsfunktionen (Settings-Zugriff)
# ---------------------------------------------------------------------------

# Diese Callbacks werden von default.py einmal gesetzt, damit updater.py
# keine zirkulaeren Abhaengigkeiten hat.
_get_setting_string = None
_get_setting_bool = None
_get_setting_int = None
_set_setting_string = None
_get_addon = None
_log_fn = None


def init(
    *,
    get_setting_string,
    get_setting_bool,
    get_setting_int,
    set_setting_string,
    get_addon,
    log_fn,
) -> None:
    """Initialisiert Callbacks fuer Settings-Zugriff."""
    global _get_setting_string, _get_setting_bool, _get_setting_int
    global _set_setting_string, _get_addon, _log_fn
    _get_setting_string = get_setting_string
    _get_setting_bool = get_setting_bool
    _get_setting_int = get_setting_int
    _set_setting_string = set_setting_string
    _get_addon = get_addon
    _log_fn = log_fn


def _log(message: str, level: int = 1) -> None:
    if _log_fn is not None:
        _log_fn(message, level)


# ---------------------------------------------------------------------------
# URL-Normalisierung
# ---------------------------------------------------------------------------


def normalize_update_info_url(raw: str) -> str:
    value = str(raw or "").strip()
    default = "http://127.0.0.1:8080/repo/addons.xml"
    if not value:
        return default
    if value.endswith("/addons.xml"):
        return value
    return value.rstrip("/") + "/addons.xml"


# ---------------------------------------------------------------------------
# Update-Kanaele
# ---------------------------------------------------------------------------


def selected_update_channel() -> int:
    channel = _get_setting_int("update_channel", default=UPDATE_CHANNEL_MAIN)
    if channel not in {UPDATE_CHANNEL_MAIN, UPDATE_CHANNEL_NIGHTLY, UPDATE_CHANNEL_CUSTOM, UPDATE_CHANNEL_DEV}:
        return UPDATE_CHANNEL_MAIN
    return channel


def channel_label(channel: int) -> str:
    if channel == UPDATE_CHANNEL_NIGHTLY:
        return "Nightly"
    if channel == UPDATE_CHANNEL_DEV:
        return "Dev"
    if channel == UPDATE_CHANNEL_CUSTOM:
        return "Custom"
    return "Main"


# ---------------------------------------------------------------------------
# Versionierung
# ---------------------------------------------------------------------------


def version_sort_key(version: str) -> tuple[int, ...]:
    base = str(version or "").split("-", 1)[0]
    parts = []
    for chunk in base.split("."):
        try:
            parts.append(int(chunk))
        except Exception:
            parts.append(0)
    while len(parts) < 4:
        parts.append(0)
    return tuple(parts[:4])


def is_stable_version(version: str) -> bool:
    return bool(re.match(r"^\d+\.\d+\.\d+$", str(version or "").strip()))


def is_nightly_version(version: str) -> bool:
    return bool(re.match(r"^\d+\.\d+\.\d+-nightly$", str(version or "").strip()))


def is_dev_version(version: str) -> bool:
    return bool(re.match(r"^\d+\.\d+\.\d+-dev$", str(version or "").strip()))


def filter_versions_for_channel(channel: int, versions: list[str]) -> list[str]:
    if channel == UPDATE_CHANNEL_MAIN:
        return [v for v in versions if is_stable_version(v)]
    if channel == UPDATE_CHANNEL_NIGHTLY:
        return [v for v in versions if is_nightly_version(v)]
    if channel == UPDATE_CHANNEL_DEV:
        return [v for v in versions if is_dev_version(v)]
    return list(versions)


# ---------------------------------------------------------------------------
# HTTP-Helfer
# ---------------------------------------------------------------------------


def read_text_url(url: str, *, timeout: int = UPDATE_HTTP_TIMEOUT_SEC) -> str:
    request = Request(url, headers={"User-Agent": "ViewIT/1.0"})
    response = None
    try:
        response = urlopen(request, timeout=timeout)
        data = response.read()
    finally:
        if response is not None:
            try:
                response.close()
            except Exception:
                pass
    return data.decode("utf-8", errors="replace")


def read_binary_url(url: str, *, timeout: int = UPDATE_HTTP_TIMEOUT_SEC) -> bytes:
    request = Request(url, headers={"User-Agent": "ViewIT/1.0"})
    response = None
    try:
        response = urlopen(request, timeout=timeout)
        return response.read()
    finally:
        if response is not None:
            try:
                response.close()
            except Exception:
                pass


# ---------------------------------------------------------------------------
# Repo-Abfragen
# ---------------------------------------------------------------------------


def extract_repo_addon_version(xml_text: str, addon_id: str = UPDATE_ADDON_ID) -> str:
    try:
        root = ET.fromstring(xml_text)
    except Exception:
        return "-"
    if root.tag == "addon":
        return str(root.attrib.get("version") or "-")
    for node in root.findall("addon"):
        if str(node.attrib.get("id") or "").strip() == addon_id:
            version = str(node.attrib.get("version") or "").strip()
            return version or "-"
    return "-"


def fetch_repo_addon_version(info_url: str) -> str:
    url = normalize_update_info_url(info_url)
    try:
        xml_text = read_text_url(url)
    except URLError:
        return "-"
    except Exception:
        return "-"
    return extract_repo_addon_version(xml_text)


def _extract_repo_identity(info_url: str) -> tuple[str, str, str, str] | None:
    from urllib.parse import urlparse

    parsed = urlparse(str(info_url or "").strip())
    parts = [part for part in parsed.path.split("/") if part]
    try:
        raw_idx = parts.index("raw")
    except ValueError:
        return None
    if raw_idx < 2 or (raw_idx + 2) >= len(parts):
        return None
    if parts[raw_idx + 1] != "branch":
        return None
    owner = parts[raw_idx - 2]
    repo = parts[raw_idx - 1]
    branch = parts[raw_idx + 2]
    scheme = parsed.scheme or "https"
    host = parsed.netloc
    if not owner or not repo or not branch or not host:
        return None
    return scheme, host, owner, repo + "|" + branch


def fetch_repo_versions(info_url: str) -> list[str]:
    identity = _extract_repo_identity(info_url)
    if identity is None:
        one = fetch_repo_addon_version(info_url)
        return [one] if one != "-" else []

    scheme, host, owner, repo_branch = identity
    repo, branch = repo_branch.split("|", 1)
    api_url = f"{scheme}://{host}/api/v1/repos/{owner}/{repo}/contents/{UPDATE_ADDON_ID}?ref={branch}"

    try:
        payload = read_text_url(api_url)
        data = json.loads(payload)
    except Exception:
        one = fetch_repo_addon_version(info_url)
        return [one] if one != "-" else []

    versions: list[str] = []
    if isinstance(data, list):
        for entry in data:
            if not isinstance(entry, dict):
                continue
            name = str(entry.get("name") or "")
            match = re.match(rf"^{re.escape(UPDATE_ADDON_ID)}-(.+)\.zip$", name)
            if not match:
                continue
            version = match.group(1).strip()
            if version:
                versions.append(version)
    unique = sorted(set(versions), key=version_sort_key, reverse=True)
    return unique


# ---------------------------------------------------------------------------
# Changelog
# ---------------------------------------------------------------------------


def extract_changelog_section(changelog_text: str, version: str) -> str:
    lines = changelog_text.splitlines()
    wanted = (version or "").strip()
    if not wanted:
        return "\n".join(lines[:120]).strip()

    start = -1
    for idx, line in enumerate(lines):
        if line.startswith("## ") and wanted in line:
            start = idx
            break
    if start < 0:
        return f"Kein Changelog-Abschnitt fuer Version {wanted} gefunden."

    end = len(lines)
    for idx in range(start + 1, len(lines)):
        if lines[idx].startswith("## "):
            end = idx
            break
    return "\n".join(lines[start:end]).strip()


def fetch_changelog_for_channel(channel: int, version: str) -> str:
    version_text = str(version or "").strip().casefold()
    if version_text.endswith("-dev"):
        url = "https://gitea.it-drui.de/viewit/ViewIT/raw/branch/dev/CHANGELOG-DEV.md"
    elif version_text.endswith("-nightly"):
        url = "https://gitea.it-drui.de/viewit/ViewIT/raw/branch/nightly/CHANGELOG-NIGHTLY.md"
    elif channel == UPDATE_CHANNEL_DEV:
        url = "https://gitea.it-drui.de/viewit/ViewIT/raw/branch/dev/CHANGELOG-DEV.md"
    elif channel == UPDATE_CHANNEL_MAIN:
        url = "https://gitea.it-drui.de/viewit/ViewIT/raw/branch/main/CHANGELOG.md"
    else:
        url = "https://gitea.it-drui.de/viewit/ViewIT/raw/branch/nightly/CHANGELOG-NIGHTLY.md"
    try:
        text = read_text_url(url)
    except Exception:
        return "Changelog konnte nicht geladen werden."
    return extract_changelog_section(text, version)


# ---------------------------------------------------------------------------
# Installation
# ---------------------------------------------------------------------------


def install_addon_version_manual(info_url: str, version: str) -> bool:
    base = info_url[: -len("/addons.xml")] if info_url.endswith("/addons.xml") else info_url.rstrip("/")
    zip_url = f"{base}/{UPDATE_ADDON_ID}/{UPDATE_ADDON_ID}-{version}.zip"
    try:
        zip_bytes = read_binary_url(zip_url)
    except Exception as exc:
        _log(f"Download fehlgeschlagen ({zip_url}): {exc}", 2)
        return False

    if xbmcvfs is None:
        return False

    addons_root = xbmcvfs.translatePath("special://home/addons")
    addons_root_real = os.path.realpath(addons_root)
    try:
        with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive:
            for member in archive.infolist():
                name = str(member.filename or "")
                if not name or name.endswith("/"):
                    continue
                target = os.path.realpath(os.path.join(addons_root, name))
                if not target.startswith(addons_root_real + os.sep):
                    _log(f"Sicherheitswarnung: Verdaechtiger ZIP-Eintrag abgelehnt: {name!r}", 2)
                    return False
                os.makedirs(os.path.dirname(target), exist_ok=True)
                with archive.open(member, "r") as src, open(target, "wb") as dst:
                    dst.write(src.read())
    except Exception as exc:
        _log(f"Entpacken fehlgeschlagen: {exc}", 2)
        return False

    builtin = getattr(xbmc, "executebuiltin", None) if xbmc else None
    if callable(builtin):
        builtin("UpdateLocalAddons")
    return True


def install_addon_version(info_url: str, version: str) -> bool:
    base = info_url[: -len("/addons.xml")] if info_url.endswith("/addons.xml") else info_url.rstrip("/")
    zip_url = f"{base}/{UPDATE_ADDON_ID}/{UPDATE_ADDON_ID}-{version}.zip"

    builtin = getattr(xbmc, "executebuiltin", None) if xbmc else None
    if callable(builtin):
        try:
            before = installed_addon_version_from_disk()
            builtin(f"InstallAddon({zip_url})")
            for _ in range(20):
                time.sleep(1)
                current = installed_addon_version_from_disk()
                if current == version:
                    return True
            if before == version:
                return True
        except Exception as exc:
            _log(f"InstallAddon fehlgeschlagen, fallback aktiv: {exc}", 2)

    return install_addon_version_manual(info_url, version)


# ---------------------------------------------------------------------------
# Installierte Version / Addon-Pruefung
# ---------------------------------------------------------------------------


def installed_addon_version_from_disk() -> str:
    if xbmcvfs is None:
        return "0.0.0"
    try:
        addon_xml = xbmcvfs.translatePath(f"special://home/addons/{UPDATE_ADDON_ID}/addon.xml")
    except Exception:
        return "0.0.0"
    if not addon_xml or not os.path.exists(addon_xml):
        return "0.0.0"
    try:
        root = ET.parse(addon_xml).getroot()
        version = str(root.attrib.get("version") or "").strip()
        return version or "0.0.0"
    except Exception:
        return "0.0.0"


def is_addon_installed(addon_id: str) -> bool:
    addon_id = str(addon_id or "").strip()
    if not addon_id:
        return False
    has_addon = getattr(xbmc, "getCondVisibility", None) if xbmc else None
    if callable(has_addon):
        try:
            return bool(has_addon(f"System.HasAddon({addon_id})"))
        except Exception:
            pass
    if xbmcvfs is None:
        return False
    try:
        addon_xml = xbmcvfs.translatePath(f"special://home/addons/{addon_id}/addon.xml")
    except Exception:
        return False
    return bool(addon_xml and os.path.exists(addon_xml))


# ---------------------------------------------------------------------------
# Repository-Quellen-Verwaltung
# ---------------------------------------------------------------------------


def repo_addon_xml_path() -> str:
    if xbmcvfs is None:
        return ""
    try:
        return xbmcvfs.translatePath("special://home/addons/repository.viewit/addon.xml")
    except Exception:
        return ""


def update_repository_source(info_url: str) -> bool:
    path = repo_addon_xml_path()
    if not path:
        return False
    if not os.path.exists(path):
        return False
    try:
        tree = ET.parse(path)
        root = tree.getroot()
        dir_node = root.find(".//dir")
        if dir_node is None:
            return False
        info = dir_node.find("info")
        checksum = dir_node.find("checksum")
        datadir = dir_node.find("datadir")
        if info is None or checksum is None or datadir is None:
            return False
        base = info_url[: -len("/addons.xml")] if info_url.endswith("/addons.xml") else info_url.rstrip("/")
        info.text = info_url
        checksum.text = f"{base}/addons.xml.md5"
        datadir.text = f"{base}/"
        tree.write(path, encoding="utf-8", xml_declaration=True)
        return True
    except Exception as exc:
        _log(f"Repository-URL konnte nicht gesetzt werden: {exc}", 2)
        return False


# ---------------------------------------------------------------------------
# ResolveURL
# ---------------------------------------------------------------------------


def sync_resolveurl_status_setting() -> None:
    status = "Installiert" if is_addon_installed(RESOLVEURL_ADDON_ID) else "Fehlt"
    _set_setting_string("resolveurl_status", status)


def install_kodi_addon(addon_id: str, *, wait_seconds: int) -> bool:
    if is_addon_installed(addon_id):
        return True
    builtin = getattr(xbmc, "executebuiltin", None) if xbmc else None
    if not callable(builtin):
        return False
    try:
        builtin(f"InstallAddon({addon_id})")
        builtin("UpdateLocalAddons")
    except Exception as exc:
        _log(f"InstallAddon fehlgeschlagen ({addon_id}): {exc}", 2)
        return False

    if wait_seconds <= 0:
        return is_addon_installed(addon_id)
    deadline = time.time() + max(1, int(wait_seconds))
    while time.time() < deadline:
        if is_addon_installed(addon_id):
            return True
        time.sleep(1)
    return is_addon_installed(addon_id)


def ensure_resolveurl_installed(*, force: bool, silent: bool) -> bool:
    if is_addon_installed(RESOLVEURL_ADDON_ID):
        sync_resolveurl_status_setting()
        return True
    if not force and not _get_setting_bool("resolveurl_auto_install", default=True):
        sync_resolveurl_status_setting()
        return False

    now = int(time.time())
    if not force:
        last_try = _get_setting_int("resolveurl_last_ts", default=0)
        if last_try > 0 and (now - last_try) < RESOLVEURL_AUTO_INSTALL_INTERVAL_SEC:
            return False
    _set_setting_string("resolveurl_last_ts", str(now))

    wait_seconds = 20 if force else 0
    ok = install_kodi_addon(RESOLVEURL_ADDON_ID, wait_seconds=wait_seconds)
    sync_resolveurl_status_setting()

    if not silent and xbmcgui is not None:
        if ok:
            xbmcgui.Dialog().notification(
                "ResolveURL",
                "script.module.resolveurl ist installiert.",
                xbmcgui.NOTIFICATION_INFO,
                4000,
            )
        else:
            xbmcgui.Dialog().notification(
                "ResolveURL",
                "Installation fehlgeschlagen. Bitte Repository/Netzwerk pruefen.",
                xbmcgui.NOTIFICATION_ERROR,
                5000,
            )
    return ok


def maybe_auto_install_resolveurl(action: str | None) -> None:
    if (action or "").strip():
        return
    ensure_resolveurl_installed(force=False, silent=True)


# ---------------------------------------------------------------------------
# Update-Kanal anwenden / Sync
# ---------------------------------------------------------------------------


def resolve_update_info_url() -> str:
    channel = selected_update_channel()
    if channel == UPDATE_CHANNEL_NIGHTLY:
        raw = _get_setting_string("update_repo_url_nightly")
    elif channel == UPDATE_CHANNEL_DEV:
        raw = _get_setting_string("update_repo_url_dev")
    elif channel == UPDATE_CHANNEL_CUSTOM:
        raw = _get_setting_string("update_repo_url")
    else:
        raw = _get_setting_string("update_repo_url_main")
    return normalize_update_info_url(raw)


def sync_update_channel_status_settings() -> None:
    channel = selected_update_channel()
    selected_info_url = resolve_update_info_url()
    available_selected = fetch_repo_addon_version(selected_info_url)
    _set_setting_string("update_active_channel", channel_label(channel))
    _set_setting_string("update_active_repo_url", selected_info_url)
    _set_setting_string("update_available_selected", available_selected)


def sync_update_version_settings() -> None:
    addon_version = installed_addon_version_from_disk()
    if addon_version == "0.0.0":
        addon = _get_addon()
        if addon is not None:
            try:
                addon_version = str(addon.getAddonInfo("version") or "0.0.0")
            except Exception:
                addon_version = "0.0.0"
    _set_setting_string("update_installed_version", addon_version)
    sync_resolveurl_status_setting()
    sync_update_channel_status_settings()


def apply_update_channel(*, silent: bool = False) -> bool:
    if xbmc is None:  # pragma: no cover - outside Kodi
        return False
    info_url = resolve_update_info_url()
    channel = selected_update_channel()
    sync_update_version_settings()
    applied = update_repository_source(info_url)
    installed_version = _get_setting_string("update_installed_version").strip() or "0.0.0"
    versions = filter_versions_for_channel(channel, fetch_repo_versions(info_url))
    target_version = versions[0] if versions else "-"

    install_result = False
    if target_version != "-" and target_version != installed_version:
        install_result = install_addon_version(info_url, target_version)
    elif target_version == installed_version:
        install_result = True

    builtin = getattr(xbmc, "executebuiltin", None)
    if callable(builtin):
        builtin("UpdateAddonRepos")
        builtin("UpdateLocalAddons")
    if not silent:
        if not applied:
            warning_icon = getattr(xbmcgui, "NOTIFICATION_WARNING", xbmcgui.NOTIFICATION_INFO)
            show_notification(
                "Updates",
                "Kanal gespeichert, aber repository.viewit nicht gefunden.",
                icon=warning_icon,
                milliseconds=5000,
            )
        elif target_version == "-":
            show_error("Updates", "Kanal angewendet, aber keine Version im Kanal gefunden.", milliseconds=5000)
        elif not install_result:
            show_error(
                "Updates",
                f"Kanal angewendet, Installation von {target_version} fehlgeschlagen.",
                milliseconds=5000,
            )
        elif target_version == installed_version:
            show_notification(
                "Updates",
                f"Kanal angewendet: {channel_label(selected_update_channel())} ({target_version} bereits installiert)",
                milliseconds=4500,
            )
        else:
            show_notification(
                "Updates",
                f"Kanal angewendet: {channel_label(selected_update_channel())} -> {target_version} installiert",
                milliseconds=5000,
            )
    sync_update_version_settings()
    return applied and install_result


def run_update_check(*, silent: bool = False) -> None:
    """Stoesst Kodi-Repo- und Addon-Updates an."""
    if xbmc is None:  # pragma: no cover - outside Kodi
        return
    try:
        apply_update_channel(silent=True)
        if not silent:
            builtin = getattr(xbmc, "executebuiltin", None)
            if callable(builtin):
                builtin("ActivateWindow(addonbrowser,addons://updates/)")
        if not silent:
            show_notification("Updates", "Update-Check gestartet.", milliseconds=4000)
    except Exception as exc:
        _log(f"Update-Pruefung fehlgeschlagen: {exc}", 2)
        if not silent:
            show_error("Updates", "Update-Check fehlgeschlagen.", milliseconds=4000)


def show_version_selector() -> None:
    if xbmc is None:  # pragma: no cover - outside Kodi
        return

    info_url = resolve_update_info_url()
    channel = selected_update_channel()
    sync_update_version_settings()

    versions = filter_versions_for_channel(channel, fetch_repo_versions(info_url))
    if not versions:
        show_error("Updates", "Keine Versionen im Repo gefunden.", milliseconds=4000)
        return

    installed = _get_setting_string("update_installed_version").strip() or "-"
    options = []
    for version in versions:
        label = version
        if version == installed:
            label = f"{version} (installiert)"
        options.append(label)

    selected = xbmcgui.Dialog().select("Version waehlen", options)
    if selected < 0 or selected >= len(versions):
        return

    version = versions[selected]
    changelog = fetch_changelog_for_channel(channel, version)
    viewer = getattr(xbmcgui.Dialog(), "textviewer", None)
    if callable(viewer):
        try:
            viewer(f"Changelog {version}", changelog)
        except Exception:
            pass

    dialog = xbmcgui.Dialog()
    try:
        confirmed = dialog.yesno(
            "Version installieren",
            f"Installiert: {installed}",
            f"Ausgewaehlt: {version}",
            yeslabel="Installieren",
            nolabel="Abbrechen",
        )
    except TypeError:
        confirmed = dialog.yesno("Version installieren", f"Installiert: {installed}", f"Ausgewaehlt: {version}")
    if not confirmed:
        return

    show_notification("Updates", f"Installation gestartet: {version}", milliseconds=2500)
    ok = install_addon_version(info_url, version)
    if ok:
        sync_update_version_settings()
        show_notification("Updates", f"Version {version} installiert.", milliseconds=4000)
    else:
        show_error("Updates", f"Installation von {version} fehlgeschlagen.", milliseconds=4500)


def maybe_run_auto_update_check(action: str | None) -> None:
    action = (action or "").strip()
    if action:
        return
    if not _get_setting_bool("auto_update_enabled", default=False):
        return
    now = int(time.time())
    last = _get_setting_int("auto_update_last_ts", default=0)
    if last > 0 and (now - last) < AUTO_UPDATE_INTERVAL_SEC:
        return
    _set_setting_string("auto_update_last_ts", str(now))
    run_update_check(silent=True)
