"""Tests for GodotIQ PRO licensing gate with Polar.sh validation."""

from __future__ import annotations

import json
import time

import pytest

from godotiq.license import (
    PRO_TOOLS,
    _CACHE_TTL_SECONDS,
    _POLAR_DEFAULT_ORG_ID,
    _POLAR_PROD_URL,
    _POLAR_SANDBOX_URL,
    _TEASER_BUILDERS,
    _UPGRADE_SUFFIX,
    _hash_key,
    _polar_org_id,
    _polar_validate_url,
    _reset_license_cache,
    gate_pro,
    is_pro,
)


@pytest.fixture(autouse=True)
def _clean_license_state(monkeypatch):
    """Reset in-process license cache and clear env vars before each test."""
    _reset_license_cache()
    monkeypatch.delenv("GODOTIQ_LICENSE", raising=False)
    monkeypatch.delenv("GODOTIQ_LICENSE_KEY", raising=False)
    monkeypatch.delenv("GODOTIQ_POLAR_SANDBOX", raising=False)
    monkeypatch.delenv("GODOTIQ_POLAR_ORG_ID", raising=False)
    yield
    _reset_license_cache()


@pytest.fixture
def cache_dir(tmp_path, monkeypatch):
    """Redirect license cache to a temp directory."""
    cache_file = tmp_path / "license_cache.json"
    import godotiq.license as lic
    monkeypatch.setattr(lic, "_CACHE_FILE", cache_file)
    monkeypatch.setattr(lic, "_CACHE_DIR", tmp_path)
    return tmp_path, cache_file


@pytest.fixture
def mock_polar_valid(monkeypatch):
    """Mock _validate_polar to return True (valid key)."""
    import godotiq.license as lic
    monkeypatch.setattr(lic, "_validate_polar", lambda key: True)


@pytest.fixture
def mock_polar_invalid(monkeypatch):
    """Mock _validate_polar to return False (invalid key)."""
    import godotiq.license as lic
    monkeypatch.setattr(lic, "_validate_polar", lambda key: False)


@pytest.fixture
def mock_polar_unreachable(monkeypatch):
    """Mock _validate_polar to raise (API unreachable)."""
    import godotiq.license as lic

    def _unreachable(key):
        raise ConnectionError("Polar API unreachable")

    monkeypatch.setattr(lic, "_validate_polar", _unreachable)


# ── is_pro() — dev override ──────────────────────────────────────────


class TestIsProDevOverride:
    def test_dev_override_pro(self, monkeypatch):
        monkeypatch.setenv("GODOTIQ_LICENSE", "pro")
        assert is_pro() is True

    def test_dev_override_case_insensitive(self, monkeypatch):
        monkeypatch.setenv("GODOTIQ_LICENSE", "PRO")
        assert is_pro() is True

    def test_dev_override_ignores_invalid(self, monkeypatch):
        monkeypatch.setenv("GODOTIQ_LICENSE", "community")
        assert is_pro() is False

    def test_community_when_no_env(self):
        assert is_pro() is False


# ── is_pro() — Polar key validation ──────────────────────────────────


class TestIsProPolarValidation:
    def test_valid_key_returns_true(self, monkeypatch, mock_polar_valid, cache_dir):
        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-XXXXX-XXXXX")
        assert is_pro() is True

    def test_invalid_key_returns_false(self, monkeypatch, mock_polar_invalid, cache_dir):
        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-INVALID-KEY")
        assert is_pro() is False

    def test_empty_key_returns_false(self, monkeypatch, cache_dir):
        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "")
        assert is_pro() is False

    def test_whitespace_key_returns_false(self, monkeypatch, cache_dir):
        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "   ")
        assert is_pro() is False

    def test_no_key_returns_false(self, cache_dir):
        assert is_pro() is False

    def test_dev_override_skips_polar(self, monkeypatch, mock_polar_unreachable, cache_dir):
        """Dev override should work even if Polar API would fail."""
        monkeypatch.setenv("GODOTIQ_LICENSE", "pro")
        assert is_pro() is True


# ── Disk cache ────────────────────────────────────────────────────────


