"""Tests for version compatibility and auto-update check module."""

import json
import warnings
from unittest.mock import MagicMock, patch

import pytest

from dotpromptz.typing import ParsedPrompt
from dotpromptz.version import (
    _ADAPTERS,
    CURRENT_MAJOR,
    PromptVersionError,
    _parse_version_tuple,
    adapt_prompt,
    check_for_update,
    register_adapter,
)


def _make_prompt(*, version: int = 1) -> ParsedPrompt:
    """Create a minimal ParsedPrompt with the given version."""
    return ParsedPrompt(
        version=version,
        ext={},
        config=None,
        metadata={},
        tool_defs=None,
        template='Hello {{name}}',
    )


class TestCurrentMajor:
    """Sanity checks for the CURRENT_MAJOR constant."""

    def test_is_positive_int(self):
        assert isinstance(CURRENT_MAJOR, int)
        assert CURRENT_MAJOR >= 1


class TestAdaptPromptExactMatch:
    """Tests for prompts matching the current engine version."""

    def test_explicit_current_version_passes(self):
        prompt = _make_prompt(version=CURRENT_MAJOR)
        result = adapt_prompt(prompt)
        assert result is prompt  # no copy, same object

    def test_missing_version_defaults_to_1(self):
        prompt = _make_prompt(version=1)
        result = adapt_prompt(prompt)
        assert result is prompt


class TestAdaptPromptForwardIncompatible:
    """Tests for prompts newer than the engine."""

    def test_version_greater_than_current_raises(self):
        prompt = _make_prompt(version=CURRENT_MAJOR + 1)
        with pytest.raises(PromptVersionError) as exc_info:
            adapt_prompt(prompt)
        assert exc_info.value.prompt_version == CURRENT_MAJOR + 1
        assert exc_info.value.engine_version == CURRENT_MAJOR

    def test_far_future_version_raises(self):
        prompt = _make_prompt(version=CURRENT_MAJOR + 99)
        with pytest.raises(PromptVersionError):
            adapt_prompt(prompt)

    def test_error_message_contains_versions(self):
        prompt = _make_prompt(version=CURRENT_MAJOR + 1)
        with pytest.raises(PromptVersionError, match=rf'v{CURRENT_MAJOR + 1}\.x'):
            adapt_prompt(prompt)


class TestAdaptPromptBackwardCompatible:
    """Tests for prompts older than the engine (migration path)."""

    def test_no_adapter_registered_raises(self):
        """When no migration adapter exists, should raise."""
        prompt = _make_prompt(version=0)
        with pytest.raises(PromptVersionError):
            adapt_prompt(prompt)


class TestRegisterAdapter:
    """Tests for the adapter registration decorator."""

    def test_register_and_invoke(self):
        """Register a mock adapter and verify it gets called during migration."""
        # Temporarily bump CURRENT_MAJOR to simulate a v2 engine
        import dotpromptz.version as mod

        original_major = mod.CURRENT_MAJOR
        original_adapters = dict(_ADAPTERS)

        try:
            mod.CURRENT_MAJOR = 2

            @register_adapter(1)
            def _adapt_v1_to_v2(prompt: ParsedPrompt) -> ParsedPrompt:
                return prompt.model_copy(update={'version': 2})

            prompt = _make_prompt(version=1)
            with warnings.catch_warnings(record=True) as w:
                warnings.simplefilter('always')
                result = adapt_prompt(prompt)

            assert result.version == 2
            # Should have emitted a DeprecationWarning
            assert any(issubclass(warning.category, DeprecationWarning) for warning in w)
        finally:
            mod.CURRENT_MAJOR = original_major
            _ADAPTERS.clear()
            _ADAPTERS.update(original_adapters)

    def test_chain_migration(self):
        """Register v1→v2 and v2→v3 adapters and verify chain migration."""
        import dotpromptz.version as mod

        original_major = mod.CURRENT_MAJOR
        original_adapters = dict(_ADAPTERS)

        try:
            mod.CURRENT_MAJOR = 3

            @register_adapter(1)
            def _adapt_v1_to_v2(prompt: ParsedPrompt) -> ParsedPrompt:
                return prompt.model_copy(update={'version': 2})

            @register_adapter(2)
            def _adapt_v2_to_v3(prompt: ParsedPrompt) -> ParsedPrompt:
                return prompt.model_copy(update={'version': 3})

            prompt = _make_prompt(version=1)
            with warnings.catch_warnings(record=True) as w:
                warnings.simplefilter('always')
                result = adapt_prompt(prompt)

            assert result.version == 3
            # Should have emitted 2 deprecation warnings (v1→v2 and v2→v3)
            dep_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
            assert len(dep_warnings) == 2
        finally:
            mod.CURRENT_MAJOR = original_major
            _ADAPTERS.clear()
            _ADAPTERS.update(original_adapters)


