from unittest.mock import Mock, call

import httpx
import pytest

from layerlens.models import CustomModel, PublicModel, ModelsResponse, CreateModelResponse
from layerlens._constants import DEFAULT_TIMEOUT
from layerlens.resources.models.models import Models


class TestModels:
    """Test Models resource API methods."""

    @pytest.fixture
    def mock_client(self):
        """Mock Stratix client."""
        client = Mock()
        client.organization_id = "org-123"
        client.project_id = "proj-456"
        client.get_cast = Mock()
        return client

    @pytest.fixture
    def models_resource(self, mock_client):
        """Models resource instance."""
        return Models(mock_client)

    @pytest.fixture
    def sample_model_data(self):
        """Sample model data for testing."""
        return {
            "id": "model-123",
            "key": "gpt-4",
            "name": "GPT-4",
            "company": "OpenAI",
            "description": "Large language model",
            "released_at": 1679875200,
            "parameters": 1.76e12,
            "modality": "text",
            "context_length": 8192,
            "architecture_type": "transformer",
            "license": "proprietary",
            "open_weights": False,
            "region": "us-east-1",
            "deprecated": False,
        }

    @pytest.fixture
    def sample_custom_model_data(self):
        """Sample custom model data for testing."""
        return {
            "id": "custom-model-456",
            "key": "my-model",
            "name": "My Custom Model",
            "description": "Custom model description",
            "max_tokens": 4096,
            "api_url": "https://api.example.com/v1/chat",
            "disabled": False,
        }

    @pytest.fixture
    def mock_public_models_response(self, sample_model_data):
        """Mock ModelsResponse response with public models."""
        model = PublicModel(**sample_model_data)
        return ModelsResponse(data=ModelsResponse.Data(models=[model]))

    @pytest.fixture
    def mock_custom_models_response(self, sample_custom_model_data):
        """Mock ModelsResponse response with custom models."""
        custom_model = CustomModel(**sample_custom_model_data)
        return ModelsResponse(data=ModelsResponse.Data(models=[custom_model]))

    def test_models_initialization(self, mock_client):
        """Models resource initializes correctly."""
        models = Models(mock_client)

        assert models._client is mock_client
        assert models._get is mock_client.get_cast

    def test_get_public_models_success(self, models_resource, mock_public_models_response):
        """get method returns public models successfully."""
        models_resource._get.side_effect = lambda *_, **kwargs: (
            mock_public_models_response
            if kwargs.get("params", {}).get("type") == "public"
            else ModelsResponse(data=ModelsResponse.Data(models=[]))
        )

        result = models_resource.get()

        assert isinstance(result, list)
        assert len(result) == 1
        assert isinstance(result[0], PublicModel)
        assert result[0].name == "GPT-4"
        assert result[0].key == "gpt-4"
        assert result[0].company == "OpenAI"

    def test_get_custom_models_success(self, models_resource, mock_custom_models_response):
        """get method returns custom models successfully."""
        models_resource._get.side_effect = lambda *_, **kwargs: (
            mock_custom_models_response
            if kwargs.get("params", {}).get("type") == "custom"
            else ModelsResponse(data=ModelsResponse.Data(models=[]))
        )

        result = models_resource.get()

        assert isinstance(result, list)
        assert len(result) == 1
        assert isinstance(result[0], CustomModel)
        assert result[0].name == "My Custom Model"
        assert result[0].key == "my-model"
        assert result[0].api_url == "https://api.example.com/v1/chat"

    def test_get_models_request_parameters_public(self, models_resource, mock_public_models_response):
        """get method makes correct API request for public models."""
        models_resource._get.return_value = mock_public_models_response

        models_resource.get()

        expected_calls = [
            call(
                "/organizations/org-123/projects/proj-456/models",
                params={"type": "custom"},
                timeout=DEFAULT_TIMEOUT,
                cast_to=ModelsResponse,
            ),
            call(
                "/organizations/org-123/projects/proj-456/models",
                params={"type": "public"},
                timeout=DEFAULT_TIMEOUT,
                cast_to=ModelsResponse,
            ),
        ]

        models_resource._get.assert_has_calls(expected_calls)

    def test_get_models_request_parameters_custom(self, models_resource, mock_custom_models_response):
        """get method makes correct API request for custom models."""
        models_resource._get.return_value = mock_custom_models_response

        models_resource.get()

        expected_calls = [
            call(
                "/organizations/org-123/projects/proj-456/models",
                params={"type": "custom"},
                timeout=DEFAULT_TIMEOUT,
                cast_to=ModelsResponse,
            ),
            call(
                "/organizations/org-123/projects/proj-456/models",
                params={"type": "public"},
                timeout=DEFAULT_TIMEOUT,
                cast_to=ModelsResponse,
            ),
        ]

        models_resource._get.assert_has_calls(expected_calls)

    def test_get_models_with_custom_timeout(self, models_resource, mock_public_models_response):
        """get method accepts custom timeout."""
        models_resource._get.return_value = mock_public_models_response
        custom_timeout = 60.0

        models_resource.get(timeout=custom_timeout)

        call_args = models_resource._get.call_args
        assert call_args.kwargs["timeout"] == custom_timeout

    def test_get_models_with_httpx_timeout(self, models_resource, mock_public_models_response):
        """get method accepts httpx.Timeout object."""
        models_resource._get.return_value = mock_public_models_response
        custom_timeout = httpx.Timeout(60.0)

        models_resource.get(timeout=custom_timeout)

        call_args = models_resource._get.call_args
        assert call_args.kwargs["timeout"] is custom_timeout

    @pytest.mark.parametrize(
        "mock_response, expected",
        [
            (None, []),  # None response
            ("invalid-response", []),  # Invalid type
            (
                ModelsResponse(data=ModelsResponse.Data(models=[])),
                [],
            ),  # Empty ModelsResponse
        ],
    )
    def test_get_models_responses(self, models_resource, mock_response, expected):
        """get method handles different types of responses correctly."""
        models_resource._get.return_value = mock_response

        result = models_resource.get()

        assert result == expected
        if isinstance(mock_response, ModelsResponse):
            assert isinstance(result, list)

    def test_get_models_multiple_items(self, models_resource, sample_model_data):
        """get method returns multiple models correctly."""
        model1 = PublicModel(**sample_model_data)

        # Create second model with different data
        model2_data = sample_model_data.copy()
        model2_data["id"] = "model-456"
        model2_data["key"] = "gpt-3.5-turbo"
        model2_data["name"] = "GPT-3.5 Turbo"
        model2_data["parameters"] = 1.75e11
        model2 = PublicModel(**model2_data)

        response = ModelsResponse(data=ModelsResponse.Data(models=[model1, model2]))

        models_resource._get.side_effect = lambda *_, **kwargs: (
            response
            if kwargs.get("params", {}).get("type") == "public"
            else ModelsResponse(data=ModelsResponse.Data(models=[]))
        )

        result = models_resource.get()

        assert len(result) == 2
        assert result[0].key == "gpt-4"
        assert result[1].key == "gpt-3.5-turbo"
        assert result[0].parameters == 1.76e12
        assert result[1].parameters == 1.75e11

    def test_get_models_url_construction(self, models_resource, mock_public_models_response):
        """get method constructs URL correctly with org and project IDs."""
        models_resource._client.organization_id = "custom-org"
        models_resource._client.project_id = "custom-project"
        models_resource._get.return_value = mock_public_models_response

        models_resource.get()

        expected_url = "/organizations/custom-org/projects/custom-project/models"
        call_args = models_resource._get.call_args
        assert call_args[0][0] == expected_url

    def test_get_models_cast_to_parameter(self, models_resource, mock_public_models_response):
        """get method specifies correct cast_to parameter."""
        models_resource._get.side_effect = lambda *_, **kwargs: (
            mock_public_models_response
            if kwargs.get("params", {}).get("type") == "public"
            else ModelsResponse(data=ModelsResponse.Data(models=[]))
        )

        models_resource.get()

        call_args = models_resource._get.call_args
        assert call_args.kwargs["cast_to"] is ModelsResponse

    def test_get_models_timeout_default(self, models_resource, mock_public_models_response):
        """get method uses DEFAULT_TIMEOUT when no timeout specified."""
        models_resource._get.return_value = mock_public_models_response

        models_resource.get()

        call_args = models_resource._get.call_args
        assert call_args.kwargs["timeout"] is DEFAULT_TIMEOUT

    def test_get_models_with_none_timeout(self, models_resource, mock_public_models_response):
        """get method accepts None timeout."""
        models_resource._get.return_value = mock_public_models_response

        models_resource.get(timeout=None)

        call_args = models_resource._get.call_args
        assert call_args.kwargs["timeout"] is None

    def test_get_models_model_attributes(self, models_resource, mock_public_models_response):
        """get method preserves all model attributes correctly."""
        models_resource._get.side_effect = lambda *_, **kwargs: (
            mock_public_models_response
            if kwargs.get("params", {}).get("type") == "public"
            else ModelsResponse(data=ModelsResponse.Data(models=[]))
        )

        result = models_resource.get()
        model = result[0]

        assert model.context_length == 8192
        assert model.open_weights is False
        assert model.deprecated is False
        assert model.region == "us-east-1"
        assert model.license == "proprietary"
        assert model.architecture_type == "transformer"
        assert model.modality == "text"

    def test_get_models_custom_model_attributes(self, models_resource, mock_custom_models_response):
        """get method preserves all custom model attributes correctly."""
        models_resource._get.return_value = mock_custom_models_response

        result = models_resource.get()
        custom_model = result[0]

        assert custom_model.max_tokens == 4096
        assert custom_model.disabled is False
        assert custom_model.api_url == "https://api.example.com/v1/chat"

    def test_get_by_id_custom_benchmark(self, models_resource, sample_custom_model_data):
        """get_by_id returns CustomModel when organization_id is present."""
        sample_custom_model_data["organization_id"] = "org-123"  # Required for type detection
        models_resource._get.return_value = {"data": sample_custom_model_data}

        result = models_resource.get_by_id("custom-model-456")

        assert isinstance(result, CustomModel)
        assert result.id == "custom-model-456"
        assert result.name == "My Custom Model"

    def test_get_by_id_public_benchmark(self, models_resource, sample_model_data):
        """get_by_id returns PublicModel when organization_id is missing."""
        models_resource._get.return_value = {"data": sample_model_data}

        result = models_resource.get_by_id("model-123")

        assert isinstance(result, PublicModel)
        assert result.id == "model-123"
        assert result.name == "GPT-4"

    def test_get_by_id_invalid_response(self, models_resource):
        """get_by_id returns None for invalid responses."""
        models_resource._get.return_value = "not-a-dict"

        result = models_resource.get_by_id("invalid")

        assert result is None

    def test_get_by_key_custom_model(self, models_resource, sample_custom_model_data):
        """get_by_key returns CustomModel when key matches and organization_id is present."""
        sample_custom_model_data["organization_id"] = "org-123"
        custom_model = CustomModel(**sample_custom_model_data)
        models_resource.get = Mock(return_value=[custom_model])

        result = models_resource.get_by_key(key="my-model")

        assert isinstance(result, CustomModel)
        assert result.key == "my-model"
        assert result.name == "My Custom Model"

    def test_get_by_key_public_model(self, models_resource, sample_model_data):
        """get_by_key returns PublicModel when key matches and organization_id is missing."""
        public_model = PublicModel(**sample_model_data)
        models_resource.get = Mock(return_value=[public_model])

        result = models_resource.get_by_key(key="gpt-4")

        assert isinstance(result, PublicModel)
        assert result.key == "gpt-4"
        assert result.name == "GPT-4"

    def test_get_by_key_no_match(self, models_resource, sample_model_data):
        """get_by_key returns None if no model has the exact key."""
        public_model = PublicModel(**sample_model_data)
        models_resource.get = Mock(return_value=[public_model])

        result = models_resource.get_by_key(key="nonexistent-key")

        assert result is None

    def test_get_by_key_invalid_response(self, models_resource):
        """get_by_key returns None when get() returns None or invalid type."""
        models_resource.get = Mock(return_value=None)

        result = models_resource.get_by_key(key="some-key")

        assert result is None


