"""ReleaseProvider abstraction and shared tag/version utilities."""

from __future__ import annotations

import re
from dataclasses import dataclass
from enum import Enum
from typing import ClassVar, Protocol, TypedDict, runtime_checkable


class ReleaseProviderKind(str, Enum):
    """Supported upstream release-check providers."""

    DISABLED = "disabled"
    GITEA = "gitea"
    GITHUB = "github"


# Single source of truth for `release_error` taxonomy. Surfaced into the cached
# `AppSetting`, returned via the API, and translated by the frontend.
class ReleaseErrorCode(str, Enum):
    DISABLED = "disabled"
    MISCONFIGURED = "misconfigured"
    PROVIDER_CHANGED = "provider_changed"
    NO_RELEASE_FOUND = "no_release_found"
    NETWORK_ERROR = "network_error"
    HTTP_ERROR = "http_error"
    PARSE_ERROR = "parse_error"
    UNSAFE_URL = "unsafe_url"
    NOT_IMPLEMENTED = "not_implemented"
    UNKNOWN_ERROR = "unknown_error"


@dataclass(frozen=True)
class ReleaseInfo:
    """Normalised release metadata returned by a provider."""

    tag: str
    version: str
    name: str | None = None
    body: str | None = None
    url: str | None = None
    published_at: str | None = None
    prerelease: bool = False
    draft: bool = False


class ReleaseTestResult(TypedDict):
    """Structured shape returned by :meth:`ReleaseProvider.test`."""

    ok: bool
    info: ReleaseInfo | None
    error: str | None


@runtime_checkable
class ReleaseProvider(Protocol):
    """Protocol implemented by every release provider.

    Implementations are expected to be safe to instantiate without external
    side effects — connectivity is deferred until :meth:`fetch_latest` or
    :meth:`test` is awaited.
    """

    kind: ClassVar[ReleaseProviderKind]

    async def fetch_latest(self, *, include_prereleases: bool = False) -> ReleaseInfo | None:
        """Return the latest release, or ``None`` if there is nothing to report."""

    async def test(self) -> ReleaseTestResult:
        """Probe the upstream and return a structured status payload."""


# Owner/name validation — matches Gitea/GitHub's allowed identifier chars.
_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")


def is_valid_repo(repo: str) -> bool:
    """``True`` when ``repo`` is a safe ``owner/name`` string (no path traversal)."""

    return bool(repo) and _REPO_RE.match(repo) is not None


_TAG_NUMERIC = re.compile(r"\d+")
# Stop reading numeric segments at the first non-digit-non-dot character so
# ``1.0a2`` doesn't get parsed as ``(1, 0, 2)``.
_HEAD_SPLIT = re.compile(r"[^0-9.]")


def normalise_version(tag: str) -> str:
    """Strip a leading ``v`` from a tag (``"v1.2.3"`` → ``"1.2.3"``)."""

    if not tag:
        return ""
    cleaned = tag.strip()
    if cleaned.startswith(("v", "V")) and len(cleaned) > 1 and cleaned[1].isdigit():
        cleaned = cleaned[1:]
    return cleaned


def _split_version(version: str) -> tuple[tuple[int, ...], str]:
    """Split a version into (numeric segments, prerelease suffix).

    A non-empty prerelease suffix marks the version as pre-stable. We use it
    as a tie-break only — when numeric segments are equal a stable build
    sorts strictly newer than its pre-release counterpart (``0.7.2`` >
    ``0.7.2-rc1``), which prevents the badge from flickering between
    "up to date" and "downgrade available" on installs that ship the GA.
    """

    if not version:
        return (), ""
    work = version.split("+", 1)[0]
    if "-" in work:
        head, _, suffix = work.partition("-")
    else:
        # Implicit prerelease form: ``1.0a2`` / ``1.0rc1``. Anything after the
        # first non-digit-non-dot is treated as the suffix.
        m = _HEAD_SPLIT.search(work)
        if m and m.start() > 0:
            head, suffix = work[: m.start()], work[m.start():]
        else:
            head, suffix = work, ""
    segments = tuple(int(n) for n in _TAG_NUMERIC.findall(head))
    return segments, suffix.strip()


def compare_versions(a: str, b: str) -> int:
    """Return ``1`` if ``a > b``, ``-1`` if ``a < b``, ``0`` if equal.

    Numeric segments win. When numerically equal, *stable* (no suffix) beats
    *prerelease* (any non-empty suffix); two equally-prereleased versions
    compare equal — we deliberately do not order ``rc2`` over ``rc1`` because
    that requires real semver parsing and would only matter for downgrades.
    """

    sa, suffix_a = _split_version(normalise_version(a))
    sb, suffix_b = _split_version(normalise_version(b))
    length = max(len(sa), len(sb))
    for i in range(length):
        x = sa[i] if i < len(sa) else 0
        y = sb[i] if i < len(sb) else 0
        if x != y:
            return 1 if x > y else -1
    # Equal numerics — stable beats prerelease.
    if not suffix_a and suffix_b:
        return 1
    if suffix_a and not suffix_b:
        return -1
    return 0


def is_newer(candidate: str, baseline: str) -> bool:
    """``True`` when ``candidate`` is strictly newer than ``baseline``."""

    return compare_versions(candidate, baseline) > 0
