"""Tests for MetaClient - Core HTTP client for Meta Graph API.

Tests cover:
- Client initialization
- Authentication (access token, appsecret_proof)
- HTTP methods (GET, POST, DELETE)
- Retry logic
- Pagination
- Error handling
"""
from __future__ import annotations

import hashlib
import hmac
import json
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import httpx
import pytest

from src.meta_client import BatchResult, MetaClient
from src.errors import MetaApiError, MetaApiRateLimitError, RateLimitError
from src.rate_limiter import RateLimitExceededError
from src.schemas import GraphBodyEncoding


# =============================================================================
# Fixtures
# =============================================================================

@pytest.fixture
def mock_transport():
    """Create a mock transport for httpx."""
    transport = AsyncMock(spec=httpx.AsyncBaseTransport)
    return transport


@pytest.fixture
def client(mock_transport):
    """Create a MetaClient with mocked transport."""
    return MetaClient(
        access_token="test_token_abc123",
        app_secret="test_secret_xyz",
        version="v24.0",
        timeout=10.0,
        transport=mock_transport,
        enable_caching=False,
        enable_rate_limiting=False,
    )


@pytest.fixture
def client_no_secret(mock_transport):
    """Create a MetaClient without app secret."""
    return MetaClient(
        access_token="test_token_abc123",
        app_secret=None,
        version="v24.0",
        transport=mock_transport,
        enable_caching=False,
        enable_rate_limiting=False,
    )


# =============================================================================
# Client Initialization Tests
# =============================================================================

class TestClientInitialization:
    """Tests for MetaClient initialization."""

    def test_client_stores_access_token(self, client):
        """Verify access token is stored."""
        assert client.access_token == "test_token_abc123"

    def test_client_stores_app_secret(self, client):
        """Verify app secret is stored."""
        assert client.app_secret == "test_secret_xyz"

    def test_client_default_version(self, mock_transport):
        """Verify default API version is v24.0."""
        c = MetaClient(
            access_token="token",
            transport=mock_transport,
            enable_caching=False,
            enable_rate_limiting=False,
        )
        assert c.version == "v24.0"

    def test_client_custom_version(self, mock_transport):
        """Verify custom API version can be set."""
        c = MetaClient(
            access_token="token",
            version="v23.0",
            transport=mock_transport,
            enable_caching=False,
            enable_rate_limiting=False,
        )
        assert c.version == "v23.0"

    def test_client_base_url_trailing_slash_stripped(self, mock_transport):
        """Verify trailing slash is stripped from base URL."""
        c = MetaClient(
            access_token="token",
            base_url="https://graph.facebook.com/",
            transport=mock_transport,
            enable_caching=False,
            enable_rate_limiting=False,
        )
        assert c.base_url == "https://graph.facebook.com"


# =============================================================================
# Authentication Tests
# =============================================================================

class TestAuthentication:
    """Tests for authentication mechanisms."""

    def test_appsecret_proof_computation(self, client):
        """Verify appsecret_proof is computed correctly using HMAC-SHA256."""
        expected = hmac.new(
            key=b"test_secret_xyz",
            msg=b"test_token_abc123",
            digestmod=hashlib.sha256,
        ).hexdigest()
        
        proof = client._compute_appsecret_proof()
        assert proof == expected

    def test_inject_auth_adds_token(self, client):
        """Verify access_token is injected into params."""
        params = {"fields": "id,name"}
        result = client._inject_auth(params)
        
        assert result["access_token"] == "test_token_abc123"
        assert result["fields"] == "id,name"

    def test_inject_auth_adds_appsecret_proof(self, client):
        """Verify appsecret_proof is injected when app_secret is set."""
        params = {}
        result = client._inject_auth(params, use_appsecret_proof=True)
        
        assert "appsecret_proof" in result
        assert len(result["appsecret_proof"]) == 64  # SHA256 hex digest

    def test_inject_auth_no_proof_when_no_secret(self, client_no_secret):
        """Verify no appsecret_proof when app_secret is None."""
        params = {}
        result = client_no_secret._inject_auth(params, use_appsecret_proof=True)
        
        assert "appsecret_proof" not in result

    def test_inject_auth_no_proof_when_disabled(self, client):
        """Verify no appsecret_proof when use_appsecret_proof=False."""
        params = {}
        result = client._inject_auth(params, use_appsecret_proof=False)
        
        assert "appsecret_proof" not in result