class TestVersionInParsedPrompt:
    """Tests that the version field is properly parsed from frontmatter."""

    def test_version_field_parsed(self):
        from dotpromptz.parse import parse_document

        source = '---\nversion: 1\n---\nHello'
        result = parse_document(source)
        assert result.version == 1

    def test_no_version_field_defaults_to_1(self):
        from dotpromptz.parse import parse_document

        source = '---\nconfig:\n  model: gpt-4o\n---\nHello'
        result = parse_document(source)
        assert result.version == 1

    def test_version_not_in_ext(self):
        """version should be a reserved keyword, not ending up in ext."""
        from dotpromptz.parse import parse_document

        source = '---\nversion: 1\n---\nHello'
        result = parse_document(source)
        assert 'version' not in (result.ext or {})


class TestParseVersionTuple:
    """Tests for _parse_version_tuple helper."""

    def test_simple_version(self):
        assert _parse_version_tuple('1.8.0') == (1, 8, 0)

    def test_two_part_version(self):
        assert _parse_version_tuple('2.0') == (2, 0)

    def test_pre_release_suffix_stripped(self):
        assert _parse_version_tuple('2.0.0rc1') == (2, 0, 0)

    def test_dev_suffix_stripped(self):
        assert _parse_version_tuple('1.9.0.dev3') == (1, 9, 0)

    def test_single_number(self):
        assert _parse_version_tuple('3') == (3,)

    def test_comparison_newer(self):
        assert _parse_version_tuple('1.9.0') > _parse_version_tuple('1.8.0')

    def test_comparison_same(self):
        assert _parse_version_tuple('1.8.0') == _parse_version_tuple('1.8.0')

    def test_comparison_older(self):
        assert _parse_version_tuple('1.7.0') < _parse_version_tuple('1.8.0')

    def test_comparison_major_bump(self):
        assert _parse_version_tuple('2.0.0') > _parse_version_tuple('1.99.99')


class TestCheckForUpdate:
    """Tests for check_for_update."""

    @patch('dotpromptz.version.installed_version', return_value='1.8.0')
    @patch('dotpromptz.version.urllib.request.urlopen')
    def test_warns_when_newer_version_available(self, mock_urlopen, mock_installed):
        """Should log a warning when PyPI has a newer version."""
        pypi_response = json.dumps({'info': {'version': '2.0.0'}}).encode()
        mock_resp = MagicMock()
        mock_resp.read.return_value = pypi_response
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_resp

        with patch('dotpromptz.version.logger') as mock_logger:
            check_for_update()
            mock_logger.warning.assert_called_once()
            call_args = mock_logger.warning.call_args
            assert 'newer version' in call_args[0][0].lower()
            assert call_args[1]['current'] == '1.8.0'
            assert call_args[1]['latest'] == '2.0.0'

    @patch('dotpromptz.version.installed_version', return_value='1.8.0')
    @patch('dotpromptz.version.urllib.request.urlopen')
    def test_silent_when_up_to_date(self, mock_urlopen, mock_installed):
        """Should NOT warn when already on latest."""
        pypi_response = json.dumps({'info': {'version': '1.8.0'}}).encode()
        mock_resp = MagicMock()
        mock_resp.read.return_value = pypi_response
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_resp

        with patch('dotpromptz.version.logger') as mock_logger:
            check_for_update()
            mock_logger.warning.assert_not_called()

    @patch('dotpromptz.version.installed_version', return_value='2.0.0')
    @patch('dotpromptz.version.urllib.request.urlopen')
    def test_silent_when_ahead_of_pypi(self, mock_urlopen, mock_installed):
        """Should NOT warn when local version is ahead (dev build)."""
        pypi_response = json.dumps({'info': {'version': '1.8.0'}}).encode()
        mock_resp = MagicMock()
        mock_resp.read.return_value = pypi_response
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_resp

        with patch('dotpromptz.version.logger') as mock_logger:
            check_for_update()
            mock_logger.warning.assert_not_called()

    @patch('dotpromptz.version.installed_version', side_effect=Exception('not found'))
    def test_silent_on_package_not_found(self, mock_installed):
        """Should silently return if package metadata is unavailable."""
        # Replace the side_effect with PackageNotFoundError
        from importlib.metadata import PackageNotFoundError

        mock_installed.side_effect = PackageNotFoundError('dotpromptz-py')

        with patch('dotpromptz.version.logger') as mock_logger:
            check_for_update()  # should not raise
            mock_logger.warning.assert_not_called()

    @patch('dotpromptz.version.installed_version', return_value='1.8.0')
    @patch('dotpromptz.version.urllib.request.urlopen', side_effect=OSError('no network'))
    def test_silent_on_network_error(self, mock_urlopen, mock_installed):
        """Should silently return on network failures."""
        with patch('dotpromptz.version.logger') as mock_logger:
            check_for_update()  # should not raise
            mock_logger.warning.assert_not_called()

    @patch('dotpromptz.version.installed_version', return_value='1.8.0')
    @patch('dotpromptz.version.urllib.request.urlopen')
    def test_silent_on_malformed_json(self, mock_urlopen, mock_installed):
        """Should silently return if PyPI returns garbage."""
        mock_resp = MagicMock()
        mock_resp.read.return_value = b'not json'
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_resp

        with patch('dotpromptz.version.logger') as mock_logger:
            check_for_update()  # should not raise
            mock_logger.warning.assert_not_called()
