"""Tests for MixLift MCP license validation.

Covers:
- Free tier defaults
- API-based validation (valid/invalid keys)
- B7: Fail-closed behavior with 24hr cached grace period
"""

import json
import time
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from mixlift_mcp.license import (
    CACHE_TTL_SECONDS,
    LicenseStatus,
    free_tier,
    validate_license,
)


class TestFreeTier:
    def test_free_tier_defaults(self):
        status = free_tier()
        assert status.is_pro is False
        assert status.max_channels == 2
        assert status.max_rows == 2000
        assert status.message == "Free tier"

    def test_free_tier_custom_message(self):
        status = free_tier("custom msg")
        assert status.message == "custom msg"
        assert status.is_pro is False


class TestValidateLicense:
    @pytest.mark.asyncio
    async def test_none_key_returns_free(self):
        result = await validate_license(None)
        assert result.is_pro is False
        assert result.max_channels == 2
        assert "no license" in result.message.lower()

    @pytest.mark.asyncio
    async def test_empty_key_returns_free(self):
        result = await validate_license("")
        assert result.is_pro is False
        assert result.max_channels == 2

    @pytest.mark.asyncio
    async def test_valid_pro_key(self, tmp_path):
        mock_response = MagicMock()
        mock_response.json.return_value = {"valid": True, "tier": "pro"}
        mock_response.raise_for_status = MagicMock()

        mock_client = AsyncMock()
        mock_client.post.return_value = mock_response
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)

        cache_file = tmp_path / "license_cache.json"
        with (
            patch("mixlift_mcp.license.httpx.AsyncClient", return_value=mock_client),
            patch("mixlift_mcp.license.CACHE_FILE", cache_file),
            patch("mixlift_mcp.license.CACHE_DIR", tmp_path),
        ):
            result = await validate_license("valid-pro-key-123")

        assert result.is_pro is True
        assert result.max_channels == 8
        assert result.max_rows == 50_000
        assert "pro" in result.message.lower()
        mock_client.post.assert_called_once()

        # Cache should have been written
        assert cache_file.exists()
        cache = json.loads(cache_file.read_text())
        assert cache["license_key"] == "valid-pro-key-123"
        assert cache["valid"] is True

    @pytest.mark.asyncio
    async def test_invalid_key_returns_free(self, tmp_path):
        mock_response = MagicMock()
        mock_response.json.return_value = {"valid": False}
        mock_response.raise_for_status = MagicMock()

        mock_client = AsyncMock()
        mock_client.post.return_value = mock_response
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)

        cache_file = tmp_path / "license_cache.json"
        with (
            patch("mixlift_mcp.license.httpx.AsyncClient", return_value=mock_client),
            patch("mixlift_mcp.license.CACHE_FILE", cache_file),
            patch("mixlift_mcp.license.CACHE_DIR", tmp_path),
        ):
            result = await validate_license("bad-key")

        assert result.is_pro is False
        assert result.max_channels == 2
        assert "invalid" in result.message.lower()

    @pytest.mark.asyncio
    async def test_network_error_no_cache_returns_free(self, tmp_path):
        """API unreachable + no cache = fail closed (free tier)."""
        mock_client = AsyncMock()
        mock_client.post.side_effect = Exception("Connection refused")
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)

        cache_file = tmp_path / "license_cache.json"
        with (
            patch("mixlift_mcp.license.httpx.AsyncClient", return_value=mock_client),
            patch("mixlift_mcp.license.CACHE_FILE", cache_file),
            patch("mixlift_mcp.license.CACHE_DIR", tmp_path),
        ):
            result = await validate_license("some-key")

        assert result.is_pro is False
        assert "unavailable" in result.message.lower()

    @pytest.mark.asyncio
    async def test_valid_growth_key(self, tmp_path):
        """Growth tier should also be accepted as pro."""
        mock_response = MagicMock()
        mock_response.json.return_value = {"valid": True, "tier": "growth"}
        mock_response.raise_for_status = MagicMock()

        mock_client = AsyncMock()
        mock_client.post.return_value = mock_response
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)

        cache_file = tmp_path / "license_cache.json"
        with (
            patch("mixlift_mcp.license.httpx.AsyncClient", return_value=mock_client),
            patch("mixlift_mcp.license.CACHE_FILE", cache_file),
            patch("mixlift_mcp.license.CACHE_DIR", tmp_path),
        ):
            result = await validate_license("valid-growth-key-123")

        assert result.is_pro is True
        assert result.max_channels == 8
        assert result.max_rows == 50_000


# ---------------------------------------------------------------------------
# B7: Fail-closed with 24hr cached grace period
# ---------------------------------------------------------------------------


