#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.
#

"""Unit tests for the Veculo Python SDK client."""

from __future__ import annotations

import os
from unittest.mock import MagicMock, patch

import httpx
import pytest

from veculo.client import VeculoClient
from veculo.errors import AuthenticationError, NotFoundError, ValidationError, VeculoError


# ------------------------------------------------------------------
# Fixtures
# ------------------------------------------------------------------


def _mock_response(status_code: int = 200, json_data: dict | None = None, text: str = "") -> MagicMock:
    """Build a fake httpx.Response."""
    resp = MagicMock(spec=httpx.Response)
    resp.status_code = status_code
    resp.is_success = 200 <= status_code < 300
    resp.text = text
    resp.json.return_value = json_data if json_data is not None else {}
    return resp


def _make_client(**kwargs) -> VeculoClient:
    """Create a client with default test credentials."""
    defaults = {
        "endpoint": "https://api.test.veculo.com",
        "api_key": "vk-test-key",
        "cluster_id": "cl-test",
    }
    defaults.update(kwargs)
    return VeculoClient(**defaults)


# ------------------------------------------------------------------
# Tests
# ------------------------------------------------------------------


class TestPutVertex:
    def test_put_vertex_basic(self) -> None:
        client = _make_client()
        expected = {"id": "v1", "label": "person", "status": "created"}
        with patch.object(client._client, "request", return_value=_mock_response(200, expected)) as mock_req:
            result = client.put_vertex(id="v1", label="person", properties={"name": "Alice"})

        assert result == expected
        mock_req.assert_called_once_with(
            "PUT",
            "/vertices",
            params=None,
            json={"id": "v1", "label": "person", "properties": {"name": "Alice"}},
        )

    def test_put_vertex_with_embedding(self) -> None:
        client = _make_client()
        expected = {"id": "v1", "status": "created"}
        with patch.object(client._client, "request", return_value=_mock_response(200, expected)):
            result = client.put_vertex(
                id="v1",
                embedding=[0.1, 0.2, 0.3],
                visibility="PUBLIC",
            )

        assert result == expected

    def test_put_vertex_minimal(self) -> None:
        client = _make_client()
        expected = {"id": "v1", "label": "default", "status": "created"}
        with patch.object(client._client, "request", return_value=_mock_response(200, expected)) as mock_req:
            result = client.put_vertex(id="v1")

        assert result == expected
        call_json = mock_req.call_args.kwargs["json"]
        assert call_json == {"id": "v1", "label": "default"}
        # Verify optional fields are omitted from the payload
        assert "properties" not in call_json
        assert "embedding" not in call_json
        assert "visibility" not in call_json


class TestGetVertex:
    def test_get_vertex(self) -> None:
        client = _make_client()
        expected = {"id": "v1", "label": "person", "properties": {"name": "Alice"}}
        with patch.object(client._client, "request", return_value=_mock_response(200, expected)) as mock_req:
            result = client.get_vertex(id="v1")

        assert result == expected
        mock_req.assert_called_once_with("GET", "/vertices/v1", params=None, json=None)

    def test_get_vertex_with_authorizations(self) -> None:
        client = _make_client()
        expected = {"id": "v1", "label": "person"}
        with patch.object(client._client, "request", return_value=_mock_response(200, expected)) as mock_req:
            result = client.get_vertex(id="v1", authorizations="PUBLIC,INTERNAL")

        assert result == expected
        mock_req.assert_called_once_with(
            "GET",
            "/vertices/v1",
            params={"authorizations": "PUBLIC,INTERNAL"},
            json=None,
        )