# =============================================================================
# HTTP Request Tests
# =============================================================================

class TestHttpRequests:
    """Tests for HTTP request methods."""

    @pytest.mark.asyncio
    async def test_get_request(self, client):
        """Test basic GET request."""
        mock_response = MagicMock()
        mock_response.is_success = True
        mock_response.content = b'{"data": []}'
        mock_response.json.return_value = {"data": []}
        mock_response.headers = {}
        
        with patch.object(client._client, 'request', new_callable=AsyncMock) as mock_req:
            mock_req.return_value = mock_response
            
            result = await client.get("me/adaccounts", params={"fields": "id"})
            
            assert result == {"data": []}
            mock_req.assert_called_once()

    @pytest.mark.asyncio
    async def test_post_request_form_encoding(self, client):
        """Test POST request with form encoding."""
        mock_response = MagicMock()
        mock_response.is_success = True
        mock_response.content = b'{"id": "123"}'
        mock_response.json.return_value = {"id": "123"}
        mock_response.headers = {}
        
        with patch.object(client._client, 'request', new_callable=AsyncMock) as mock_req:
            mock_req.return_value = mock_response
            
            result = await client.post(
                "act_123/campaigns",
                body={"name": "Test Campaign"},
                encoding=GraphBodyEncoding.FORM,
            )
            
            assert result == {"id": "123"}

    @pytest.mark.asyncio
    async def test_delete_request(self, client):
        """Test DELETE request."""
        mock_response = MagicMock()
        mock_response.is_success = True
        mock_response.content = b'{"success": true}'
        mock_response.json.return_value = {"success": True}
        mock_response.headers = {}
        
        with patch.object(client._client, 'request', new_callable=AsyncMock) as mock_req:
            mock_req.return_value = mock_response
            
            result = await client.delete("campaign_123")
            
            assert result == {"success": True}

    @pytest.mark.asyncio
    async def test_path_leading_slash_stripped(self, client):
        """Test that leading slashes are stripped from paths."""
        mock_response = MagicMock()
        mock_response.is_success = True
        mock_response.content = b'{}'
        mock_response.json.return_value = {}
        mock_response.headers = {}
        
        with patch.object(client._client, 'request', new_callable=AsyncMock) as mock_req:
            mock_req.return_value = mock_response
            
            await client.get("/me/adaccounts")
            
            # Verify the path doesn't have leading slash
            call_args = mock_req.call_args
            assert call_args.kwargs.get("url") == "me/adaccounts" or call_args[1].get("url") == "me/adaccounts"


# =============================================================================
# Error Handling Tests
# =============================================================================

