"""
Tests for cloud/registry/ — Agent Registry + Model Registry.

Covers:
    - Agent registration (validation, duplicate rejection, persistence)
    - Agent listing (filtering, sorting, pagination)
    - Agent lookup (get_agent, get_install_url, get_endpoint)
    - Usage recording (billing, commission, counters)
    - Validation edge cases (bad IDs, missing fields, URL injection)
    - Model registry (hardcoded catalog, task-based lookup)
    - AgentListing / AgentDetail data models
"""

import json
import time
from pathlib import Path

import pytest

from cloud.registry.agent_registry import (
    COMMISSION_RATE,
    AgentDetail,
    AgentListing,
    AgentRegistry,
    RegistryError,
    UsageRecord,
    _validate_agent_id,
    _validate_url,
)
from cloud.registry.model_registry import (
    ModelCapability,
    ModelProvider,
    ModelRegistry,
)


# ── Fixtures ─────────────────────────────────────────────────────────────


@pytest.fixture
def registry(tmp_path):
    """Fresh in-memory registry backed by a temp JSON file."""
    return AgentRegistry(store_path=tmp_path / "registry.json")


@pytest.fixture
def registry_no_persist():
    """In-memory only registry (no disk persistence)."""
    return AgentRegistry(store_path=None)


def _make_local_agent_def(**overrides) -> dict:
    """Build a valid local/open_source agent definition."""
    base = {
        "agent_id": "test-agent",
        "name": "Test Agent",
        "description": "A test agent for unit tests.",
        "category": "code",
        "type": "open_source",
        "install_type": "local",
        "package_url": "https://example.com/agent.tar.gz",
    }
    base.update(overrides)
    return base


def _make_remote_agent_def(**overrides) -> dict:
    """Build a valid remote/commercial agent definition."""
    base = {
        "agent_id": "acme-reviewer",
        "name": "ACME Contract Reviewer",
        "description": "AI-powered contract review.",
        "category": "legal",
        "type": "commercial",
        "install_type": "remote",
        "endpoint": "https://api.acme.com/v1/review",
        "price_per_call": 0.05,
    }
    base.update(overrides)
    return base


# ── 1. Agent ID Validation ──────────────────────────────────────────────


class TestAgentIdValidation:
    def test_valid_ids(self):
        for aid in ["my-agent", "agent123", "a1b2c3", "fin-analyzer-v2"]:
            assert _validate_agent_id(aid) is None

    def test_too_short(self):
        assert _validate_agent_id("ab") is not None

    def test_uppercase_rejected(self):
        assert _validate_agent_id("MyAgent") is not None

    def test_dots_rejected(self):
        assert _validate_agent_id("my.agent") is not None

    def test_leading_hyphen(self):
        assert _validate_agent_id("-bad") is not None

    def test_trailing_hyphen(self):
        assert _validate_agent_id("bad-") is not None

    def test_path_separator(self):
        assert _validate_agent_id("../evil") is not None

    def test_spaces(self):
        assert _validate_agent_id("my agent") is not None


# ── 2. URL Validation ───────────────────────────────────────────────────


class TestUrlValidation:
    def test_valid_https(self):
        assert _validate_url("https://example.com/path", "test") is None

    def test_http_rejected(self):
        assert _validate_url("http://example.com", "test") is not None

    def test_ip_literal_rejected(self):
        assert _validate_url("https://192.168.1.1/path", "test") is not None

    def test_no_scheme_rejected(self):
        assert _validate_url("example.com/path", "test") is not None

    def test_localhost_rejected(self):
        # Localhost as an IP would fail; "localhost" starts with letter so
        # the regex allows it. This is fine — remote agents need hostnames.
        pass


# ── 3. Agent Registration ───────────────────────────────────────────────