class TestPutEdge:
    def test_put_edge(self) -> None:
        client = _make_client()
        expected = {"source": "v1", "target": "v2", "edge_type": "knows", "status": "created"}
        with patch.object(client._client, "request", return_value=_mock_response(200, expected)) as mock_req:
            result = client.put_edge(
                source="v1",
                target="v2",
                edge_type="knows",
                properties={"since": "2024"},
            )

        assert result == expected
        mock_req.assert_called_once_with(
            "PUT",
            "/edges",
            params=None,
            json={
                "source": "v1",
                "target": "v2",
                "edge_type": "knows",
                "properties": {"since": "2024"},
            },
        )

    def test_put_edge_minimal(self) -> None:
        client = _make_client()
        expected = {"status": "created"}
        with patch.object(client._client, "request", return_value=_mock_response(200, expected)) as mock_req:
            client.put_edge(source="a", target="b", edge_type="links")

        call_json = mock_req.call_args.kwargs["json"]
        assert call_json == {"source": "a", "target": "b", "edge_type": "links"}


class TestQuery:
    def test_query(self) -> None:
        client = _make_client()
        expected = {
            "results": [{"vertex_id": "v1", "score": 0.95, "properties": {}}],
            "graph_context": {},
        }
        with patch.object(client._client, "request", return_value=_mock_response(200, expected)) as mock_req:
            result = client.query(
                embedding=[0.1, 0.2, 0.3],
                top_k=5,
                edge_type="knows",
                depth=2,
                authorizations="PUBLIC",
            )

        assert result == expected
        mock_req.assert_called_once_with(
            "POST",
            "/query",
            params=None,
            json={
                "embedding": [0.1, 0.2, 0.3],
                "top_k": 5,
                "edge_type": "knows",
                "depth": 2,
                "authorizations": "PUBLIC",
            },
        )

    def test_query_defaults(self) -> None:
        client = _make_client()
        expected = {"results": [], "graph_context": {}}
        with patch.object(client._client, "request", return_value=_mock_response(200, expected)) as mock_req:
            client.query()

        call_json = mock_req.call_args.kwargs["json"]
        assert call_json == {"top_k": 10, "depth": 1}


class TestGetMetrics:
    def test_get_metrics(self) -> None:
        client = _make_client()
        expected = {"status": "healthy", "vertices": 1000, "edges": 5000}
        with patch.object(client._client, "request", return_value=_mock_response(200, expected)) as mock_req:
            result = client.get_metrics()

        assert result == expected
        mock_req.assert_called_once_with("GET", "/metrics", params=None, json=None)


class TestEnvVars:
    def test_client_from_env_vars(self) -> None:
        env = {
            "VECULO_API_KEY": "vk-env-key",
            "VECULO_ENDPOINT": "https://env.veculo.com",
            "VECULO_CLUSTER_ID": "cl-env",
        }
        with patch.dict(os.environ, env, clear=False):
            client = VeculoClient()

        assert client.api_key == "vk-env-key"
        assert client.endpoint == "https://env.veculo.com"
        assert client.cluster_id == "cl-env"

    def test_explicit_args_override_env(self) -> None:
        env = {
            "VECULO_API_KEY": "vk-env-key",
            "VECULO_ENDPOINT": "https://env.veculo.com",
            "VECULO_CLUSTER_ID": "cl-env",
        }
        with patch.dict(os.environ, env, clear=False):
            client = VeculoClient(
                api_key="vk-explicit",
                endpoint="https://explicit.veculo.com",
                cluster_id="cl-explicit",
            )

        assert client.api_key == "vk-explicit"
        assert client.endpoint == "https://explicit.veculo.com"
        assert client.cluster_id == "cl-explicit"

    def test_missing_api_key_raises(self) -> None:
        env_keys = ["VECULO_API_KEY", "VECULO_ENDPOINT", "VECULO_CLUSTER_ID"]
        clean_env = {k: "" for k in env_keys}
        with patch.dict(os.environ, clean_env, clear=False):
            # Remove keys entirely so os.environ.get returns None
            for k in env_keys:
                os.environ.pop(k, None)
            with pytest.raises(VeculoError, match="API key is required"):
                VeculoClient(cluster_id="cl-test")

    def test_missing_cluster_id_raises(self) -> None:
        with patch.dict(os.environ, {}, clear=False):
            os.environ.pop("VECULO_CLUSTER_ID", None)
            with pytest.raises(VeculoError, match="Cluster ID is required"):
                VeculoClient(api_key="vk-test")