class TestErrorHandling:
    """Tests for error handling."""

    @pytest.mark.asyncio
    async def test_api_error_raised_on_failure(self, client):
        """Test that MetaApiError is raised on API error response."""
        mock_response = MagicMock()
        mock_response.is_success = False
        mock_response.status_code = 400
        mock_response.json.return_value = {
            "error": {
                "message": "Invalid parameter",
                "type": "OAuthException",
                "code": 100,
            }
        }
        
        with patch.object(client._client, 'request', new_callable=AsyncMock) as mock_req:
            mock_req.return_value = mock_response
            
            with pytest.raises(MetaApiError) as exc_info:
                await client.get("invalid_path")
            
            assert exc_info.value.status_code == 400

    @pytest.mark.asyncio
    async def test_rate_limit_error_raised_on_429(self, client):
        """Test that MetaApiRateLimitError is raised on 429."""
        mock_response = MagicMock()
        mock_response.is_success = False
        mock_response.status_code = 429
        mock_response.json.return_value = {
            "error": {
                "message": "Rate limit exceeded",
                "type": "OAuthException",
                "code": 4,
            }
        }
        
        with patch.object(client._client, 'request', new_callable=AsyncMock) as mock_req:
            mock_req.return_value = mock_response
            
            with pytest.raises(MetaApiRateLimitError):
                await client.get("some_path")

    @pytest.mark.asyncio
    async def test_rate_limiter_token_released_on_api_error(self, mock_transport):
        """Ensure acquired rate-limit tokens are returned after failed requests."""
        mocked_limiter = MagicMock()
        mocked_limiter.acquire = AsyncMock(
            return_value=SimpleNamespace(remaining=9, reset_time=1234567890)
        )
        mocked_limiter.release_token = AsyncMock()

        with patch("src.rate_limiter.get_rate_limiter", return_value=mocked_limiter):
            client = MetaClient(
                access_token="test_token_abc123",
                app_secret="test_secret_xyz",
                transport=mock_transport,
                enable_caching=False,
                enable_rate_limiting=True,
            )

        mock_response = MagicMock()
        mock_response.is_success = False
        mock_response.status_code = 400
        mock_response.json.return_value = {
            "error": {
                "message": "Invalid parameter",
                "type": "OAuthException",
                "code": 100,
            }
        }
        mock_response.headers = {}

        with patch.object(client._client, "request", new_callable=AsyncMock) as mock_req:
            mock_req.return_value = mock_response
            with pytest.raises(MetaApiError):
                await client.get("campaigns")

        mocked_limiter.release_token.assert_awaited_once_with("campaigns", None)

    @pytest.mark.asyncio
    async def test_local_rate_limit_exceeded_fails_fast_with_retry_after(self, mock_transport):
        """Ensure local limiter throttling blocks outbound requests."""
        mocked_limiter = MagicMock()
        mocked_limiter.acquire = AsyncMock(
            side_effect=RateLimitExceededError(
                "Rate limit exceeded for campaigns",
                reset_time=int(time.time()) + 30,
            )
        )
        mocked_limiter.release_token = AsyncMock()

        with patch("src.rate_limiter.get_rate_limiter", return_value=mocked_limiter):
            client = MetaClient(
                access_token="test_token_abc123",
                app_secret="test_secret_xyz",
                transport=mock_transport,
                enable_caching=False,
                enable_rate_limiting=True,
            )
        client.observability_manager = None

        with patch.object(client._client, "request", new_callable=AsyncMock) as mock_req:
            with pytest.raises(RateLimitError) as exc_info:
                await client.get("campaigns")

        assert "Local rate limit reached for 'campaigns'" in exc_info.value.message
        assert exc_info.value.details["retry_after"] >= 1
        assert exc_info.value.details["reason"] == "local_rate_limiter_throttle"
        assert exc_info.value.details["endpoint"] == "campaigns"
        mock_req.assert_not_called()
        mocked_limiter.release_token.assert_not_called()

    @pytest.mark.asyncio
    async def test_non_rate_limit_limiter_failures_degrade_gracefully(self, mock_transport):
        """Ensure non-throttle limiter errors continue request path explicitly."""
        mocked_limiter = MagicMock()
        mocked_limiter.acquire = AsyncMock(side_effect=RuntimeError("limiter backend unavailable"))
        mocked_limiter.release_token = AsyncMock()

        with patch("src.rate_limiter.get_rate_limiter", return_value=mocked_limiter):
            client = MetaClient(
                access_token="test_token_abc123",
                app_secret="test_secret_xyz",
                transport=mock_transport,
                enable_caching=False,
                enable_rate_limiting=True,
            )
        client.observability_manager = None

        mock_response = MagicMock()
        mock_response.is_success = True
        mock_response.content = b'{"data": []}'
        mock_response.json.return_value = {"data": []}
        mock_response.headers = {}

        with patch("src.meta_client.logger.warning") as mock_warning:
            with patch.object(client._client, "request", new_callable=AsyncMock) as mock_req:
                mock_req.return_value = mock_response
                result = await client.get("campaigns")

        assert result == {"data": []}
        mock_req.assert_called_once()
        mocked_limiter.release_token.assert_not_called()
        assert any(
            "local_rate_limiter_degraded" in str(call.args[0])
            for call in mock_warning.call_args_list
        )


# =============================================================================
# Pagination Tests
# =============================================================================