class TestModelsErrorHandling:
    """Test error handling in Models resource."""

    @pytest.fixture
    def mock_client(self):
        """Mock Stratix client."""
        client = Mock()
        client.organization_id = "org-123"
        client.project_id = "proj-456"
        client.get_cast = Mock()
        return client

    @pytest.fixture
    def models_resource(self, mock_client):
        """Models resource instance."""
        return Models(mock_client)

    def test_get_models_handles_api_error(self, models_resource):
        """get method propagates API errors."""
        from layerlens._exceptions import APIStatusError

        mock_response = Mock()
        mock_response.status_code = 500
        mock_response.headers = {}

        api_error = APIStatusError("Internal Server Error", response=mock_response, body=None)
        models_resource._get.side_effect = api_error

        with pytest.raises(APIStatusError):
            models_resource.get()

    def test_get_models_handles_forbidden_error(self, models_resource):
        """get method propagates permission errors."""
        from layerlens._exceptions import PermissionDeniedError

        mock_response = Mock()
        mock_response.status_code = 403
        mock_response.headers = {}

        permission_error = PermissionDeniedError("Forbidden", response=mock_response, body=None)
        models_resource._get.side_effect = permission_error

        with pytest.raises(PermissionDeniedError):
            models_resource.get()

    def test_get_models_handles_connection_error(self, models_resource):
        """get method propagates connection errors."""
        from layerlens._exceptions import APIConnectionError

        mock_request = Mock()
        connection_error = APIConnectionError(request=mock_request)
        models_resource._get.side_effect = connection_error

        with pytest.raises(APIConnectionError):
            models_resource.get()

    def test_get_models_handles_timeout_error(self, models_resource):
        """get method propagates timeout errors."""
        from layerlens._exceptions import APITimeoutError

        mock_request = Mock()
        timeout_error = APITimeoutError(mock_request)
        models_resource._get.side_effect = timeout_error

        with pytest.raises(APITimeoutError):
            models_resource.get(timeout=5.0)