class TestErrorHandling:
    def test_authentication_error_401(self) -> None:
        client = _make_client()
        error_body = {"error": {"message": "Invalid API key"}}
        with patch.object(
            client._client, "request", return_value=_mock_response(401, error_body)
        ):
            with pytest.raises(AuthenticationError) as exc_info:
                client.get_vertex(id="v1")

        assert exc_info.value.status_code == 401
        assert "Invalid API key" in exc_info.value.message

    def test_authentication_error_403(self) -> None:
        client = _make_client()
        error_body = {"error": {"message": "Forbidden"}}
        with patch.object(
            client._client, "request", return_value=_mock_response(403, error_body)
        ):
            with pytest.raises(AuthenticationError) as exc_info:
                client.get_vertex(id="v1")

        assert exc_info.value.status_code == 403

    def test_not_found_error(self) -> None:
        client = _make_client()
        error_body = {"error": {"message": "Vertex not found"}}
        with patch.object(
            client._client, "request", return_value=_mock_response(404, error_body)
        ):
            with pytest.raises(NotFoundError) as exc_info:
                client.get_vertex(id="nonexistent")

        assert exc_info.value.status_code == 404
        assert "Vertex not found" in exc_info.value.message

    def test_validation_error_400(self) -> None:
        client = _make_client()
        error_body = {"error": {"message": "Invalid vertex ID"}}
        with patch.object(
            client._client, "request", return_value=_mock_response(400, error_body)
        ):
            with pytest.raises(ValidationError) as exc_info:
                client.put_vertex(id="")

        assert exc_info.value.status_code == 400

    def test_validation_error_422(self) -> None:
        client = _make_client()
        error_body = {"error": {"message": "Embedding dimension mismatch"}}
        with patch.object(
            client._client, "request", return_value=_mock_response(422, error_body)
        ):
            with pytest.raises(ValidationError) as exc_info:
                client.put_vertex(id="v1", embedding=[0.1])

        assert exc_info.value.status_code == 422

    def test_generic_server_error(self) -> None:
        client = _make_client()
        error_body = {"error": {"message": "Internal server error"}}
        with patch.object(
            client._client, "request", return_value=_mock_response(500, error_body)
        ):
            with pytest.raises(VeculoError) as exc_info:
                client.get_vertex(id="v1")

        assert exc_info.value.status_code == 500
        assert not isinstance(exc_info.value, (AuthenticationError, NotFoundError, ValidationError))

    def test_error_with_unparseable_body(self) -> None:
        client = _make_client()
        resp = _mock_response(500, text="Bad Gateway")
        resp.json.side_effect = ValueError("not json")
        with patch.object(client._client, "request", return_value=resp):
            with pytest.raises(VeculoError) as exc_info:
                client.get_vertex(id="v1")

        assert exc_info.value.status_code == 500
        assert "Bad Gateway" in exc_info.value.message

    def test_204_no_content(self) -> None:
        client = _make_client()
        resp = _mock_response(204)
        with patch.object(client._client, "request", return_value=resp):
            result = client.get_vertex(id="v1")

        assert result == {}


class TestClientLifecycle:
    def test_context_manager(self) -> None:
        with _make_client() as client:
            assert isinstance(client, VeculoClient)

    def test_repr(self) -> None:
        client = _make_client()
        r = repr(client)
        assert "api.test.veculo.com" in r
        assert "cl-test" in r
        # API key must not appear in repr
        assert "vk-test-key" not in r

    def test_close(self) -> None:
        client = _make_client()
        with patch.object(client._client, "close") as mock_close:
            client.close()
        mock_close.assert_called_once()


class TestGetNeighbors:
    def test_get_neighbors(self) -> None:
        client = _make_client()
        expected = {"neighbors": [{"id": "v2", "label": "person"}]}
        with patch.object(client._client, "request", return_value=_mock_response(200, expected)) as mock_req:
            result = client.get_neighbors(vertex_id="v1", edge_type="knows", depth=2)

        assert result == expected
        mock_req.assert_called_once_with(
            "GET",
            "/vertices/v1/neighbors",
            params={"edge_type": "knows", "depth": "2"},
            json=None,
        )
