"""
UpdateService - check for, download and apply VoxCast updates.

Talks to the backend update manifest, compares against the running version, and
(on request) downloads the installer, verifies its SHA-256, launches it and lets
the app quit so the installer can replace the running files.

Backend contract (server/app/main.py):
  GET /api/v1/update/latest?channel=stable&variant=cuda
      -> {available:false} | {available:true, version, url, sha256, notes, mandatory,
          # optional (releases built with the runtime/app split):
          update_url, update_sha256, runtime_version}

Runtime/app split: url/sha256 always point at the FULL installer (~1.5 GB).
When the release also ships the small app-only update package AND this
install's runtime pack (runtime.json next to the EXE, written by build.py)
matches the release's runtime_version, the small package is downloaded
instead. Anything else — old installs without runtime.json, dev mode, a
release built against a newer CUDA runtime — falls back to the full setup.
"""
from __future__ import annotations

import glob
import hashlib
import json
import subprocess
from collections.abc import Callable
from pathlib import Path
from urllib.parse import urlparse

import httpx
from loguru import logger

from paths import app_dir, data_dir
from version import __version__
from .types import AppState
from .base_service import BaseService
from .http_exec import arequest, run_blocking
from .i18n import t

API_BASE = "https://getvoxcast.com/api/v1"
_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
_DOWNLOAD_TIMEOUT = httpx.Timeout(600.0, connect=10.0)
# Installers may only be fetched from our own domain (defense-in-depth: the
# downloaded file is executed, so a compromised manifest must not be able to
# point the download at an arbitrary host).
_ALLOWED_DOWNLOAD_HOSTS = {"getvoxcast.com", "www.getvoxcast.com"}


class UpdateError(Exception):
    """User-facing update failure (network, checksum mismatch, ...)."""


def _safe_installer_name(url: str) -> str:
    """Derive a safe local filename from the manifest URL (no traversal)."""
    name = (Path(urlparse(url).path).name or "").replace("/", "_").replace("\\", "_")[:128]
    if not name.lower().endswith(".exe"):
        raise UpdateError(t("upd_err_filename"))
    return name


def _assert_download_url(url: str) -> None:
    parsed = urlparse(url)
    if parsed.scheme != "https":
        raise UpdateError(t("upd_err_https"))
    # Reject userinfo outright: "https://getvoxcast.com:x@evil.com/" carries
    # our host in the netloc prefix but actually connects to evil.com — a
    # legitimate download URL never needs credentials embedded in it.
    if parsed.username is not None or parsed.password is not None:
        raise UpdateError(t("upd_err_host", host=parsed.hostname or ""))
    host = (parsed.hostname or "").lower()
    if host not in _ALLOWED_DOWNLOAD_HOSTS:
        raise UpdateError(t("upd_err_host", host=host))


def parse_version(v: str) -> tuple[int, ...]:
    """Parse '1.2.0' (ignoring any pre-release suffix) into a comparable tuple."""
    head = (v or "0").strip().lstrip("vV").split("-")[0].split("+")[0]
    parts: list[int] = []
    for chunk in head.split("."):
        digits = "".join(c for c in chunk if c.isdigit())
        parts.append(int(digits) if digits else 0)
    return tuple(parts) or (0,)


def is_newer(remote: str, local: str) -> bool:
    return parse_version(remote) > parse_version(local)


# Re-check pacing for the in-app update watcher. After a successful check the
# app re-checks a few times per day; after a failed one it retries quickly with
# backoff — an autostarted app often races the network coming up, and that
# single lost startup check must not mean "never informed until restart".
CHECK_INTERVAL_OK = 4 * 60 * 60
_RETRY_DELAYS = (60.0, 300.0, 900.0, 3600.0)


def next_check_delay(last_ok: bool, consecutive_failures: int) -> float:
    """Seconds to wait before the next automatic update check."""
    if last_ok:
        return float(CHECK_INTERVAL_OK)
    idx = min(max(consecutive_failures, 1), len(_RETRY_DELAYS)) - 1
    return _RETRY_DELAYS[idx]