class TestFailClosedCaching:
    """Verify fail-closed license validation with 24hr cached grace period."""

    @pytest.mark.asyncio
    async def test_api_down_fresh_cache_honors_pro(self, tmp_path):
        """API unreachable + fresh valid cache (<24h) = honor cached pro tier."""
        cache_file = tmp_path / "license_cache.json"
        # Write a fresh cache entry
        cache_data = {
            "license_key": "MIXLIFT-cached-key",
            "tier": "pro",
            "valid": True,
            "cached_at": time.time(),  # Fresh — just now
        }
        cache_file.write_text(json.dumps(cache_data))

        mock_client = AsyncMock()
        mock_client.post.side_effect = Exception("Connection timeout")
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)

        with (
            patch("mixlift_mcp.license.httpx.AsyncClient", return_value=mock_client),
            patch("mixlift_mcp.license.CACHE_FILE", cache_file),
            patch("mixlift_mcp.license.CACHE_DIR", tmp_path),
        ):
            result = await validate_license("MIXLIFT-cached-key")

        assert result.is_pro is True
        assert "grace period" in result.message.lower()

    @pytest.mark.asyncio
    async def test_api_down_stale_cache_blocks_pro(self, tmp_path):
        """API unreachable + stale cache (>24h) = fail closed to free tier."""
        cache_file = tmp_path / "license_cache.json"
        # Write a stale cache entry (25 hours old)
        cache_data = {
            "license_key": "MIXLIFT-stale-key",
            "tier": "pro",
            "valid": True,
            "cached_at": time.time() - (25 * 60 * 60),  # 25 hours ago
        }
        cache_file.write_text(json.dumps(cache_data))

        mock_client = AsyncMock()
        mock_client.post.side_effect = Exception("Connection timeout")
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)

        with (
            patch("mixlift_mcp.license.httpx.AsyncClient", return_value=mock_client),
            patch("mixlift_mcp.license.CACHE_FILE", cache_file),
            patch("mixlift_mcp.license.CACHE_DIR", tmp_path),
        ):
            result = await validate_license("MIXLIFT-stale-key")

        assert result.is_pro is False
        assert "unavailable" in result.message.lower()

    @pytest.mark.asyncio
    async def test_api_down_cache_for_different_key_ignored(self, tmp_path):
        """Cache for a different key should not be used."""
        cache_file = tmp_path / "license_cache.json"
        cache_data = {
            "license_key": "MIXLIFT-other-key",
            "tier": "pro",
            "valid": True,
            "cached_at": time.time(),
        }
        cache_file.write_text(json.dumps(cache_data))

        mock_client = AsyncMock()
        mock_client.post.side_effect = Exception("Connection timeout")
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)

        with (
            patch("mixlift_mcp.license.httpx.AsyncClient", return_value=mock_client),
            patch("mixlift_mcp.license.CACHE_FILE", cache_file),
            patch("mixlift_mcp.license.CACHE_DIR", tmp_path),
        ):
            result = await validate_license("MIXLIFT-different-key")

        assert result.is_pro is False

    @pytest.mark.asyncio
    async def test_api_down_cache_invalid_key_blocks_pro(self, tmp_path):
        """Cache with valid=False should NOT grant pro access."""
        cache_file = tmp_path / "license_cache.json"
        cache_data = {
            "license_key": "MIXLIFT-revoked-key",
            "tier": "free",
            "valid": False,
            "cached_at": time.time(),
        }
        cache_file.write_text(json.dumps(cache_data))

        mock_client = AsyncMock()
        mock_client.post.side_effect = Exception("Connection timeout")
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)

        with (
            patch("mixlift_mcp.license.httpx.AsyncClient", return_value=mock_client),
            patch("mixlift_mcp.license.CACHE_FILE", cache_file),
            patch("mixlift_mcp.license.CACHE_DIR", tmp_path),
        ):
            result = await validate_license("MIXLIFT-revoked-key")

        assert result.is_pro is False

    @pytest.mark.asyncio
    async def test_successful_validation_updates_cache(self, tmp_path):
        """A successful API validation should write/update the cache."""
        cache_file = tmp_path / "license_cache.json"

        mock_response = MagicMock()
        mock_response.json.return_value = {"valid": True, "tier": "pro"}
        mock_response.raise_for_status = MagicMock()

        mock_client = AsyncMock()
        mock_client.post.return_value = mock_response
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)

        with (
            patch("mixlift_mcp.license.httpx.AsyncClient", return_value=mock_client),
            patch("mixlift_mcp.license.CACHE_FILE", cache_file),
            patch("mixlift_mcp.license.CACHE_DIR", tmp_path),
        ):
            result = await validate_license("MIXLIFT-new-pro-key")

        assert result.is_pro is True
        assert cache_file.exists()

        cache = json.loads(cache_file.read_text())
        assert cache["license_key"] == "MIXLIFT-new-pro-key"
        assert cache["valid"] is True
        assert cache["tier"] == "pro"
        assert abs(cache["cached_at"] - time.time()) < 5  # Written recently

    @pytest.mark.asyncio
    async def test_invalid_key_caches_invalid(self, tmp_path):
        """An explicitly invalid key should cache valid=False to prevent
        stale positive cache from granting access after key revocation."""
        cache_file = tmp_path / "license_cache.json"
        # Pre-populate with a valid cache
        old_cache = {
            "license_key": "MIXLIFT-revoked",
            "tier": "pro",
            "valid": True,
            "cached_at": time.time(),
        }
        cache_file.write_text(json.dumps(old_cache))

        mock_response = MagicMock()
        mock_response.json.return_value = {"valid": False, "tier": "free"}
        mock_response.raise_for_status = MagicMock()

        mock_client = AsyncMock()
        mock_client.post.return_value = mock_response
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)

        with (
            patch("mixlift_mcp.license.httpx.AsyncClient", return_value=mock_client),
            patch("mixlift_mcp.license.CACHE_FILE", cache_file),
            patch("mixlift_mcp.license.CACHE_DIR", tmp_path),
        ):
            result = await validate_license("MIXLIFT-revoked")

        assert result.is_pro is False
        # Cache should now show valid=False
        cache = json.loads(cache_file.read_text())
        assert cache["valid"] is False