class TestModelsTyping:
    """Test type handling in Models resource."""

    @pytest.fixture
    def mock_client(self):
        """Mock Stratix client."""
        client = Mock()
        client.organization_id = "org-123"
        client.project_id = "proj-456"
        client.get_cast = Mock()
        return client

    @pytest.fixture
    def models_resource(self, mock_client):
        """Models resource instance."""
        return Models(mock_client)

    @pytest.mark.parametrize(
        "mock_response, expected_type",
        [
            (None, list),  # None response
            (
                ModelsResponse(data=ModelsResponse.Data(models=[])),
                list,
            ),  # Empty ModelsResponse
        ],
    )
    def test_get_models_return_type_consistency(self, models_resource, mock_response, expected_type):
        """get method returns consistent types."""
        models_resource._get.return_value = mock_response

        result = models_resource.get()

        assert isinstance(result, expected_type)

    def test_get_models_mixed_model_types(self, models_resource):
        """get method can handle mixed model types in response."""
        # Create mixed response with both Model and CustomModel
        public_data = {
            "id": "public-123",
            "key": "gpt-4",
            "name": "GPT-4",
            "company": "OpenAI",
            "description": "Public model",
            "released_at": 1679875200,
            "parameters": 1.76e12,
            "modality": "text",
            "context_length": 8192,
            "architecture_type": "transformer",
            "license": "proprietary",
            "open_weights": False,
            "region": "us-east-1",
            "deprecated": False,
        }

        custom_data = {
            "id": "custom-456",
            "key": "my-model",
            "name": "My Custom Model",
            "description": "Custom model",
            "max_tokens": 4096,
            "api_url": "https://api.example.com/v1/chat",
            "disabled": False,
        }

        public_model = PublicModel(**public_data)
        custom_model = CustomModel(**custom_data)

        models_resource._get.side_effect = lambda *_, **kwargs: (
            ModelsResponse(data=ModelsResponse.Data(models=[public_model]))
            if kwargs.get("params", {}).get("type") == "public"
            else ModelsResponse(data=ModelsResponse.Data(models=[custom_model]))
        )

        result = models_resource.get()  # Type doesn't matter for this test

        assert len(result) == 2
        assert isinstance(result[0], CustomModel)
        assert isinstance(result[1], PublicModel)
        assert result[0].key == "my-model"
        assert result[1].key == "gpt-4"
        assert hasattr(result[0], "max_tokens")  # CustomModel-specific attribute
        assert hasattr(result[1], "parameters")  # PublicModel-specific attribute

    def test_get_models_large_parameters_handling(self, models_resource):
        """get method handles large parameter numbers correctly."""
        large_model_data = {
            "id": "large-model",
            "key": "claude-3-opus",
            "name": "Claude 3 Opus",
            "company": "Anthropic",
            "description": "Very large language model",
            "released_at": 1709251200,
            "parameters": 1.3e14,  # 130 trillion parameters
            "modality": "text",
            "context_length": 200000,
            "architecture_type": "transformer",
            "license": "proprietary",
            "open_weights": False,
            "region": "us-west-2",
            "deprecated": False,
        }

        large_model = PublicModel(**large_model_data)
        response = ModelsResponse(data=ModelsResponse.Data(models=[large_model]))
        models_resource._get.side_effect = lambda *_, **kwargs: (
            response
            if kwargs.get("params", {}).get("type") == "public"
            else ModelsResponse(data=ModelsResponse.Data(models=[]))
        )

        result = models_resource.get()

        assert len(result) == 1
        assert result[0].parameters == 1.3e14
        assert result[0].context_length == 200000
        assert isinstance(result[0].parameters, float)
        assert isinstance(result[0].context_length, int)


