"""Version compatibility and auto-update check for dotpromptz.

Ensures ``.prompt`` files written for older (or newer) major versions of
dotpromptz are handled correctly at runtime:

- **prompt version == engine version** → run as-is.
- **prompt version < engine version** → chain-migrate through registered
  adapters and emit a deprecation warning.
- **prompt version > engine version** → raise ``PromptVersionError``
  (the engine cannot understand a newer format).
- **prompt version missing** → default to ``CURRENT_MAJOR``.

Also queries the PyPI JSON API to determine whether a newer version of the
package is available and emits a warning via structlog if so.

The update check is best-effort: network errors, timeouts, and malformed
responses are silently ignored so they never block normal CLI operation.
"""

import json
import urllib.error
import urllib.request
import warnings
from collections.abc import Callable
from importlib.metadata import PackageNotFoundError, version as installed_version
from typing import TypeVar

import structlog

from dotpromptz.typing import ParsedPrompt

logger = structlog.get_logger(__name__)

T = TypeVar('T')

# ---------------------------------------------------------------------------
# Engine version constant
# ---------------------------------------------------------------------------

CURRENT_MAJOR: int = 1
"""The current major version of the dotpromptz engine.

This MUST be bumped whenever a backwards-incompatible change is made to the
``.prompt`` file format (frontmatter schema, template semantics, etc.).
"""

# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------


class PromptVersionError(Exception):
    """Raised when a ``.prompt`` file's version is incompatible with the engine."""

    def __init__(self, prompt_version: int, engine_version: int) -> None:
        """Initialize with prompt and engine version numbers."""
        self.prompt_version = prompt_version
        self.engine_version = engine_version
        super().__init__(
            f'.prompt file requires dotpromptz v{prompt_version}.x, '
            f'but the current engine is v{engine_version}.x. '
            f'Please upgrade dotpromptz to a compatible version.'
        )


# ---------------------------------------------------------------------------
# Adapter registry
# ---------------------------------------------------------------------------

VersionAdapter = Callable[[ParsedPrompt], ParsedPrompt]
"""Signature for a function that migrates a ``ParsedPrompt`` from version *N*
to version *N + 1*.
"""

_ADAPTERS: dict[int, VersionAdapter] = {}
"""Registry mapping source major version → adapter that upgrades to the next."""


def register_adapter(from_version: int) -> Callable[[VersionAdapter], VersionAdapter]:
    """Decorator to register a version adapter.

    Usage::

        @register_adapter(1)
        def _adapt_v1_to_v2(prompt: ParsedPrompt) -> ParsedPrompt:
            # transform v1 frontmatter fields to v2 equivalents
            ...
            return updated_prompt

    Args:
        from_version: The source major version this adapter migrates *from*.
            The target version is implicitly ``from_version + 1``.

    Returns:
        A decorator that registers the adapter function.
    """

    def _decorator(fn: VersionAdapter) -> VersionAdapter:
        _ADAPTERS[from_version] = fn
        return fn

    return _decorator


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def adapt_prompt(prompt: ParsedPrompt) -> ParsedPrompt:
    """Validate and adapt a parsed prompt to the current engine version.

    Args:
        prompt: The parsed prompt (as returned by ``parse_document``).

    Returns:
        The (possibly migrated) ``ParsedPrompt`` compatible with the
        current engine version.

    Raises:
        PromptVersionError: If the prompt's version is higher than the
            engine's current major version (forward-incompatible).
        PromptVersionError: If no migration path exists between the
            prompt's version and the current engine version.
    """
    prompt_version: int = prompt.version

    # --- Forward-incompatible: prompt is newer than engine ---
    if prompt_version > CURRENT_MAJOR:
        raise PromptVersionError(prompt_version, CURRENT_MAJOR)

    # --- Exact match: nothing to do ---
    if prompt_version == CURRENT_MAJOR:
        return prompt

    # --- Backward-compatible: chain-migrate through adapters ---
    for v in range(prompt_version, CURRENT_MAJOR):
        adapter = _ADAPTERS.get(v)
        if adapter is None:
            raise PromptVersionError(
                prompt_version,
                CURRENT_MAJOR,
            )
        warnings.warn(
            f'Migrating .prompt from v{v} to v{v + 1} format. Consider updating the file to version {CURRENT_MAJOR}.',
            DeprecationWarning,
            stacklevel=2,
        )
        logger.info('Migrating .prompt format.', from_version=v, to_version=v + 1)
        prompt = adapter(prompt)

    return prompt


# ---------------------------------------------------------------------------
# Auto-update check
# ---------------------------------------------------------------------------

_PYPI_PACKAGE_NAME = 'dotpromptz-py'
_PYPI_URL = f'https://pypi.org/pypi/{_PYPI_PACKAGE_NAME}/json'
_TIMEOUT_SECONDS = 3


def _parse_version_tuple(version_str: str) -> tuple[int, ...]:
    """Parse a PEP-440 version string into a comparable tuple of ints.

    Only numeric segments are considered (pre-release suffixes are ignored).

    Args:
        version_str: A version string like ``"1.8.0"`` or ``"2.0.0rc1"``.

    Returns:
        A tuple of integers, e.g. ``(1, 8, 0)``.
    """
    parts: list[int] = []
    for segment in version_str.split('.'):
        # Strip non-numeric suffixes (e.g. "0rc1" → "0").
        numeric = ''
        for ch in segment:
            if ch.isdigit():
                numeric += ch
            else:
                break
        if numeric:
            parts.append(int(numeric))
    return tuple(parts)


def check_for_update() -> None:
    """Check PyPI for a newer version and warn if one is available.

    This function is designed to be called at CLI startup.  It is
    fully best-effort: any failure (network, parsing, missing metadata)
    is silently swallowed so it never interferes with normal operation.
    """
    try:
        current = installed_version(_PYPI_PACKAGE_NAME)
    except PackageNotFoundError:
        # Running from source or editable install without metadata.
        return

    try:
        req = urllib.request.Request(_PYPI_URL, headers={'Accept': 'application/json'})
        with urllib.request.urlopen(req, timeout=_TIMEOUT_SECONDS) as resp:
            data = json.loads(resp.read())
        latest: str = data['info']['version']
    except Exception:  # noqa: BLE001  — intentionally broad
        return

    if _parse_version_tuple(latest) > _parse_version_tuple(current):
        logger.warning(
            'A newer version of dotpromptz-py is available.',
            current=current,
            latest=latest,
            upgrade_command='uv tool upgrade dotpromptz-py',
        )