class TestLicenseCache:
    def test_valid_result_cached_to_disk(self, monkeypatch, mock_polar_valid, cache_dir):
        _, cache_file = cache_dir
        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-VALID-KEY")
        assert is_pro() is True
        # Cache file should exist
        assert cache_file.exists()
        data = json.loads(cache_file.read_text())
        assert data["valid"] is True
        assert data["key_hash"] == _hash_key("POLAR-VALID-KEY")

    def test_invalid_result_cached_to_disk(self, monkeypatch, mock_polar_invalid, cache_dir):
        _, cache_file = cache_dir
        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-BAD-KEY")
        assert is_pro() is False
        assert cache_file.exists()
        data = json.loads(cache_file.read_text())
        assert data["valid"] is False

    def test_fresh_cache_skips_api(self, monkeypatch, mock_polar_unreachable, cache_dir):
        """Fresh cache (< 24h) should be used without hitting the API."""
        _, cache_file = cache_dir
        key = "POLAR-CACHED-KEY"
        # Pre-populate fresh cache
        cache = {
            "key_hash": _hash_key(key),
            "valid": True,
            "validated_at": time.time(),  # now = fresh
        }
        cache_file.write_text(json.dumps(cache))
        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", key)
        # API is unreachable but cache is fresh — should still return True
        assert is_pro() is True

    def test_expired_cache_triggers_api(self, monkeypatch, mock_polar_valid, cache_dir):
        """Expired cache should trigger API re-validation."""
        _, cache_file = cache_dir
        key = "POLAR-EXPIRED-KEY"
        # Pre-populate expired cache (invalid result, 25h ago)
        cache = {
            "key_hash": _hash_key(key),
            "valid": False,
            "validated_at": time.time() - _CACHE_TTL_SECONDS - 3600,
        }
        cache_file.write_text(json.dumps(cache))
        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", key)
        # API says valid → should override expired cache
        assert is_pro() is True

    def test_wrong_key_hash_triggers_api(self, monkeypatch, mock_polar_valid, cache_dir):
        """Cache for a different key should not be used."""
        _, cache_file = cache_dir
        # Cache for a different key
        cache = {
            "key_hash": _hash_key("POLAR-OTHER-KEY"),
            "valid": True,
            "validated_at": time.time(),
        }
        cache_file.write_text(json.dumps(cache))
        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-NEW-KEY")
        # API is called for the new key
        assert is_pro() is True

    def test_missing_cache_file_ok(self, monkeypatch, mock_polar_valid, cache_dir):
        _, cache_file = cache_dir
        assert not cache_file.exists()
        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-FIRST-TIME")
        assert is_pro() is True

    def test_corrupt_cache_triggers_api(self, monkeypatch, mock_polar_valid, cache_dir):
        _, cache_file = cache_dir
        cache_file.write_text("not json!")
        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-CORRUPT")
        assert is_pro() is True


# ── Offline fallback ──────────────────────────────────────────────────


class TestOfflineFallback:
    def test_api_down_uses_expired_cache(self, monkeypatch, mock_polar_unreachable, cache_dir):
        """When API is unreachable, expired cache should serve as fallback."""
        _, cache_file = cache_dir
        key = "POLAR-OFFLINE-KEY"
        # Expired but valid cache
        cache = {
            "key_hash": _hash_key(key),
            "valid": True,
            "validated_at": time.time() - _CACHE_TTL_SECONDS - 7200,
        }
        cache_file.write_text(json.dumps(cache))
        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", key)
        assert is_pro() is True

    def test_api_down_no_cache_returns_false(self, monkeypatch, mock_polar_unreachable, cache_dir):
        """No cache + API unreachable = community."""
        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-NO-CACHE")
        assert is_pro() is False

    def test_api_down_wrong_cache_returns_false(self, monkeypatch, mock_polar_unreachable, cache_dir):
        """Expired cache for different key + API down = community."""
        _, cache_file = cache_dir
        cache = {
            "key_hash": _hash_key("POLAR-DIFFERENT"),
            "valid": True,
            "validated_at": time.time() - _CACHE_TTL_SECONDS - 3600,
        }
        cache_file.write_text(json.dumps(cache))
        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-MINE")
        assert is_pro() is False


# ── In-process cache ──────────────────────────────────────────────────