class TestRegisterAgent:
    def test_register_local_agent(self, registry):
        aid = registry.register_agent("creator-1", _make_local_agent_def())
        assert aid == "test-agent"
        detail = registry.get_agent("test-agent")
        assert detail.name == "Test Agent"
        assert detail.creator == "creator-1"
        assert detail.category == "code"
        assert detail.type == "open_source"
        assert detail.install_type == "local"
        assert detail.package_url == "https://example.com/agent.tar.gz"

    def test_register_remote_agent(self, registry):
        aid = registry.register_agent("creator-2", _make_remote_agent_def())
        assert aid == "acme-reviewer"
        detail = registry.get_agent("acme-reviewer")
        assert detail.type == "commercial"
        assert detail.install_type == "remote"
        assert detail.endpoint == "https://api.acme.com/v1/review"
        assert detail.price_per_call == 0.05

    def test_duplicate_id_rejected(self, registry):
        registry.register_agent("creator-1", _make_local_agent_def())
        with pytest.raises(RegistryError, match="already exists"):
            registry.register_agent("creator-2", _make_local_agent_def())

    def test_missing_required_field(self, registry):
        bad = _make_local_agent_def()
        del bad["name"]
        with pytest.raises(RegistryError, match="Missing required"):
            registry.register_agent("creator-1", bad)

    def test_invalid_category(self, registry):
        bad = _make_local_agent_def(category="nonsense")
        with pytest.raises(RegistryError, match="Invalid category"):
            registry.register_agent("creator-1", bad)

    def test_invalid_type(self, registry):
        bad = _make_local_agent_def(type="freeware")
        with pytest.raises(RegistryError, match="Invalid type"):
            registry.register_agent("creator-1", bad)

    def test_local_without_package_url(self, registry):
        bad = _make_local_agent_def()
        del bad["package_url"]
        with pytest.raises(RegistryError, match="package_url"):
            registry.register_agent("creator-1", bad)

    def test_remote_without_endpoint(self, registry):
        bad = _make_remote_agent_def()
        del bad["endpoint"]
        with pytest.raises(RegistryError, match="endpoint"):
            registry.register_agent("creator-1", bad)

    def test_commercial_without_price(self, registry):
        bad = _make_remote_agent_def(price_per_call=0.0)
        with pytest.raises(RegistryError, match="price_per_call"):
            registry.register_agent("creator-1", bad)

    def test_description_too_long(self, registry):
        bad = _make_local_agent_def(description="x" * 501)
        with pytest.raises(RegistryError, match="500 characters"):
            registry.register_agent("creator-1", bad)

    def test_invalid_agent_id(self, registry):
        bad = _make_local_agent_def(agent_id="../evil")
        with pytest.raises(RegistryError, match="Invalid agent_id"):
            registry.register_agent("creator-1", bad)

    def test_http_package_url_rejected(self, registry):
        bad = _make_local_agent_def(package_url="http://evil.com/pkg.tar.gz")
        with pytest.raises(RegistryError, match="HTTPS"):
            registry.register_agent("creator-1", bad)

    def test_timestamps_set(self, registry):
        before = time.time()
        registry.register_agent("creator-1", _make_local_agent_def())
        after = time.time()
        detail = registry.get_agent("test-agent")
        assert before <= detail.created_at <= after
        assert before <= detail.updated_at <= after


# ── 4. Agent Listing ────────────────────────────────────────────────────


class TestListAgents:
    def _populate(self, registry):
        registry.register_agent("c1", _make_local_agent_def(
            agent_id="alpha-agent", name="Alpha", category="code",
        ))
        registry.register_agent("c2", _make_remote_agent_def(
            agent_id="beta-agent", name="Beta", category="legal",
            price_per_call=0.10,
        ))
        registry.register_agent("c3", _make_local_agent_def(
            agent_id="gamma-agent", name="Gamma", category="code",
        ))

    def test_list_all(self, registry):
        self._populate(registry)
        agents = registry.list_agents()
        assert len(agents) == 3

    def test_filter_by_category(self, registry):
        self._populate(registry)
        agents = registry.list_agents(category="code")
        assert len(agents) == 2
        assert all(a.category == "code" for a in agents)

    def test_filter_by_type(self, registry):
        self._populate(registry)
        agents = registry.list_agents(agent_type="commercial")
        assert len(agents) == 1
        assert agents[0].type == "commercial"

    def test_sort_by_name(self, registry):
        self._populate(registry)
        agents = registry.list_agents(sort_by="name")
        names = [a.name for a in agents]
        assert names == sorted(names)

    def test_pagination(self, registry):
        self._populate(registry)
        page1 = registry.list_agents(limit=2, offset=0)
        page2 = registry.list_agents(limit=2, offset=2)
        assert len(page1) == 2
        assert len(page2) == 1
        # No overlap.
        ids1 = {a.agent_id for a in page1}
        ids2 = {a.agent_id for a in page2}
        assert ids1.isdisjoint(ids2)

    def test_limit_capped_at_100(self, registry):
        # Should not raise even if limit > 100.
        agents = registry.list_agents(limit=999)
        assert isinstance(agents, list)

    def test_invalid_sort_field(self, registry):
        with pytest.raises(RegistryError, match="Invalid sort_by"):
            registry.list_agents(sort_by="nonexistent")

    def test_invalid_category_filter(self, registry):
        with pytest.raises(RegistryError, match="Invalid category"):
            registry.list_agents(category="weapons")

    def test_returns_listings_not_details(self, registry):
        registry.register_agent("c1", _make_local_agent_def())
        agents = registry.list_agents()
        assert isinstance(agents[0], AgentListing)
        # AgentListing should NOT have internal-only fields.
        assert not hasattr(agents[0], "total_calls")