class TestModelsAdd:
    """Test Models.add() method."""

    @pytest.fixture
    def mock_client(self):
        client = Mock()
        client.organization_id = "org-123"
        client.project_id = "proj-456"
        client.get_cast = Mock()
        client.patch_cast = Mock()
        return client

    @pytest.fixture
    def models_resource(self, mock_client):
        return Models(mock_client)

    def test_add_single_model(self, models_resource):
        """add() merges new ID with current models and PATCHes."""
        existing = PublicModel(id="m1", key="m1", name="M1", description="")
        models_resource.get = Mock(return_value=[existing])
        models_resource._patch.return_value = {"id": "proj-456"}

        result = models_resource.add("m2")

        assert result is True
        models_resource._patch.assert_called_once_with(
            "/organizations/org-123/projects/proj-456",
            body={"models": ["m1", "m2"]},
            timeout=DEFAULT_TIMEOUT,
            cast_to=dict,
        )

    def test_add_multiple_models(self, models_resource):
        """add() handles multiple model IDs."""
        models_resource.get = Mock(return_value=[])
        models_resource._patch.return_value = {"id": "proj-456"}

        result = models_resource.add("m1", "m2", "m3")

        assert result is True
        call_body = models_resource._patch.call_args.kwargs["body"]
        assert call_body == {"models": ["m1", "m2", "m3"]}

    def test_add_deduplicates(self, models_resource):
        """add() deduplicates IDs already in the project."""
        existing = PublicModel(id="m1", key="m1", name="M1", description="")
        models_resource.get = Mock(return_value=[existing])
        models_resource._patch.return_value = {"id": "proj-456"}

        models_resource.add("m1", "m2")

        call_body = models_resource._patch.call_args.kwargs["body"]
        assert call_body == {"models": ["m1", "m2"]}

    def test_add_returns_false_on_failure(self, models_resource):
        """add() returns False when PATCH fails."""
        models_resource.get = Mock(return_value=[])
        models_resource._patch.return_value = "error"

        result = models_resource.add("m1")

        assert result is False

    def test_add_with_none_get_response(self, models_resource):
        """add() handles None from get() gracefully."""
        models_resource.get = Mock(return_value=None)
        models_resource._patch.return_value = {"id": "proj-456"}

        result = models_resource.add("m1")

        assert result is True
        call_body = models_resource._patch.call_args.kwargs["body"]
        assert call_body == {"models": ["m1"]}