class TestInProcessCache:
    def test_result_cached_in_memory(self, monkeypatch, cache_dir):
        """Second call should use in-process cache, not re-validate."""
        import godotiq.license as lic
        call_count = 0

        def counting_validate(key):
            nonlocal call_count
            call_count += 1
            return True

        monkeypatch.setattr(lic, "_validate_polar", counting_validate)
        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-MEMORY-TEST")
        assert is_pro() is True
        assert call_count == 1
        # Second call uses in-process cache — no additional validation
        assert is_pro() is True
        assert call_count == 1  # still 1, no re-validation

    def test_reset_clears_cache(self, monkeypatch, mock_polar_valid, cache_dir):
        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-RESET-TEST")
        assert is_pro() is True
        _reset_license_cache()
        # After clearing env + cache, should be community
        monkeypatch.delenv("GODOTIQ_LICENSE_KEY")
        assert is_pro() is False


# ── Sandbox / org ID configuration ────────────────────────────────────


class TestPolarSandboxConfig:
    def test_default_url_is_production(self):
        assert _polar_validate_url() == _POLAR_PROD_URL

    def test_sandbox_true_uses_sandbox_url(self, monkeypatch):
        monkeypatch.setenv("GODOTIQ_POLAR_SANDBOX", "true")
        assert _polar_validate_url() == _POLAR_SANDBOX_URL

    def test_sandbox_true_case_insensitive(self, monkeypatch):
        monkeypatch.setenv("GODOTIQ_POLAR_SANDBOX", "True")
        assert _polar_validate_url() == _POLAR_SANDBOX_URL

    def test_sandbox_false_uses_prod_url(self, monkeypatch):
        monkeypatch.setenv("GODOTIQ_POLAR_SANDBOX", "false")
        assert _polar_validate_url() == _POLAR_PROD_URL

    def test_default_org_id(self):
        assert _polar_org_id() == _POLAR_DEFAULT_ORG_ID

    def test_custom_org_id(self, monkeypatch):
        monkeypatch.setenv("GODOTIQ_POLAR_ORG_ID", "my-sandbox-org")
        assert _polar_org_id() == "my-sandbox-org"

    def test_empty_org_id_falls_back_to_default(self, monkeypatch):
        monkeypatch.setenv("GODOTIQ_POLAR_ORG_ID", "")
        assert _polar_org_id() == _POLAR_DEFAULT_ORG_ID

    def test_whitespace_org_id_falls_back_to_default(self, monkeypatch):
        monkeypatch.setenv("GODOTIQ_POLAR_ORG_ID", "   ")
        assert _polar_org_id() == _POLAR_DEFAULT_ORG_ID


# ── PRO_TOOLS set ─────────────────────────────────────────────────────


class TestProToolsSet:
    def test_contains_13_tools(self):
        assert len(PRO_TOOLS) == 13

    def test_all_prefixed(self):
        for name in PRO_TOOLS:
            assert name.startswith("godotiq_")

    def test_known_pro_tools(self):
        expected = {
            "godotiq_project_summary",
            "godotiq_file_context",
            "godotiq_dependency_graph",
            "godotiq_validate",
            "godotiq_signal_map",
            "godotiq_trace_flow",
            "godotiq_impact_check",
            "godotiq_animation_audit",
            "godotiq_asset_registry",
            "godotiq_scene_map",
            "godotiq_spatial_audit",
            "godotiq_suggest_scale",
            "godotiq_placement",
        }
        assert PRO_TOOLS == expected

    def test_free_tools_not_in_set(self):
        free = [
            "godotiq_ping",
            "godotiq_screenshot",
            "godotiq_node_ops",
            "godotiq_exec",
            "godotiq_run",
            "godotiq_animation_info",
            "godotiq_verify_motion",
        ]
        for name in free:
            assert name not in PRO_TOOLS


# ── Teaser builders ───────────────────────────────────────────────────

# Mock result dicts matching each tool's actual return shape.

