# Copyright (c) 2026 Pyry Haulos
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT

"""Version information and upstream update checks.

Provides version resolution, install-source detection (PEP 610), and
upstream update checks (PyPI / GitHub).

Two version resolution strategies are supported:

1. **Embedded** — when running from a built wheel, ``airut/_version.py``
   (generated by the ``hatch_build.py`` build hook) is imported to get the
   version, SHA and commit info baked in at build time.
2. **Git fallback** — when running from a source checkout (or if
   ``_version.py`` is absent), git commands are executed at runtime.
"""

from __future__ import annotations

import json
import logging
import subprocess
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from importlib.metadata import PackageNotFoundError, distribution
from pathlib import Path

from packaging.version import InvalidVersion, Version


logger = logging.getLogger(__name__)

#: Package name as registered on PyPI and in importlib.metadata.
PACKAGE_NAME = "airut"

#: GitHub owner/repo for upstream commit checks.
GITHUB_OWNER = "airutorg"
GITHUB_REPO = "airut"


@dataclass
class GitVersionInfo:
    """Git version information captured at service startup.

    Attributes:
        version: Human-readable version string (e.g. "v0.7.0",
            "v0.7.0-3-gabc1234").  Empty string when no version tag
            is available.
        sha_short: Short git commit SHA (7-8 characters).
        sha_full: Full 40-character git commit SHA.
        full_status: Full git status output (HEAD info + worktree status).
    """

    version: str
    sha_short: str
    sha_full: str
    full_status: str


@dataclass
class InstallSource:
    """Describes how the package was installed.

    Attributes:
        kind: One of ``"pypi"``, ``"vcs"``, ``"editable"``, ``"local-dir"``,
            ``"archive"``, or ``"unknown"``.
        url: Remote URL for VCS or archive installs, local path for
            editable/local-dir installs.  None for PyPI installs.
        vcs_commit: Resolved commit hash for VCS installs.
        vcs_requested_revision: Branch/tag/ref requested for VCS installs.
    """

    kind: str
    url: str | None = None
    vcs_commit: str | None = None
    vcs_requested_revision: str | None = None


def get_install_source() -> InstallSource:
    """Determine how the package was installed.

    Uses PEP 610 ``direct_url.json`` metadata from the distribution info.
    Absence of ``direct_url.json`` indicates a standard index install (PyPI).

    Returns:
        InstallSource describing the installation origin.
    """
    try:
        dist = distribution(PACKAGE_NAME)
    except PackageNotFoundError:
        return InstallSource(kind="unknown")

    raw = dist.read_text("direct_url.json")
    if raw is None:
        return InstallSource(kind="pypi")

    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        return InstallSource(kind="unknown")

    url = data.get("url")

    if "vcs_info" in data:
        vcs_info = data["vcs_info"]
        return InstallSource(
            kind="vcs",
            url=url,
            vcs_commit=vcs_info.get("commit_id"),
            vcs_requested_revision=vcs_info.get("requested_revision"),
        )

    if "archive_info" in data:
        return InstallSource(kind="archive", url=url)

    if "dir_info" in data:
        editable = data["dir_info"].get("editable", False)
        return InstallSource(
            kind="editable" if editable else "local-dir",
            url=url,
        )

    return InstallSource(kind="unknown")


@dataclass
class UpstreamVersion:
    """Latest version information from upstream.

    Attributes:
        source: Where the check was made (``"pypi"`` or ``"github"``).
        latest: Latest version string (PyPI version) or commit SHA
            (GitHub).
        current: Current installed version string or commit SHA.
        update_available: True if the upstream version differs from
            the installed version.
    """

    source: str
    latest: str
    current: str
    update_available: bool


def check_upstream_version(
    version_info: GitVersionInfo,
    *,
    timeout: float = 5,
) -> UpstreamVersion | None:
    """Check if a newer version is available upstream.

    For PyPI installs, queries the PyPI JSON API for the latest release.
    For VCS (GitHub) installs, queries the GitHub API for the latest
    commit on the requested branch.

    For editable, local-dir, and unknown installs, returns None since
    there is no meaningful upstream to check.

    Args:
        version_info: Current version info from ``get_git_version_info()``.
        timeout: HTTP request timeout in seconds.

    Returns:
        UpstreamVersion with comparison result, or None if the check
        is not applicable or fails.
    """
    source = get_install_source()

    if source.kind == "pypi":
        return _check_pypi(version_info, timeout=timeout)

    if source.kind == "vcs":
        return _check_github(
            version_info,
            branch=source.vcs_requested_revision or "main",
            timeout=timeout,
        )

    # Editable / local-dir / archive / unknown — nothing to check.
    return None