class TestModelsRemove:
    """Test Models.remove() method."""

    @pytest.fixture
    def mock_client(self):
        client = Mock()
        client.organization_id = "org-123"
        client.project_id = "proj-456"
        client.get_cast = Mock()
        client.patch_cast = Mock()
        return client

    @pytest.fixture
    def models_resource(self, mock_client):
        return Models(mock_client)

    def test_remove_single_model(self, models_resource):
        """remove() removes specified ID and PATCHes remaining."""
        m1 = PublicModel(id="m1", key="m1", name="M1", description="")
        m2 = PublicModel(id="m2", key="m2", name="M2", description="")
        models_resource.get = Mock(return_value=[m1, m2])
        models_resource._patch.return_value = {"id": "proj-456"}

        result = models_resource.remove("m1")

        assert result is True
        call_body = models_resource._patch.call_args.kwargs["body"]
        assert call_body == {"models": ["m2"]}

    def test_remove_multiple_models(self, models_resource):
        """remove() handles removing multiple IDs."""
        m1 = PublicModel(id="m1", key="m1", name="M1", description="")
        m2 = PublicModel(id="m2", key="m2", name="M2", description="")
        m3 = PublicModel(id="m3", key="m3", name="M3", description="")
        models_resource.get = Mock(return_value=[m1, m2, m3])
        models_resource._patch.return_value = {"id": "proj-456"}

        models_resource.remove("m1", "m3")

        call_body = models_resource._patch.call_args.kwargs["body"]
        assert call_body == {"models": ["m2"]}

    def test_remove_nonexistent_id(self, models_resource):
        """remove() ignores IDs that aren't in the project."""
        m1 = PublicModel(id="m1", key="m1", name="M1", description="")
        models_resource.get = Mock(return_value=[m1])
        models_resource._patch.return_value = {"id": "proj-456"}

        models_resource.remove("nonexistent")

        call_body = models_resource._patch.call_args.kwargs["body"]
        assert call_body == {"models": ["m1"]}

    def test_remove_returns_false_on_failure(self, models_resource):
        """remove() returns False when PATCH fails."""
        models_resource.get = Mock(return_value=[])
        models_resource._patch.return_value = None

        result = models_resource.remove("m1")

        assert result is False


class TestModelsCreateCustom:
    """Test Models.create_custom() method."""

    @pytest.fixture
    def mock_client(self):
        client = Mock()
        client.organization_id = "org-123"
        client.project_id = "proj-456"
        client.get_cast = Mock()
        client.post_cast = Mock()
        return client

    @pytest.fixture
    def models_resource(self, mock_client):
        return Models(mock_client)

    def test_create_custom_success_with_envelope(self, models_resource):
        """create_custom() unwraps envelope and returns CreateModelResponse."""
        models_resource._post.return_value = {
            "status": "success",
            "data": {
                "model_id": "new-model-123",
                "organization_id": "org-123",
                "project_id": "proj-456",
            },
        }

        result = models_resource.create_custom(
            name="Test Model",
            key="test/model-v1",
            description="A test model",
            api_url="https://api.example.com/v1",
            max_tokens=4096,
        )

        assert isinstance(result, CreateModelResponse)
        assert result.model_id == "new-model-123"
        assert result.organization_id == "org-123"
        assert result.project_id == "proj-456"

    def test_create_custom_success_without_envelope(self, models_resource):
        """create_custom() works when response has no envelope."""
        models_resource._post.return_value = {
            "model_id": "new-model-123",
            "organization_id": "org-123",
            "project_id": "proj-456",
        }

        result = models_resource.create_custom(
            name="Test Model",
            key="test/model-v1",
            description="A test model",
            api_url="https://api.example.com/v1",
            max_tokens=4096,
        )

        assert isinstance(result, CreateModelResponse)
        assert result.model_id == "new-model-123"

    def test_create_custom_sends_correct_body(self, models_resource):
        """create_custom() sends all required fields in the request body."""
        models_resource._post.return_value = {
            "status": "success",
            "data": {"model_id": "x", "organization_id": "o", "project_id": "p"},
        }

        models_resource.create_custom(
            name="My Model",
            key="my/model",
            description="desc",
            api_url="https://example.com/v1",
            max_tokens=8192,
            api_key="sk-secret",
        )

        call_kwargs = models_resource._post.call_args.kwargs
        assert call_kwargs["body"] == {
            "name": "My Model",
            "key": "my/model",
            "description": "desc",
            "api_url": "https://example.com/v1",
            "max_tokens": 8192,
            "api_key": "sk-secret",
        }

    def test_create_custom_omits_api_key_when_none(self, models_resource):
        """create_custom() does not include api_key when not provided."""
        models_resource._post.return_value = {
            "status": "success",
            "data": {"model_id": "x", "organization_id": "o", "project_id": "p"},
        }

        models_resource.create_custom(
            name="My Model",
            key="my/model",
            description="desc",
            api_url="https://example.com/v1",
            max_tokens=4096,
        )

        call_body = models_resource._post.call_args.kwargs["body"]
        assert "api_key" not in call_body

    def test_create_custom_includes_extra_payload_when_provided(self, models_resource):
        """create_custom() forwards extra_payload verbatim."""
        models_resource._post.return_value = {
            "status": "success",
            "data": {"model_id": "x", "organization_id": "o", "project_id": "p"},
        }

        payload = {"top_p": 0.9, "provider": {"order": ["anthropic"]}}
        models_resource.create_custom(
            name="My Model",
            key="my/model",
            description="desc",
            api_url="https://example.com/v1/chat/completions",
            max_tokens=4096,
            extra_payload=payload,
        )

        call_body = models_resource._post.call_args.kwargs["body"]
        assert call_body["extra_payload"] == payload

    def test_create_custom_omits_extra_payload_when_none(self, models_resource):
        """create_custom() does not include extra_payload when not provided."""
        models_resource._post.return_value = {
            "status": "success",
            "data": {"model_id": "x", "organization_id": "o", "project_id": "p"},
        }

        models_resource.create_custom(
            name="My Model",
            key="my/model",
            description="desc",
            api_url="https://example.com/v1/chat/completions",
            max_tokens=4096,
        )

        call_body = models_resource._post.call_args.kwargs["body"]
        assert "extra_payload" not in call_body

    def test_create_custom_correct_url(self, models_resource):
        """create_custom() posts to the correct endpoint."""
        models_resource._post.return_value = {
            "status": "success",
            "data": {"model_id": "x", "organization_id": "o", "project_id": "p"},
        }

        models_resource.create_custom(
            name="M",
            key="k",
            description="d",
            api_url="https://x.com",
            max_tokens=1,
        )

        call_args = models_resource._post.call_args
        assert call_args[0][0] == "/organizations/org-123/projects/proj-456/custom-models"

    def test_create_custom_returns_none_on_failure(self, models_resource):
        """create_custom() returns None when response is unexpected."""
        models_resource._post.return_value = "not-a-dict"

        result = models_resource.create_custom(
            name="M",
            key="k",
            description="d",
            api_url="https://x.com",
            max_tokens=1,
        )

        assert result is None

    def test_create_custom_returns_none_on_error_envelope(self, models_resource):
        """create_custom() returns None when response has no model_id."""
        models_resource._post.return_value = {"status": "error", "data": {"message": "failed"}}

        result = models_resource.create_custom(
            name="M",
            key="k",
            description="d",
            api_url="https://x.com",
            max_tokens=1,
        )

        assert result is None