class TestPagination:
    """Tests for pagination handling."""

    @pytest.mark.asyncio
    async def test_get_all_single_page(self, client):
        """Test get_all with a single page of results."""
        mock_response = MagicMock()
        mock_response.is_success = True
        mock_response.content = b'{"data": [{"id": "1"}, {"id": "2"}]}'
        mock_response.json.return_value = {
            "data": [{"id": "1"}, {"id": "2"}],
            "paging": {}
        }
        mock_response.headers = {}
        
        with patch.object(client._client, 'request', new_callable=AsyncMock) as mock_req:
            mock_req.return_value = mock_response
            
            results = await client.get_all("me/adaccounts")
            
            assert len(results) == 2
            assert results[0]["id"] == "1"

    @pytest.mark.asyncio
    async def test_get_all_multiple_pages(self, client):
        """Test get_all with multiple pages of results."""
        page1 = MagicMock()
        page1.is_success = True
        page1.content = b'{"data": [{"id": "1"}]}'
        page1.json.return_value = {
            "data": [{"id": "1"}],
            "paging": {
                "cursors": {"after": "cursor123"},
                "next": "https://graph.facebook.com/..."
            }
        }
        page1.headers = {}
        
        page2 = MagicMock()
        page2.is_success = True
        page2.content = b'{"data": [{"id": "2"}]}'
        page2.json.return_value = {
            "data": [{"id": "2"}],
            "paging": {}
        }
        page2.headers = {}
        
        with patch.object(client._client, 'request', new_callable=AsyncMock) as mock_req:
            mock_req.side_effect = [page1, page2]
            
            results = await client.get_all("me/adaccounts")
            
            assert len(results) == 2
            assert mock_req.call_count == 2

    @pytest.mark.asyncio
    async def test_get_all_follows_next_when_after_cursor_only_in_next_url(self, client):
        """Test get_all continues when paging.next exists but paging.cursors.after is missing."""
        page1 = MagicMock()
        page1.is_success = True
        page1.content = b'{"data": [{"id": "1"}]}'
        page1.json.return_value = {
            "data": [{"id": "1"}],
            "paging": {
                "next": "https://graph.facebook.com/v24.0/me/adaccounts?limit=100&after=cursor_next_only"
            },
        }
        page1.headers = {}

        page2 = MagicMock()
        page2.is_success = True
        page2.content = b'{"data": [{"id": "2"}]}'
        page2.json.return_value = {
            "data": [{"id": "2"}],
            "paging": {},
        }
        page2.headers = {}

        with patch.object(client._client, "request", new_callable=AsyncMock) as mock_req:
            mock_req.side_effect = [page1, page2]

            results = await client.get_all("me/adaccounts")

            assert len(results) == 2
            assert mock_req.call_count == 2
            second_call_params = mock_req.call_args_list[1].kwargs["params"]
            assert second_call_params["after"] == "cursor_next_only"

    @pytest.mark.asyncio
    async def test_get_all_follows_next_with_offset_pagination(self, client):
        """Test get_all can continue pagination when paging.next uses offset parameters."""
        page1 = MagicMock()
        page1.is_success = True
        page1.content = b'{"data": [{"id": "1"}]}'
        page1.json.return_value = {
            "data": [{"id": "1"}],
            "paging": {
                "next": "https://graph.facebook.com/v24.0/me/adaccounts?limit=100&offset=100"
            },
        }
        page1.headers = {}

        page2 = MagicMock()
        page2.is_success = True
        page2.content = b'{"data": [{"id": "2"}]}'
        page2.json.return_value = {
            "data": [{"id": "2"}],
            "paging": {},
        }
        page2.headers = {}

        with patch.object(client._client, "request", new_callable=AsyncMock) as mock_req:
            mock_req.side_effect = [page1, page2]

            results = await client.get_all("me/adaccounts")

            assert len(results) == 2
            assert mock_req.call_count == 2
            second_call_params = mock_req.call_args_list[1].kwargs["params"]
            assert second_call_params["offset"] == "100"

    @pytest.mark.asyncio
    async def test_get_all_respects_limit(self, client):
        """Test get_all stops at safety limit."""
        # Create response with data and pagination
        mock_response = MagicMock()
        mock_response.is_success = True
        mock_response.content = b'{"data": []}'
        mock_response.json.return_value = {
            "data": [{"id": str(i)} for i in range(50)],
            "paging": {}  # No next page
        }
        mock_response.headers = {}
        
        with patch.object(client._client, 'request', new_callable=AsyncMock) as mock_req:
            mock_req.return_value = mock_response
            
            results = await client.get_all("me/adaccounts", limit=100)
            
            # Should return results from the single page
            assert len(results) == 50


# =============================================================================
# Context Manager Tests
# =============================================================================

