"""Upstream release-check service.

Reads the configured release provider, asks it for the latest upstream release,
and caches the result into :class:`AppSetting` rows so the API can serve the
status without re-hitting the network. All failures are swallowed and surfaced
through ``release_error`` — the server must stay up even if Gitea is down.
"""

from __future__ import annotations

import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone

import aiohttp

from notify_bridge_core.release import (
    ReleaseErrorCode,
    ReleaseInfo,
    ReleaseProviderKind,
    build_release_provider,
)
from notify_bridge_core.release.base import is_newer
from sqlmodel.ext.asyncio.session import AsyncSession

from ..api.app_settings import get_setting
from ..database.engine import get_engine
from ..database.models import AppSetting

_LOGGER = logging.getLogger(__name__)

# Cached-state AppSetting keys (read by the API, written by the checker).
KEY_LATEST_TAG = "release_latest_tag"
KEY_LATEST_VERSION = "release_latest_version"
KEY_LATEST_URL = "release_latest_url"
KEY_LATEST_BODY = "release_latest_body"
KEY_LATEST_NAME = "release_latest_name"
KEY_LATEST_PUBLISHED_AT = "release_latest_published_at"
KEY_LATEST_PRERELEASE = "release_latest_prerelease"
KEY_CHECKED_AT = "release_checked_at"
KEY_ERROR = "release_error"

# Operator-configured keys.
KEY_PROVIDER_KIND = "release_provider_kind"
KEY_PROVIDER_URL = "release_provider_url"
KEY_PROVIDER_REPO = "release_provider_repo"
KEY_INCLUDE_PRERELEASES = "release_include_prereleases"
KEY_CHECK_INTERVAL_HOURS = "release_check_interval_hours"

# Allowed range for the interval (matches the UI hint).
INTERVAL_MIN_HOURS = 1
INTERVAL_MAX_HOURS = 168

# Minimum gap between checks. Independent of the configured interval — a flood
# of /release/check API calls or scheduler misfires can't push real load on
# upstream Gitea within this window.
_MIN_CHECK_INTERVAL = timedelta(seconds=30)

# Serialises concurrent run_check invocations (scheduled job + manual force
# check + provider-changed save can all fire close together).
_run_lock = asyncio.Lock()

_CACHED_KEYS = (
    KEY_LATEST_TAG,
    KEY_LATEST_VERSION,
    KEY_LATEST_URL,
    KEY_LATEST_BODY,
    KEY_LATEST_NAME,
    KEY_LATEST_PUBLISHED_AT,
    KEY_LATEST_PRERELEASE,
    KEY_CHECKED_AT,
    KEY_ERROR,
)


@dataclass(frozen=True)
class ReleaseStatus:
    """Snapshot returned by :func:`load_status` and friends."""

    provider: str
    current: str
    latest: str | None
    latest_tag: str | None
    latest_url: str | None
    latest_body: str | None
    latest_name: str | None
    latest_published_at: str | None
    latest_prerelease: bool
    checked_at: str | None
    update_available: bool
    error: str | None


def _server_version() -> str:
    """Resolve the running server version (delegates to the shared helper).

    Routed through :mod:`notify_bridge_server.version` so the "current" the
    UI reports matches `/api/health` and is robust to stale editable installs.
    """
    from ..version import resolve_version

    return resolve_version()


def parse_interval_hours(raw: str | None, default: int = 12) -> int:
    """Clamp/parse the interval setting into a sensible integer."""

    try:
        value = int((raw or "").strip() or default)
    except (TypeError, ValueError):
        return default
    return max(INTERVAL_MIN_HOURS, min(INTERVAL_MAX_HOURS, value))


def _coerce_provider_kind(raw: str | None) -> str:
    """Normalise the stored kind to a known enum value (default: disabled)."""
    try:
        return ReleaseProviderKind(raw or "").value
    except ValueError:
        return ReleaseProviderKind.DISABLED.value


async def load_status() -> ReleaseStatus:
    """Read the latest cached status without performing a network call."""

    async with AsyncSession(get_engine()) as session:
        provider = await get_setting(session, KEY_PROVIDER_KIND)
        latest_tag = await get_setting(session, KEY_LATEST_TAG)
        latest_version = await get_setting(session, KEY_LATEST_VERSION)
        latest_url = await get_setting(session, KEY_LATEST_URL)
        latest_body = await get_setting(session, KEY_LATEST_BODY)
        latest_name = await get_setting(session, KEY_LATEST_NAME)
        latest_published_at = await get_setting(session, KEY_LATEST_PUBLISHED_AT)
        latest_prerelease = await get_setting(session, KEY_LATEST_PRERELEASE)
        checked_at = await get_setting(session, KEY_CHECKED_AT)
        error = await get_setting(session, KEY_ERROR)

    current = _server_version()
    has_latest = bool(latest_version)
    update_available = bool(has_latest and is_newer(latest_version, current))
    return ReleaseStatus(
        provider=_coerce_provider_kind(provider),
        current=current,
        latest=latest_version or None,
        latest_tag=latest_tag or None,
        latest_url=latest_url or None,
        latest_body=latest_body or None,
        latest_name=latest_name or None,
        latest_published_at=latest_published_at or None,
        latest_prerelease=latest_prerelease == "1",
        checked_at=checked_at or None,
        update_available=update_available,
        error=error or None,
    )