def _fetch_pypi_version(*, timeout: float = 5) -> str | None:
    """Fetch the latest version string from PyPI.

    Args:
        timeout: HTTP request timeout in seconds.

    Returns:
        Version string (e.g. ``"0.9.0"``), or None on failure.
    """
    url = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
    try:
        req = urllib.request.Request(url, method="GET")
        req.add_header("Accept", "application/json")
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            data = json.loads(resp.read().decode())
    except (urllib.error.URLError, OSError, json.JSONDecodeError, ValueError):
        return None

    latest = data.get("info", {}).get("version", "")
    return latest or None


#: Seconds to wait between PyPI retry attempts.
_PYPI_RETRY_DELAY = 1.0


def _check_pypi(
    version_info: GitVersionInfo,
    *,
    timeout: float = 5,
) -> UpstreamVersion | None:
    """Query PyPI for the latest release version.

    Retries once when the returned version is older than the installed
    version, which can happen due to stale CDN caches.  Never reports
    an update when the upstream version is older than the installed
    version (prevents accidental downgrades).

    Args:
        version_info: Current version info.
        timeout: HTTP request timeout in seconds.

    Returns:
        UpstreamVersion or None on failure.
    """
    # Current version from embedded metadata, stripped of leading "v".
    current = version_info.version.lstrip("v")

    latest = _fetch_pypi_version(timeout=timeout)
    if latest is None:
        return None

    # When PyPI returns a version older than installed, retry once —
    # the CDN cache may be stale and a second request often hits a
    # fresh edge.
    if _is_older(latest, current):
        logger.debug(
            "PyPI returned %s which is older than installed %s; retrying",
            latest,
            current,
        )
        time.sleep(_PYPI_RETRY_DELAY)
        retry = _fetch_pypi_version(timeout=timeout)
        if retry is not None:
            latest = retry

    # Never suggest a downgrade.
    if _is_older(latest, current):
        logger.debug(
            "PyPI version %s is still older than installed %s; "
            "reporting up-to-date",
            latest,
            current,
        )
        return UpstreamVersion(
            source="pypi",
            latest=latest,
            current=current,
            update_available=False,
        )

    return UpstreamVersion(
        source="pypi",
        latest=latest,
        current=current,
        update_available=latest != current,
    )


def _is_older(candidate: str, reference: str) -> bool:
    """Return True if *candidate* is strictly older than *reference*.

    Uses PEP 440 version parsing.  Returns False if either string
    cannot be parsed (fall back to simple string comparison in the
    caller).
    """
    try:
        return Version(candidate) < Version(reference)
    except InvalidVersion:
        return False


def _check_github(
    version_info: GitVersionInfo,
    *,
    branch: str = "main",
    timeout: float = 5,
) -> UpstreamVersion | None:
    """Query GitHub API for the latest commit on a branch.

    Args:
        version_info: Current version info (needs ``sha_full``).
        branch: Branch name to check.
        timeout: HTTP request timeout in seconds.

    Returns:
        UpstreamVersion or None on failure.
    """
    url = (
        f"https://api.github.com/repos/"
        f"{GITHUB_OWNER}/{GITHUB_REPO}/branches/{branch}"
    )
    try:
        req = urllib.request.Request(url, method="GET")
        req.add_header("Accept", "application/vnd.github+json")
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            data = json.loads(resp.read().decode())
    except (urllib.error.URLError, OSError, json.JSONDecodeError, ValueError):
        return None

    latest_sha = data.get("commit", {}).get("sha", "")
    if not latest_sha:
        return None

    current_sha = version_info.sha_full

    return UpstreamVersion(
        source="github",
        latest=latest_sha,
        current=current_sha,
        update_available=latest_sha != current_sha,
    )


def is_exact_version_tag(version: str) -> bool:
    """Check if a version string is an exact tag (not a git describe suffix).

    ``git describe`` outputs ``v0.9.0`` for exact matches and
    ``v0.9.0-4-gecb890e`` for 4 commits after the tag.  Only the exact
    form corresponds to a GitHub release.

    Args:
        version: Version string from ``git describe``.

    Returns:
        True if this looks like an exact version tag (e.g. ``v0.9.0``),
        False if it has a ``-N-gSHA`` suffix or is empty.
    """
    if not version:
        return False
    # git describe format: vX.Y.Z-N-gSHA — the -N-g suffix means
    # commits after the tag.
    v = version.lstrip("v")
    try:
        Version(v)
        return True
    except InvalidVersion:
        return False