class TestModelsClientSideFiltering:
    """Test client-side filtering for models (fixes API not filtering custom objects)."""

    @pytest.fixture
    def mock_client(self):
        client = Mock()
        client.organization_id = "org-123"
        client.project_id = "proj-456"
        client.get_cast = Mock()
        return client

    @pytest.fixture
    def models_resource(self, mock_client):
        return Models(mock_client)

    @pytest.fixture
    def public_openai(self):
        return PublicModel(
            id="pub-1",
            key="gpt-4",
            name="GPT-4",
            description="OpenAI model",
            company="OpenAI",
            architecture_type="Transformer",
            open_weights=False,
            region="us-east-1",
            license="proprietary",
        )

    @pytest.fixture
    def public_meta(self):
        return PublicModel(
            id="pub-2",
            key="llama-3",
            name="Llama 3",
            description="Meta model",
            company="Meta",
            architecture_type="Transformer",
            open_weights=True,
            region="us-west-2",
            license="llama-community",
        )

    @pytest.fixture
    def public_mistral(self):
        return PublicModel(
            id="pub-3",
            key="mixtral",
            name="Mixtral",
            description="Mistral MoE",
            company="Mistral",
            architecture_type="MoE",
            open_weights=True,
            region="eu-west-1",
            license="apache-2.0",
        )

    @pytest.fixture
    def custom_model(self):
        return CustomModel(
            id="custom-1",
            key="my-model",
            name="My Custom Model",
            description="Custom",
            max_tokens=4096,
        )

    def _mock_responses(self, resource, custom_list, public_list):
        """Helper to set up mock API responses."""
        custom_resp = ModelsResponse(data=ModelsResponse.Data(models=custom_list))
        public_resp = ModelsResponse(data=ModelsResponse.Data(models=public_list))
        resource._get.side_effect = lambda *_, **kwargs: (
            custom_resp if kwargs.get("params", {}).get("type") == "custom" else public_resp
        )

    def test_filter_by_companies_excludes_custom(
        self,
        models_resource,
        custom_model,
        public_openai,
        public_meta,
    ):
        """Filtering by company excludes custom models (they have no company)."""
        self._mock_responses(models_resource, [custom_model], [public_openai, public_meta])

        result = models_resource.get(companies=["OpenAI"])

        assert len(result) == 1
        assert result[0].key == "gpt-4"

    def test_filter_by_companies_no_match_returns_empty(
        self,
        models_resource,
        custom_model,
        public_openai,
    ):
        """Filtering by a company with no match returns empty list."""
        self._mock_responses(models_resource, [custom_model], [public_openai])

        result = models_resource.get(companies=["Google"])

        assert result == []

    def test_filter_by_regions_excludes_custom(
        self,
        models_resource,
        custom_model,
        public_openai,
        public_mistral,
    ):
        """Filtering by region excludes custom models."""
        self._mock_responses(models_resource, [custom_model], [public_openai, public_mistral])

        result = models_resource.get(regions=["eu-west-1"])

        assert len(result) == 1
        assert result[0].key == "mixtral"

    def test_filter_by_licenses_excludes_custom(
        self,
        models_resource,
        custom_model,
        public_meta,
        public_mistral,
    ):
        """Filtering by license excludes custom models."""
        self._mock_responses(models_resource, [custom_model], [public_meta, public_mistral])

        result = models_resource.get(licenses=["apache-2.0"])

        assert len(result) == 1
        assert result[0].key == "mixtral"

    def test_filter_by_categories_open_source(
        self,
        models_resource,
        custom_model,
        public_openai,
        public_meta,
    ):
        """Filtering by 'Open-Source' category matches models with open_weights=True."""
        self._mock_responses(models_resource, [custom_model], [public_openai, public_meta])

        result = models_resource.get(categories=["Open-Source"])

        assert len(result) == 1
        assert result[0].key == "llama-3"

    def test_filter_by_categories_closed_source(
        self,
        models_resource,
        custom_model,
        public_openai,
        public_meta,
    ):
        """Filtering by 'Closed-Source' matches models with open_weights=False."""
        self._mock_responses(models_resource, [custom_model], [public_openai, public_meta])

        result = models_resource.get(categories=["Closed-Source"])

        assert len(result) == 1
        assert result[0].key == "gpt-4"

    def test_filter_by_categories_architecture(
        self,
        models_resource,
        custom_model,
        public_openai,
        public_mistral,
    ):
        """Filtering by architecture type category (MoE) works."""
        self._mock_responses(models_resource, [custom_model], [public_openai, public_mistral])

        result = models_resource.get(categories=["MoE"])

        assert len(result) == 1
        assert result[0].key == "mixtral"

    def test_filter_by_key_sends_param_to_api(
        self,
        models_resource,
        public_openai,
    ):
        """Filtering by key sends the key param to the API."""
        self._mock_responses(models_resource, [], [public_openai])

        models_resource.get(key="gpt")

        for c in models_resource._get.call_args_list:
            assert c.kwargs["params"]["key"] == "gpt"

    def test_combined_filters(
        self,
        models_resource,
        custom_model,
        public_openai,
        public_meta,
        public_mistral,
    ):
        """Multiple filters work together (AND logic)."""
        self._mock_responses(
            models_resource,
            [custom_model],
            [public_openai, public_meta, public_mistral],
        )

        result = models_resource.get(categories=["Open-Source"], regions=["eu-west-1"])

        assert len(result) == 1
        assert result[0].key == "mixtral"

    def test_no_filters_returns_all(
        self,
        models_resource,
        custom_model,
        public_openai,
        public_meta,
    ):
        """When no filters are applied, all models are returned."""
        self._mock_responses(models_resource, [custom_model], [public_openai, public_meta])

        result = models_resource.get()

        assert len(result) == 3

    def test_filter_excludes_all_when_no_match(
        self,
        models_resource,
        custom_model,
        public_openai,
    ):
        """Filtering with no matches returns empty list, including no custom models."""
        self._mock_responses(models_resource, [custom_model], [public_openai])

        result = models_resource.get(regions=["ap-southeast-1"])

        assert result == []