_MOCK_RESULTS: dict[str, dict] = {
    "godotiq_project_summary": {
        "counts": {"scripts": 42, "scenes": 18, "assets": 200},
        "autoloads": ["GameManager", "AudioManager", "SaveManager"],
    },
    "godotiq_file_context": {
        "public_api": {"functions": [{"name": "f1"}, {"name": "f2"}]},
        "signals_defined": ["sig1", "sig2", "sig3"],
        "depended_by": ["a.gd", "b.gd"],
    },
    "godotiq_dependency_graph": {
        "emits_signals": [{"signal_name": "s1"}, {"signal_name": "s2"}],
        "referenced_by": ["x.gd", "y.gd", "z.gd"],
        "impact_rating": "high",
    },
    "godotiq_validate": {
        "total_issues": 14,
        "by_severity": {"error": 0, "warning": 6, "info": 8},
    },
    "godotiq_signal_map": {
        "total_signals_defined": 23,
        "total_connections": 47,
        "orphans": [{"name": "orphan1"}, {"name": "orphan2"}, {"name": "orphan3"}],
    },
    "godotiq_trace_flow": {
        "total_steps": 8,
        "files_involved": 6,
        "failure_points": [{"step": 2}, {"step": 5}],
    },
    "godotiq_impact_check": {
        "risk": "high",
        "total_files_affected": 5,
        "callers": [{"file": "a.gd"}, {"file": "b.gd"}, {"file": "c.gd"}],
    },
    "godotiq_animation_audit": {
        "total_animations": 36,
        "total_issues": 5,
        "by_severity": {"critical": 1, "warning": 3, "info": 1},
    },
    "godotiq_asset_registry": {
        "total_assets": 184,
        "unused_count": 23,
        "by_category": {"models": 30, "textures": 80, "audio": 20, "scenes": 18, "scripts": 30, "other": 6},
    },
    "godotiq_scene_map": {
        "total_nodes": 47,
        "spatial_summary": {
            "bounds": {"min": [-6, 0, -4], "max": [6, 4, 4]},
        },
    },
    "godotiq_spatial_audit": {
        "total_issues": 7,
        "by_severity": {"warning": 4, "info": 3},
        "checks_run": ["floating_objects", "scale_mismatch", "z_fighting",
                       "empty_markers", "overlapping", "extreme_positions"],
    },
    "godotiq_suggest_scale": {
        "reference_models": [{"name": "m1"}, {"name": "m2"}, {"name": "m3"}],
        "match_tier": "same_directory",
    },
    "godotiq_placement": {
        "candidates_evaluated": 48,
        "suggestions": [{"pos": [1, 0, 1]}, {"pos": [2, 0, 2]}, {"pos": [3, 0, 3]}],
        "excluded_zones": [{"reason": "door"}, {"reason": "wall"}],
    },
}


class TestTeaserBuilders:
    """Each teaser builder extracts real counts and produces a readable string."""

    def test_all_pro_tools_have_builders(self):
        for tool_name in PRO_TOOLS:
            assert tool_name in _TEASER_BUILDERS, f"Missing teaser builder for {tool_name}"

    @pytest.mark.parametrize("tool_name", sorted(PRO_TOOLS))
    def test_teaser_is_string(self, tool_name):
        builder = _TEASER_BUILDERS[tool_name]
        result = builder(_MOCK_RESULTS[tool_name])
        assert isinstance(result, str)
        assert len(result) > 10  # not trivially empty

    def test_project_summary_teaser(self):
        t = _TEASER_BUILDERS["godotiq_project_summary"](_MOCK_RESULTS["godotiq_project_summary"])
        assert "42 scripts" in t
        assert "18 scenes" in t
        assert "3 autoloads" in t

    def test_file_context_teaser(self):
        t = _TEASER_BUILDERS["godotiq_file_context"](_MOCK_RESULTS["godotiq_file_context"])
        assert "2 public functions" in t
        assert "3 signals" in t
        assert "2 scripts" in t

    def test_dependency_graph_teaser(self):
        t = _TEASER_BUILDERS["godotiq_dependency_graph"](_MOCK_RESULTS["godotiq_dependency_graph"])
        assert "2 signals emitted" in t
        assert "3 reverse refs" in t
        assert "high" in t

    def test_validate_teaser(self):
        t = _TEASER_BUILDERS["godotiq_validate"](_MOCK_RESULTS["godotiq_validate"])
        assert "14 issues" in t
        assert "0 errors" in t
        assert "6 warnings" in t

    def test_signal_map_teaser(self):
        t = _TEASER_BUILDERS["godotiq_signal_map"](_MOCK_RESULTS["godotiq_signal_map"])
        assert "23 signals" in t
        assert "47 connections" in t
        assert "3 orphans" in t

    def test_trace_flow_teaser(self):
        t = _TEASER_BUILDERS["godotiq_trace_flow"](_MOCK_RESULTS["godotiq_trace_flow"])
        assert "8 steps" in t
        assert "6 files" in t
        assert "2 failure points" in t

    def test_impact_check_teaser(self):
        t = _TEASER_BUILDERS["godotiq_impact_check"](_MOCK_RESULTS["godotiq_impact_check"])
        assert "risk: high" in t
        assert "5 files" in t
        assert "3 callers" in t

    def test_animation_audit_teaser(self):
        t = _TEASER_BUILDERS["godotiq_animation_audit"](_MOCK_RESULTS["godotiq_animation_audit"])
        assert "36 animations" in t
        assert "1 critical" in t
        assert "3 warnings" in t

    def test_asset_registry_teaser(self):
        t = _TEASER_BUILDERS["godotiq_asset_registry"](_MOCK_RESULTS["godotiq_asset_registry"])
        assert "184 assets" in t
        assert "23 unused" in t
        assert "6 categories" in t

    def test_scene_map_teaser(self):
        t = _TEASER_BUILDERS["godotiq_scene_map"](_MOCK_RESULTS["godotiq_scene_map"])
        assert "47 nodes" in t
        assert "12x4x8m" in t

    def test_spatial_audit_teaser(self):
        t = _TEASER_BUILDERS["godotiq_spatial_audit"](_MOCK_RESULTS["godotiq_spatial_audit"])
        assert "7 issues" in t
        assert "4 warnings" in t
        assert "6 checks" in t

    def test_suggest_scale_teaser(self):
        t = _TEASER_BUILDERS["godotiq_suggest_scale"](_MOCK_RESULTS["godotiq_suggest_scale"])
        assert "3 reference models" in t
        assert "same_directory" in t

    def test_placement_teaser(self):
        t = _TEASER_BUILDERS["godotiq_placement"](_MOCK_RESULTS["godotiq_placement"])
        assert "48 positions" in t
        assert "3 valid" in t
        assert "2 excluded" in t

    def test_builder_handles_empty_result(self):
        """All builders degrade gracefully on empty/missing keys."""
        for tool_name, builder in _TEASER_BUILDERS.items():
            result = builder({})
            assert isinstance(result, str)