def prompt_decision(
    *,
    update_available: bool,
    license_activated: bool,
    activation_visible: bool,
    app_state: str,
    meeting_state: str,
    version: str,
    shown_version: str,
) -> str:
    """Decide whether the update/changelog dialog may open right now.

    Returns "show", "defer" (re-evaluate when app/license/meeting state
    changes) or "skip" (no update, or this version was already prompted).
    Pure so the prompt state machine is unit-testable without Qt. The dialog
    is application-modal, so it must never interrupt active work and never
    stack on the (equally modal) activation overlay."""
    if not update_available or version == shown_version:
        return "skip"
    if not license_activated or activation_visible:
        return "defer"
    if app_state not in (AppState.IDLE, AppState.READY, AppState.ERROR):
        return "defer"
    if meeting_state != "idle":
        return "defer"
    return "show"


def build_variant() -> str:
    """Detect this build's variant. The CUDA build ships cudart64_*.dll next to
    the EXE (copied to the dist root during packaging); the CPU build does not."""
    if glob.glob(str(app_dir() / "cudart64_*.dll")):
        return "cuda"
    return "cpu"


def local_runtime_version() -> str:
    """Runtime-pack version of this install.

    Empty when absent — dev mode, or installs made before the runtime/app
    split shipped — which makes the client fall back to the full installer.
    """
    try:
        data = json.loads((app_dir() / "runtime.json").read_text(encoding="utf-8"))
        return str(data.get("runtime_version") or "")
    except Exception:
        return ""


def pick_download(data: dict, runtime_version: str,
                  allow_delta: bool = True) -> tuple[str, str, bool]:
    """Choose (url, sha256, is_delta) from an update manifest.

    The small app-only package is only usable when the locally installed
    runtime pack exactly matches the release's runtime_version; everything
    else takes the full installer. `allow_delta=False` forces the full
    installer even on a match (used after a delta was already rejected by
    the installer's runtime gate). Pure function — unit-tested.
    """
    upd_url = str(data.get("update_url") or "")
    upd_sha = str(data.get("update_sha256") or "").lower()
    required = str(data.get("runtime_version") or "")
    if allow_delta and upd_url and upd_sha and required and runtime_version == required:
        return upd_url, upd_sha, True
    return str(data.get("url") or ""), str(data.get("sha256") or "").lower(), False


# The delta package is refused by the installer when its runtime gate does not
# match this install (mismatching runtime.json, moved folder, stale registry).
# The app has already quit at that point, so nothing would ever change: the
# next launch would pick the same delta again — for a mandatory update that is
# an inescapable loop. Before launching a delta we therefore record the target
# version; if the app starts again on the OLD version with the marker present,
# the delta clearly did not apply and we fall back to the full installer.
_MARKER_FILE = "pending_delta.json"


def _marker_path() -> Path:
    return data_dir() / _MARKER_FILE


def mark_delta_attempt(version: str) -> None:
    """Record that a delta package for *version* is about to be installed."""
    try:
        _marker_path().write_text(json.dumps({"version": version}), encoding="utf-8")
    except OSError as e:
        # Best effort: without the marker we merely lose the fallback, so a
        # failure here must never block the update itself.
        logger.warning("Could not write delta marker: {}", e)


def clear_delta_marker() -> None:
    try:
        _marker_path().unlink(missing_ok=True)
    except OSError as e:
        logger.debug("Could not remove delta marker: {}", e)


def delta_marker_version() -> str:
    """Target version of the last launched delta package ('' = none)."""
    try:
        data = json.loads(_marker_path().read_text(encoding="utf-8"))
    except Exception:
        return ""
    return str(data.get("version") or "")


def delta_rejected_for(version: str) -> bool:
    """True when a delta for *version* was launched but we never got there.

    Only ever consulted for a version NEWER than the running one, so a marker
    naming exactly that version proves the installer refused it.
    """
    return bool(version) and delta_marker_version() == version