def github_release_url(version: str) -> str:
    """Construct a GitHub releases page URL for a version tag.

    Args:
        version: Clean version string (e.g. "v0.7.0" or "0.7.0").
            Must be an exact tag — do not pass git describe output
            with ``-N-gSHA`` suffix.

    Returns:
        URL to the GitHub releases page for this tag.
    """
    tag = version if version.startswith("v") else f"v{version}"
    return f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/tag/{tag}"


def github_commit_url(sha: str) -> str:
    """Construct a GitHub commit page URL.

    Args:
        sha: Full or short commit SHA.

    Returns:
        URL to the GitHub commit page.
    """
    return f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/commit/{sha}"


def _try_embedded() -> GitVersionInfo | None:
    """Try to load version info from the build-time embedded module.

    Returns:
        GitVersionInfo if ``airut._version`` is importable, None otherwise.
    """
    try:
        from airut._version import GIT_SHA_FULL, GIT_SHA_SHORT, VERSION
    except ImportError:
        return None

    full_status = (
        f"=== VERSION ===\n{VERSION} ({GIT_SHA_SHORT})\n\n"
        f"Built from: {GIT_SHA_FULL}"
    )

    return GitVersionInfo(
        version=VERSION,
        sha_short=GIT_SHA_SHORT,
        sha_full=GIT_SHA_FULL,
        full_status=full_status,
    )


def get_git_version_info() -> GitVersionInfo:
    """Read git repository version information.

    Always attempts to load embedded version info first (from a built
    wheel).  If that fails, falls back to live git commands.

    Returns:
        GitVersionInfo with all version details.
    """
    embedded = _try_embedded()
    if embedded is not None:
        return embedded

    return _get_git_version_info_live()


def _get_git_version_info_live() -> GitVersionInfo:
    """Read version info from git commands (live fallback).

    Returns:
        GitVersionInfo with all version details from git.
    """
    repo_path = Path(__file__).parent.parent

    # Get version from git describe
    try:
        result = subprocess.run(
            ["git", "describe", "--tags", "--match", "v*"],
            cwd=repo_path,
            capture_output=True,
            text=True,
            check=True,
        )
        version = result.stdout.strip()
    except subprocess.CalledProcessError:
        version = ""

    # Get short SHA
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--short", "HEAD"],
            cwd=repo_path,
            capture_output=True,
            text=True,
            check=True,
        )
        sha_short = result.stdout.strip()
    except subprocess.CalledProcessError:
        sha_short = "unknown"

    # Get full SHA
    try:
        result = subprocess.run(
            ["git", "rev-parse", "HEAD"],
            cwd=repo_path,
            capture_output=True,
            text=True,
            check=True,
        )
        sha_full = result.stdout.strip()
    except subprocess.CalledProcessError:
        sha_full = "unknown"

    # Get HEAD commit info (git show HEAD --no-patch)
    try:
        result = subprocess.run(
            ["git", "show", "HEAD", "--no-patch"],
            cwd=repo_path,
            capture_output=True,
            text=True,
            check=True,
        )
        head_info = result.stdout.strip()
    except subprocess.CalledProcessError as e:
        error_msg = e.stderr.strip() if e.stderr else str(e)
        head_info = f"Error: {error_msg}"

    # Get full status
    try:
        result = subprocess.run(
            ["git", "status"],
            cwd=repo_path,
            capture_output=True,
            text=True,
            check=True,
        )
        status_info = result.stdout.strip()
    except subprocess.CalledProcessError as e:
        error_msg = e.stderr.strip() if e.stderr else str(e)
        status_info = f"Error: {error_msg}"

    # Build full_status with optional version header
    sections = []
    if version:
        sections.append(f"=== VERSION ===\n{version} ({sha_short})")
    sections.append(f"=== HEAD COMMIT ===\n{head_info}")
    sections.append(f"=== WORKING TREE STATUS ===\n{status_info}")
    full_status = "\n\n".join(sections)

    return GitVersionInfo(
        version=version,
        sha_short=sha_short,
        sha_full=sha_full,
        full_status=full_status,
    )