class TestModelsAddRemoveIncludesCustoms:
    """Regression tests for the PR #1916 fix: add()/remove() now operate on
    the full project model list (public + custom), not public only. The old
    behavior silently wiped any custom model out of Project.Models on every
    add/remove because the PATCH payload omitted them."""

    @pytest.fixture
    def mock_client(self):
        client = Mock()
        client.organization_id = "org-123"
        client.project_id = "proj-456"
        client.get_cast = Mock()
        client.patch_cast = Mock()
        return client

    @pytest.fixture
    def models_resource(self, mock_client):
        return Models(mock_client)

    def test_add_includes_customs_in_patch_payload(self, models_resource):
        """add() must preserve already-attached custom models in the PATCH body."""
        public_m = PublicModel(id="pub-1", key="pub-1", name="Pub1", description="")
        custom_m = CustomModel(
            id="cust-1",
            key="cust-1",
            name="Cust1",
            description="",
            max_tokens=2048,
            api_url="https://x.example.com",
            disabled=False,
        )
        models_resource.get = Mock(return_value=[public_m, custom_m])
        models_resource._patch.return_value = {"id": "proj-456"}

        result = models_resource.add("new-id")

        assert result is True
        models_resource.get.assert_called_once()
        # Critical: the get call should NOT filter by type="public" anymore.
        assert models_resource.get.call_args.kwargs.get("type") is None
        call_body = models_resource._patch.call_args.kwargs["body"]
        assert call_body == {"models": ["pub-1", "cust-1", "new-id"]}

    def test_remove_preserves_unrelated_customs(self, models_resource):
        """remove() of a public ID must leave attached customs untouched."""
        public_m = PublicModel(id="pub-1", key="pub-1", name="Pub1", description="")
        custom_m = CustomModel(
            id="cust-1",
            key="cust-1",
            name="Cust1",
            description="",
            max_tokens=2048,
            api_url="https://x.example.com",
            disabled=False,
        )
        models_resource.get = Mock(return_value=[public_m, custom_m])
        models_resource._patch.return_value = {"id": "proj-456"}

        models_resource.remove("pub-1")

        # Custom model must survive.
        call_body = models_resource._patch.call_args.kwargs["body"]
        assert call_body == {"models": ["cust-1"]}
        assert models_resource.get.call_args.kwargs.get("type") is None