class UpdateService(BaseService):
    def __init__(self) -> None:
        self._ready = False

    async def start(self) -> None:
        # We are running the version the marker was written for, so that
        # delta DID apply — drop the marker, otherwise a later update to the
        # same version number would needlessly take the full installer.
        marker = delta_marker_version()
        if marker and not is_newer(marker, __version__):
            clear_delta_marker()
        self._ready = True

    async def stop(self) -> None:
        self._ready = False

    @property
    def is_ready(self) -> bool:
        return self._ready

    async def check(self, channel: str = "stable") -> dict | None:
        """Return update info if a newer version is available, else None."""
        try:
            r = await arequest(
                "GET",
                f"{API_BASE}/update/latest",
                timeout=_TIMEOUT,
                follow_redirects=True,
                params={"channel": channel, "variant": build_variant()},
            )
            r.raise_for_status()
            data = r.json()
        except httpx.HTTPError as e:
            raise UpdateError(t("upd_err_check", error=e)) from e
        except ValueError as e:
            raise UpdateError(t("upd_err_response")) from e

        if not isinstance(data, dict) or not data.get("available"):
            return None
        remote = str(data.get("version") or "")
        if not remote or not is_newer(remote, __version__):
            return None
        # We are still running the OLD version: if a delta for exactly this
        # target was launched before, the installer's runtime gate refused it.
        rejected = delta_rejected_for(remote)
        if rejected:
            logger.warning(
                "Update {}: delta was launched before but did not apply - "
                "falling back to the full installer", remote)
        url, sha256, is_delta = pick_download(
            data, local_runtime_version(), allow_delta=not rejected)
        if is_delta:
            logger.info("Update {}: using app-only update package", remote)
        return {
            "version": remote,
            "url": url,
            "sha256": sha256,
            "notes": data.get("notes") or "",
            "mandatory": bool(data.get("mandatory")),
            "delta": is_delta,
        }

    async def download(
        self, url: str, sha256: str, progress_cb: Callable[[float], None] | None = None
    ) -> Path:
        """Download the installer, verify its SHA-256, return the local path."""
        if not url:
            raise UpdateError(t("upd_err_no_url"))
        _assert_download_url(url)
        # Fail closed: never run an installer we can't integrity-check.
        if not sha256:
            raise UpdateError(t("upd_err_no_checksum"))
        safe_name = _safe_installer_name(url)
        cache = data_dir() / "cache"
        cache.mkdir(parents=True, exist_ok=True)
        dest = cache / safe_name
        hasher = hashlib.sha256()

        def _download_blocking() -> None:
            # Runs in a worker thread (QtAsyncio has no async networking).
            # progress_cb only emits Qt signals, which are queued cross-thread.
            # No redirects: the host allowlist is checked on the manifest URL,
            # and following a redirect would silently bypass that check.
            with httpx.Client(timeout=_DOWNLOAD_TIMEOUT, follow_redirects=False) as client:
                with client.stream("GET", url) as resp:
                    if resp.is_redirect:
                        raise UpdateError(t("upd_err_redirect"))
                    resp.raise_for_status()
                    total = int(resp.headers.get("content-length", 0))
                    done = 0
                    last_pct = -1.0
                    with dest.open("wb") as f:
                        for chunk in resp.iter_bytes(1024 * 256):
                            f.write(chunk)
                            hasher.update(chunk)
                            done += len(chunk)
                            if progress_cb and total > 0:
                                pct = min(done / total, 1.0)
                                # Throttle cross-thread signal emission (a
                                # 1.4 GB installer would emit ~5600 times).
                                if pct - last_pct >= 0.005 or pct >= 1.0:
                                    last_pct = pct
                                    progress_cb(pct)

        try:
            await run_blocking(_download_blocking)
        except UpdateError:
            dest.unlink(missing_ok=True)
            raise
        except httpx.HTTPError as e:
            dest.unlink(missing_ok=True)  # don't leave a partial installer behind
            raise UpdateError(t("upd_err_download", error=e)) from e
        except OSError as e:  # disk full, AV lock, permissions
            dest.unlink(missing_ok=True)
            raise UpdateError(t("upd_err_save", error=e)) from e

        digest = hasher.hexdigest().lower()
        if digest != sha256.lower():
            dest.unlink(missing_ok=True)
            logger.error("Update checksum mismatch: expected {}, got {}", sha256, digest)
            raise UpdateError(t("upd_err_integrity"))
        logger.info("Update downloaded and verified: {}", dest)
        return dest

    @staticmethod
    def launch_installer(installer_path: Path) -> None:
        """Start the installer detached so it can replace the running app."""
        logger.info("Launching installer: {}", installer_path)
        subprocess.Popen(  # noqa: S603 (trusted, integrity-checked installer)
            [str(installer_path)],
            close_fds=True,
            creationflags=getattr(subprocess, "DETACHED_PROCESS", 0),
        )