class TestContextManager:
    """Tests for async context manager."""

    @pytest.mark.asyncio
    async def test_async_context_manager(self, mock_transport):
        """Test client works as async context manager."""
        async with MetaClient(
            access_token="token",
            transport=mock_transport,
            enable_caching=False,
            enable_rate_limiting=False,
        ) as client:
            assert client.access_token == "token"

    @pytest.mark.asyncio
    async def test_aclose_closes_token_provider(self, mock_transport):
        """Ensure hosted-mode token providers are closed with the client."""
        token_provider = AsyncMock()
        token_provider.aclose = AsyncMock()

        client = MetaClient(
            access_token="token",
            token_provider=token_provider,
            transport=mock_transport,
            enable_caching=False,
            enable_rate_limiting=False,
        )

        await client.aclose()
        token_provider.aclose.assert_awaited_once()


# =============================================================================
# Batch Request Tests
# =============================================================================

class TestBatchRequests:
    """Tests for batch operations."""

    @pytest.mark.asyncio
    async def test_batch_request(self, client):
        """Test batch request combines multiple operations."""
        mock_response = MagicMock()
        mock_response.is_success = True
        mock_response.content = b'[{"code": 200, "body": "{}"}]'
        mock_response.json.return_value = [{"code": 200, "body": "{}"}]
        mock_response.headers = {}

        with patch.object(client._client, 'request', new_callable=AsyncMock) as mock_req:
            mock_req.return_value = mock_response

            operations = [
                {"method": "GET", "relative_url": "campaign_1"},
                {"method": "GET", "relative_url": "campaign_2"},
            ]

            result = await client.batch(operations)

            assert not result.has_failures
            assert len(result.succeeded) == 1
            assert result.succeeded[0]["code"] == 200

    def test_batch_result_to_dict(self):
        """BatchResult exposes JSON-safe payload for tool serialization."""
        result = BatchResult(
            all_results=[{"index": 0, "code": 200}],
            succeeded=[{"index": 0, "code": 200}],
            failed=[],
            raw=[{"code": 200, "body": "{}"}],
        )

        payload = result.to_dict()
        assert payload["has_failures"] is False
        assert payload["succeeded"][0]["code"] == 200


class TestLegacyCompatibilityHelpers:
    """Tests for compatibility helpers used by legacy engine modules."""

    @pytest.mark.asyncio
    async def test_graph_request_uses_params_as_body_for_post(self, client):
        """Legacy graph_request POST calls often pass payload in params."""
        with patch.object(client, "request", new_callable=AsyncMock) as mock_request:
            mock_request.return_value = {"success": True}

            response = await client.graph_request(
                "POST",
                "campaign_123",
                params={"status": "PAUSED"},
            )

            assert response == {"success": True}
            assert mock_request.call_args.args[0] == "POST"
            assert mock_request.call_args.args[1] == "campaign_123"
            assert mock_request.call_args.kwargs["params"] is None
            assert mock_request.call_args.kwargs["body"] == {"status": "PAUSED"}

    @pytest.mark.asyncio
    async def test_get_insights_serializes_nested_params(self, client):
        """get_insights should normalize account path and JSON-style params."""
        with patch.object(client, "get", new_callable=AsyncMock) as mock_get:
            mock_get.return_value = {"data": []}

            response = await client.get_insights(
                object_id="123456",
                level="account",
                fields=["spend", "impressions"],
                time_range={"since": "2026-02-01", "until": "2026-02-13"},
                breakdowns=["country", "region"],
                filtering=[{"field": "campaign.id", "operator": "IN", "value": ["1"]}],
                time_increment=1,
            )

            assert response == {"data": []}
            assert mock_get.call_args.args[0] == "act_123456/insights"
            params = mock_get.call_args.kwargs["params"]
            assert params["fields"] == "spend,impressions"
            assert params["breakdowns"] == "country,region"
            assert params["time_increment"] == "1"
            assert json.loads(params["time_range"]) == {"since": "2026-02-01", "until": "2026-02-13"}
            assert json.loads(params["filtering"]) == [
                {"field": "campaign.id", "operator": "IN", "value": ["1"]}
            ]

    @pytest.mark.asyncio
    async def test_get_ad_account_normalizes_account_id(self, client):
        """get_ad_account should normalize account id and join fields."""
        with patch.object(client, "get", new_callable=AsyncMock) as mock_get:
            mock_get.return_value = {"id": "act_123"}

            response = await client.get_ad_account(
                ad_account_id="123",
                fields=["account_id", "name"],
            )

            assert response == {"id": "act_123"}
            assert mock_get.call_args.args[0] == "act_123"
            assert mock_get.call_args.kwargs["params"]["fields"] == "account_id,name"