# ── 5. Agent Lookup ─────────────────────────────────────────────────────


class TestGetAgent:
    def test_get_existing(self, registry):
        registry.register_agent("c1", _make_local_agent_def())
        detail = registry.get_agent("test-agent")
        assert isinstance(detail, AgentDetail)
        assert detail.agent_id == "test-agent"

    def test_get_nonexistent(self, registry):
        with pytest.raises(RegistryError, match="not found"):
            registry.get_agent("ghost")


class TestGetInstallUrl:
    def test_local_agent(self, registry):
        registry.register_agent("c1", _make_local_agent_def())
        url = registry.get_install_url("test-agent")
        assert url == "https://example.com/agent.tar.gz"

    def test_remote_agent_rejected(self, registry):
        registry.register_agent("c1", _make_remote_agent_def())
        with pytest.raises(RegistryError, match="remote"):
            registry.get_install_url("acme-reviewer")

    def test_nonexistent(self, registry):
        with pytest.raises(RegistryError, match="not found"):
            registry.get_install_url("nobody")


class TestGetEndpoint:
    def test_remote_agent(self, registry):
        registry.register_agent("c1", _make_remote_agent_def())
        ep = registry.get_endpoint("acme-reviewer")
        assert ep == "https://api.acme.com/v1/review"

    def test_local_agent_rejected(self, registry):
        registry.register_agent("c1", _make_local_agent_def())
        with pytest.raises(RegistryError, match="local"):
            registry.get_endpoint("test-agent")


# ── 6. Usage Recording ──────────────────────────────────────────────────


class TestRecordUsage:
    def test_open_source_zero_cost(self, registry):
        registry.register_agent("c1", _make_local_agent_def())
        record = registry.record_usage("test-agent", "user-1", tokens=500)
        assert isinstance(record, UsageRecord)
        assert record.cost == 0.0
        assert record.commission == 0.0
        assert record.tokens == 500

    def test_commercial_with_commission(self, registry):
        registry.register_agent("c1", _make_remote_agent_def(price_per_call=1.00))
        record = registry.record_usage("acme-reviewer", "user-2", tokens=1000)
        assert record.cost == 1.00
        assert record.commission == pytest.approx(COMMISSION_RATE)  # 30% of $1.00

    def test_usage_increments_counters(self, registry):
        registry.register_agent("c1", _make_local_agent_def())
        registry.record_usage("test-agent", "user-1", tokens=100)
        registry.record_usage("test-agent", "user-2", tokens=200)
        detail = registry.get_agent("test-agent")
        assert detail.total_calls == 2
        assert detail.total_tokens == 300

    def test_nonexistent_agent(self, registry):
        with pytest.raises(RegistryError, match="not found"):
            registry.record_usage("ghost", "user-1", tokens=100)

    def test_usage_records_stored(self, registry):
        registry.register_agent("c1", _make_local_agent_def())
        registry.record_usage("test-agent", "user-1", tokens=50)
        assert len(registry.usage) == 1
        assert registry.usage[0].agent_id == "test-agent"

    def test_usage_record_has_unique_id(self, registry):
        registry.register_agent("c1", _make_local_agent_def())
        r1 = registry.record_usage("test-agent", "user-1", tokens=10)
        r2 = registry.record_usage("test-agent", "user-1", tokens=20)
        assert r1.record_id != r2.record_id


# ── 7. Install Counter ──────────────────────────────────────────────────


class TestIncrementInstalls:
    def test_increment(self, registry):
        registry.register_agent("c1", _make_local_agent_def())
        assert registry.get_agent("test-agent").installs == 0
        count = registry.increment_installs("test-agent")
        assert count == 1
        assert registry.get_agent("test-agent").installs == 1

    def test_increment_multiple(self, registry):
        registry.register_agent("c1", _make_local_agent_def())
        registry.increment_installs("test-agent")
        registry.increment_installs("test-agent")
        count = registry.increment_installs("test-agent")
        assert count == 3


# ── 8. Persistence ──────────────────────────────────────────────────────


class TestPersistence:
    def test_persist_and_reload(self, tmp_path):
        store = tmp_path / "reg.json"
        reg1 = AgentRegistry(store_path=store)
        reg1.register_agent("c1", _make_local_agent_def())
        reg1.register_agent("c2", _make_remote_agent_def())

        # Create a new instance from the same file.
        reg2 = AgentRegistry(store_path=store)
        assert len(reg2.agents) == 2
        assert "test-agent" in reg2.agents
        assert "acme-reviewer" in reg2.agents

    def test_corrupt_store_handled(self, tmp_path):
        store = tmp_path / "bad.json"
        store.write_text("{{{invalid json")
        # Should not raise — just start empty.
        reg = AgentRegistry(store_path=store)
        assert len(reg.agents) == 0