class TestModelsUpdateCustom:
    """Test Models.update_custom() method."""

    @pytest.fixture
    def mock_client(self):
        client = Mock()
        client.organization_id = "org-123"
        client.project_id = "proj-456"
        client.patch_cast = Mock()
        return client

    @pytest.fixture
    def models_resource(self, mock_client):
        return Models(mock_client)

    def test_update_custom_api_url_only(self, models_resource):
        """update_custom() sends only api_url in body when that's all that's provided."""
        models_resource._patch.return_value = {"data": {"id": "model-1"}}

        result = models_resource.update_custom("model-1", api_url="https://new.example.com/v1")

        assert result is True
        models_resource._patch.assert_called_once_with(
            "/organizations/org-123/projects/proj-456/custom-models/model-1",
            body={"api_url": "https://new.example.com/v1"},
            timeout=DEFAULT_TIMEOUT,
            cast_to=dict,
        )

    def test_update_custom_all_three_fields(self, models_resource):
        """update_custom() sends all provided fields together."""
        models_resource._patch.return_value = {"data": {"id": "model-1"}}

        result = models_resource.update_custom(
            "model-1",
            api_url="https://x.io",
            api_key="sk-new",
            max_tokens=1024,
        )

        assert result is True
        body = models_resource._patch.call_args.kwargs["body"]
        assert body == {
            "api_url": "https://x.io",
            "api_key": "sk-new",
            "max_tokens": 1024,
        }

    def test_update_custom_max_tokens_only(self, models_resource):
        """update_custom() supports max_tokens-only updates."""
        models_resource._patch.return_value = {"data": {"id": "model-1"}}

        result = models_resource.update_custom("model-1", max_tokens=8192)

        assert result is True
        body = models_resource._patch.call_args.kwargs["body"]
        assert body == {"max_tokens": 8192}

    def test_update_custom_extra_payload_only(self, models_resource):
        """update_custom() supports extra_payload-only updates."""
        models_resource._patch.return_value = {"data": {"id": "model-1"}}

        payload = {"temperature": 1, "top_p": 0.9}
        result = models_resource.update_custom("model-1", extra_payload=payload)

        assert result is True
        body = models_resource._patch.call_args.kwargs["body"]
        assert body == {"extra_payload": payload}

    def test_update_custom_extra_payload_empty_dict_clears_payload(self, models_resource):
        """update_custom(extra_payload={}) sends the empty dict so the backend clears the stored payload."""
        models_resource._patch.return_value = {"data": {"id": "model-1"}}

        result = models_resource.update_custom("model-1", extra_payload={})

        assert result is True
        body = models_resource._patch.call_args.kwargs["body"]
        assert body == {"extra_payload": {}}

    def test_update_custom_omits_extra_payload_when_none(self, models_resource):
        """update_custom() does not include extra_payload when not provided."""
        models_resource._patch.return_value = {"data": {"id": "model-1"}}

        models_resource.update_custom("model-1", api_url="https://x.io")

        body = models_resource._patch.call_args.kwargs["body"]
        assert "extra_payload" not in body

    def test_update_custom_returns_false_on_error_envelope(self, models_resource):
        """update_custom() returns False when response has no data field."""
        models_resource._patch.return_value = {"code": "NOT_FOUND", "message": "missing"}

        result = models_resource.update_custom("model-1", api_url="https://x.io")

        assert result is False

    def test_update_custom_returns_false_when_response_not_dict(self, models_resource):
        """update_custom() returns False when response isn't a dict."""
        models_resource._patch.return_value = httpx.Response(404)

        result = models_resource.update_custom("model-1", api_url="https://x.io")

        assert result is False


class TestModelsDeleteCustom:
    """Test Models.delete_custom() method."""

    @pytest.fixture
    def mock_client(self):
        client = Mock()
        client.organization_id = "org-123"
        client.project_id = "proj-456"
        client.delete_cast = Mock()
        return client

    @pytest.fixture
    def models_resource(self, mock_client):
        return Models(mock_client)

    def test_delete_custom_happy_path(self, models_resource):
        """delete_custom() hits the right URL and returns True on success."""
        models_resource._delete.return_value = {"data": {"id": "model-1"}}

        result = models_resource.delete_custom("model-1")

        assert result is True
        models_resource._delete.assert_called_once_with(
            "/organizations/org-123/projects/proj-456/custom-models/model-1",
            timeout=DEFAULT_TIMEOUT,
            cast_to=dict,
        )

    def test_delete_custom_returns_false_on_error(self, models_resource):
        """delete_custom() returns False when response has no data field."""
        models_resource._delete.return_value = {"code": "NOT_FOUND"}

        result = models_resource.delete_custom("model-1")

        assert result is False
