#!/usr/bin/env python3
"""Minimal self-update helper for Python scripts."""

from __future__ import annotations

import hashlib
import json
import os
import stat
import sys
import tempfile
from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urljoin
from urllib.request import Request, urlopen


class UpdateError(RuntimeError):
    """Raised when the update flow cannot be completed safely."""


ReleaseHandler = Callable[[Path, dict[str, Any]], None]


def ensure_updated(
    base_url: str,
    api_key: str,
    project_slug: str,
    current_version: str,
    apply_update: ReleaseHandler | None = None,
) -> bool:
    """
    Check for a newer release and restart the current script after update.

    If `apply_update` is omitted, the current script file is replaced in place.
    If `apply_update` is provided, it receives `(downloaded_artifact_path, release_dict)`
    and is responsible for applying the downloaded artifact before restart.
    """
    if not api_key or api_key == "upsk_replace_me":
        raise UpdateError("set a real API key before calling ensure_updated")

    script_path = _current_script_path()
    release = _load_latest_release(
        base_url=base_url,
        api_key=api_key,
        project_slug=project_slug,
    )

    latest_version = str(release.get("version") or "")
    if not latest_version or latest_version == current_version:
        return False

    download_url = urljoin(base_url.rstrip("/") + "/", str(release.get("download_url") or ""))
    if not download_url:
        raise UpdateError("latest release does not contain a download URL")

    temp_path = _download_release(
        url=download_url,
        api_key=api_key,
        checksum_sha256=str(release.get("checksum_sha256") or ""),
        size_bytes=int(release.get("size_bytes") or 0),
        target_name=script_path.name,
    )

    try:
        if apply_update is None:
            _replace_file(temp_path, script_path)
        else:
            apply_update(temp_path, release)
    finally:
        temp_path.unlink(missing_ok=True)

    _restart_current_process(script_path)
    return True


def _current_script_path() -> Path:
    main_module = sys.modules.get("__main__")
    main_file = getattr(main_module, "__file__", None)
    if not main_file:
        raise UpdateError("cannot resolve the current script path")
    return Path(main_file).resolve()


def _make_request(url: str, api_key: str, accept_json: bool = False) -> Request:
    headers = {
        "Authorization": f"Bearer {api_key}",
        "User-Agent": "autoupdate/1.0",
    }
    if accept_json:
        headers["Accept"] = "application/json"
    return Request(url, headers=headers)


def _load_latest_release(
    base_url: str,
    api_key: str,
    project_slug: str,
) -> dict[str, Any]:
    latest_url = urljoin(
        base_url.rstrip("/") + "/",
        f"api/v1/projects/{quote(project_slug, safe='')}/releases/latest",
    )
    request = _make_request(latest_url, api_key, accept_json=True)

    try:
        with urlopen(request, timeout=15) as response:
            charset = response.headers.get_content_charset() or "utf-8"
            payload = json.loads(response.read().decode(charset))
    except HTTPError as exc:
        if exc.code == 404:
            raise UpdateError(f"no releases found for project '{project_slug}'") from exc
        raise UpdateError(f"HTTP {exc.code} while checking updates: {exc.reason}") from exc
    except URLError as exc:
        raise UpdateError(f"network error while checking updates: {exc.reason}") from exc

    release = payload.get("release")
    if not isinstance(release, dict):
        raise UpdateError("update server returned an unexpected response")
    return release


def _download_release(
    url: str,
    api_key: str,
    checksum_sha256: str,
    size_bytes: int,
    target_name: str,
) -> Path:
    request = _make_request(url, api_key)
    digest = hashlib.sha256()
    written = 0
    tmp_path: Path | None = None

    try:
        with urlopen(request, timeout=60) as response:
            with tempfile.NamedTemporaryFile(
                prefix=target_name + ".",
                suffix=".download",
                delete=False,
            ) as tmp_file:
                tmp_path = Path(tmp_file.name)
                while True:
                    chunk = response.read(64 * 1024)
                    if not chunk:
                        break
                    tmp_file.write(chunk)
                    digest.update(chunk)
                    written += len(chunk)

                tmp_file.flush()
                os.fsync(tmp_file.fileno())
    except HTTPError as exc:
        if tmp_path is not None:
            tmp_path.unlink(missing_ok=True)
        raise UpdateError(f"HTTP {exc.code} while downloading update: {exc.reason}") from exc
    except URLError as exc:
        if tmp_path is not None:
            tmp_path.unlink(missing_ok=True)
        raise UpdateError(f"network error while downloading update: {exc.reason}") from exc
    except Exception:
        if tmp_path is not None:
            tmp_path.unlink(missing_ok=True)
        raise

    actual_sha256 = digest.hexdigest()
    if checksum_sha256 and actual_sha256 != checksum_sha256:
        tmp_path.unlink(missing_ok=True)
        raise UpdateError(
            f"checksum mismatch: expected {checksum_sha256}, got {actual_sha256}"
        )

    if size_bytes and written != size_bytes:
        tmp_path.unlink(missing_ok=True)
        raise UpdateError(f"size mismatch: expected {size_bytes}, got {written}")

    return tmp_path


def _replace_file(downloaded_path: Path, target_path: Path) -> None:
    target_path.parent.mkdir(parents=True, exist_ok=True)

    if target_path.exists():
        current_mode = stat.S_IMODE(target_path.stat().st_mode)
        os.chmod(downloaded_path, current_mode)

    os.replace(downloaded_path, target_path)


def _restart_current_process(script_path: Path) -> None:
    os.execv(sys.executable, [sys.executable, str(script_path), *sys.argv[1:]])