# ── 9. AgentDetail -> AgentListing Downcast ─────────────────────────────


class TestDetailToListing:
    def test_listing_has_public_fields(self, registry):
        registry.register_agent("c1", _make_local_agent_def())
        detail = registry.get_agent("test-agent")
        listing = detail.to_listing()
        assert listing.agent_id == detail.agent_id
        assert listing.name == detail.name
        assert listing.creator == detail.creator

    def test_listing_omits_internal_fields(self, registry):
        registry.register_agent("c1", _make_local_agent_def())
        detail = registry.get_agent("test-agent")
        listing = detail.to_listing()
        # AgentListing should not expose these.
        assert not hasattr(listing, "created_at") or not hasattr(listing, "total_calls")


# ── 10. Model Registry ──────────────────────────────────────────────────


class TestModelRegistry:
    def test_catalog_preloaded(self):
        reg = ModelRegistry()
        assert len(reg.models) >= 9

    def test_all_required_models_present(self):
        reg = ModelRegistry()
        required = [
            "llama3", "mistral", "phi3",
            "claude-sonnet", "claude-opus",
            "gemini-pro", "gemini-flash",
            "gpt-4o", "gpt-4o-mini",
        ]
        for mid in required:
            assert reg.get(mid) is not None, f"Model {mid} missing from catalog"

    def test_get_unknown_model(self):
        reg = ModelRegistry()
        assert reg.get("nonexistent-model") is None

    def test_get_for_task_returns_sorted(self):
        reg = ModelRegistry()
        coders = reg.get_for_task("code")
        assert len(coders) > 0
        # Verify descending order.
        scores = [m.task_affinities["code"] for m in coders]
        assert scores == sorted(scores, reverse=True)

    def test_get_for_task_excludes_zero_affinity(self):
        reg = ModelRegistry()
        # A made-up task type should return nothing.
        results = reg.get_for_task("underwater-basket-weaving")
        assert results == []

    def test_local_models_are_free(self):
        reg = ModelRegistry()
        for m in reg.list_local():
            assert m.cost_per_1k_input == 0.0
            assert m.cost_per_1k_output == 0.0

    def test_cloud_models_have_costs(self):
        reg = ModelRegistry()
        for m in reg.list_cloud():
            # At least one cost should be > 0.
            assert m.cost_per_1k_input > 0.0 or m.cost_per_1k_output > 0.0

    def test_list_local(self):
        reg = ModelRegistry()
        local = reg.list_local()
        assert all(m.provider == ModelProvider.LOCAL for m in local)
        assert len(local) == 3  # llama3, mistral, phi3

    def test_list_cloud(self):
        reg = ModelRegistry()
        cloud = reg.list_cloud()
        assert all(m.provider == ModelProvider.CLOUD_BYOK for m in cloud)
        assert len(cloud) == 6

    def test_list_all_sorted(self):
        reg = ModelRegistry()
        all_models = reg.list_all()
        ids = [m.model_id for m in all_models]
        assert ids == sorted(ids)

    def test_register_custom_model(self):
        reg = ModelRegistry(preload=False)
        assert len(reg.models) == 0
        reg.register(ModelCapability(
            model_id="custom-model",
            provider=ModelProvider.LOCAL,
            display_name="Custom",
            strengths="Testing",
            task_affinities={"test": 1.0},
            context_window=4096,
            output_limit=2048,
        ))
        assert reg.get("custom-model") is not None

    def test_claude_opus_highest_analysis(self):
        """Claude Opus should have the highest analysis affinity."""
        reg = ModelRegistry()
        analysts = reg.get_for_task("analysis")
        assert analysts[0].model_id == "claude-opus"

    def test_phi3_is_fastest(self):
        """Phi-3 should have the lowest latency."""
        reg = ModelRegistry()
        all_models = reg.list_all()
        fastest = min(all_models, key=lambda m: m.typical_latency_ms)
        assert fastest.model_id == "phi3"

    def test_each_model_has_strengths(self):
        reg = ModelRegistry()
        for m in reg.list_all():
            assert len(m.strengths) > 20, f"Model {m.model_id} has insufficient strengths description"

    def test_each_model_has_affinities(self):
        reg = ModelRegistry()
        for m in reg.list_all():
            assert len(m.task_affinities) >= 3, f"Model {m.model_id} has too few task affinities"

    def test_no_preload_creates_empty(self):
        reg = ModelRegistry(preload=False)
        assert len(reg.models) == 0