# ── gate_pro() ────────────────────────────────────────────────────────


class TestGatePro:
    def test_pro_user_gets_full_result(self, monkeypatch):
        monkeypatch.setenv("GODOTIQ_LICENSE", "pro")
        result = {"total_issues": 5, "issues": [1, 2, 3, 4, 5]}
        assert gate_pro("godotiq_validate", result) is result

    def test_community_user_gets_teaser(self):
        result = _MOCK_RESULTS["godotiq_validate"]
        gated = gate_pro("godotiq_validate", result)
        assert gated["tier"] == "community"
        assert gated["tool"] == "godotiq_validate"
        assert "14 issues" in gated["teaser"]
        assert "godotiq.com/pro" in gated["teaser"]

    def test_community_error_passes_through(self):
        error_result = {"error": "No Godot project loaded"}
        assert gate_pro("godotiq_validate", error_result) is error_result

    def test_free_tool_always_passes_through(self):
        result = {"status": "ok"}
        assert gate_pro("godotiq_ping", result) is result

    def test_teaser_has_upgrade_suffix(self):
        result = _MOCK_RESULTS["godotiq_trace_flow"]
        gated = gate_pro("godotiq_trace_flow", result)
        assert gated["teaser"].endswith("godotiq.com/pro")

    @pytest.mark.parametrize("tool_name", sorted(PRO_TOOLS))
    def test_all_pro_tools_gated_for_community(self, tool_name):
        result = _MOCK_RESULTS[tool_name]
        gated = gate_pro(tool_name, result)
        assert gated["tier"] == "community"
        assert "teaser" in gated

    @pytest.mark.parametrize("tool_name", sorted(PRO_TOOLS))
    def test_all_pro_tools_pass_for_pro(self, monkeypatch, tool_name):
        monkeypatch.setenv("GODOTIQ_LICENSE", "pro")
        result = _MOCK_RESULTS[tool_name]
        assert gate_pro(tool_name, result) is result

    def test_polar_key_unlocks_pro_tools(self, monkeypatch, mock_polar_valid, cache_dir):
        monkeypatch.setenv("GODOTIQ_LICENSE_KEY", "POLAR-REAL-KEY")
        result = _MOCK_RESULTS["godotiq_validate"]
        assert gate_pro("godotiq_validate", result) is result