async def run_check(*, force: bool = False) -> ReleaseStatus:
    """Hit the configured provider and persist the result, then return it.

    Args:
        force: bypass the per-process rate limit. Used by the manual
            "Check now" admin action; the scheduled probe never forces.
    """
    async with _run_lock:
        return await _run_check_locked(force=force)


async def _run_check_locked(*, force: bool) -> ReleaseStatus:
    from .http_session import get_http_session

    # Throttle: if the last check landed within _MIN_CHECK_INTERVAL and the
    # caller didn't ask for force, skip the network round-trip and return the
    # cached status. Force is still gated by the lock above, so an abusive
    # admin spamming /release/check serialises to one in-flight at a time.
    if not force:
        async with AsyncSession(get_engine()) as session:
            last = await get_setting(session, KEY_CHECKED_AT)
        if last:
            try:
                last_dt = datetime.fromisoformat(last)
                if datetime.now(timezone.utc) - last_dt < _MIN_CHECK_INTERVAL:
                    return await load_status()
            except ValueError:
                pass  # corrupted timestamp → fall through and overwrite

    async with AsyncSession(get_engine()) as session:
        provider_kind = await get_setting(session, KEY_PROVIDER_KIND)
        provider_url = await get_setting(session, KEY_PROVIDER_URL)
        provider_repo = await get_setting(session, KEY_PROVIDER_REPO)
        include_prereleases = (await get_setting(session, KEY_INCLUDE_PRERELEASES)) == "1"

    http = await get_http_session()
    provider = build_release_provider(
        provider_kind or ReleaseProviderKind.DISABLED.value,
        session=http,
        url=provider_url,
        repo=provider_repo,
    )

    timestamp = datetime.now(timezone.utc).isoformat()

    if provider is None:
        # Disabled (no error to surface) vs misconfigured (operator action
        # required) are different states — the UI distinguishes them.
        kind = _coerce_provider_kind(provider_kind)
        err = (
            ReleaseErrorCode.DISABLED.value
            if kind == ReleaseProviderKind.DISABLED.value
            else ReleaseErrorCode.MISCONFIGURED.value
        )
        await persist_release_state(checked_at=timestamp, error=err, info=None)
        return await load_status()

    try:
        info = await provider.fetch_latest(include_prereleases=include_prereleases)
    except (aiohttp.ClientError, asyncio.TimeoutError) as err:
        _LOGGER.warning("Release provider network error: %s", err)
        await persist_release_state(
            checked_at=timestamp,
            error=ReleaseErrorCode.NETWORK_ERROR.value,
            info=None,
        )
        return await load_status()
    except ValueError as err:
        _LOGGER.warning("Release provider parse/validation error: %s", err)
        await persist_release_state(
            checked_at=timestamp,
            error=ReleaseErrorCode.PARSE_ERROR.value,
            info=None,
        )
        return await load_status()

    if info is None:
        await persist_release_state(
            checked_at=timestamp,
            error=ReleaseErrorCode.NO_RELEASE_FOUND.value,
            info=None,
        )
        return await load_status()

    await persist_release_state(checked_at=timestamp, error=None, info=info)
    return await load_status()


async def persist_release_state(
    *,
    checked_at: str,
    error: str | None,
    info: ReleaseInfo | None,
) -> None:
    """Write all cached-state keys in one transaction.

    Public because the settings PUT handler invokes it to flush stale cache
    when the operator points the provider at a different repo — we don't want
    the previous repo's "latest" to keep advertising as available.
    """

    if info is None:
        rows: dict[str, str] = {
            KEY_LATEST_TAG: "",
            KEY_LATEST_VERSION: "",
            KEY_LATEST_URL: "",
            KEY_LATEST_BODY: "",
            KEY_LATEST_NAME: "",
            KEY_LATEST_PUBLISHED_AT: "",
            KEY_LATEST_PRERELEASE: "0",
        }
    else:
        rows = {
            KEY_LATEST_TAG: info.tag,
            KEY_LATEST_VERSION: info.version,
            KEY_LATEST_URL: info.url or "",
            KEY_LATEST_BODY: info.body or "",
            KEY_LATEST_NAME: info.name or "",
            KEY_LATEST_PUBLISHED_AT: info.published_at or "",
            KEY_LATEST_PRERELEASE: "1" if info.prerelease else "0",
        }
    rows[KEY_CHECKED_AT] = checked_at
    rows[KEY_ERROR] = error or ""

    async with AsyncSession(get_engine()) as session:
        for key, value in rows.items():
            row = await session.get(AppSetting, key)
            if row:
                row.value = value
            else:
                row = AppSetting(key=key, value=value)
            session.add(row)
        await session.commit()


def cached_keys() -> tuple[str, ...]:
    """Return the keys the checker writes — used by API masking helpers."""
    return _CACHED_KEYS
