"""Tests for MorpheClient."""
from __future__ import annotations

import json
from typing import Any, Dict
from unittest.mock import MagicMock

import pytest

from morphe_adapter_sdk.client import MorpheClient
from morphe_adapter_sdk.errors import ApiError, NetworkError, SchemaValidationError
from morphe_adapter_sdk.hmac_utils import sign_body

ENDPOINT = "https://cp.example.com"
TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test"
HMAC_SECRET = "test-hmac-secret"

VALID_EVENT: Dict[str, Any] = {
    "event_type": "dataops.v1.sync_failed",
    "system_id": "11111111-1111-1111-1111-111111111111",
    "payload": {"records": 5},
}

ACCEPTED_202 = {
    "data": {
        "type": "event",
        "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
        "attributes": {
            "received_at": "2026-03-03T00:00:00Z",
            "schema_valid": True,
            "schema_validation_errors": [],
        },
    }
}

SCHEMA_RESPONSE = {
    "data": {
        "type": "schema",
        "id": "some-id",
        "attributes": {
            "event_type": "dataops.v1.sync_failed",
            "schema": {
                "type": "object",
                "properties": {"records": {"type": "number"}},
                "required": ["records"],
            },
            "canonical": True,
        },
    }
}


# ── Successful emission ────────────────────────────────────────────────────────

class TestSuccessfulEmission:
    def test_returns_emit_result_on_202(self, httpx_mock):
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v1/events",
            status_code=202,
            json=ACCEPTED_202,
        )
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN)
        result = client.emit(VALID_EVENT)

        assert result.id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
        assert result.received_at == "2026-03-03T00:00:00Z"
        assert result.schema_valid is True
        assert result.schema_validation_errors == []

    def test_posts_to_api_v1_events_with_auth_header(self, httpx_mock):
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v1/events",
            status_code=202,
            json=ACCEPTED_202,
        )
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN)
        client.emit(VALID_EVENT)

        request = httpx_mock.get_request()
        assert request.headers["authorization"] == f"Bearer {TOKEN}"
        assert request.headers["content-type"] == "application/json"

    def test_strips_trailing_slash_from_endpoint(self, httpx_mock):
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v1/events",
            status_code=202,
            json=ACCEPTED_202,
        )
        client = MorpheClient(endpoint=f"{ENDPOINT}/", token=TOKEN)
        client.emit(VALID_EVENT)  # would raise if URL had double slash

    def test_routes_v2_event_type_to_api_v2_events(self, httpx_mock):
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v2/events",
            status_code=202,
            json=ACCEPTED_202,
        )
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN)
        result = client.emit({**VALID_EVENT, "event_type": "dataops.v2.sync_failed"})
        assert result.id is not None

    def test_routes_versionless_event_type_to_api_v1_events(self, httpx_mock):
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v1/events",
            status_code=202,
            json=ACCEPTED_202,
        )
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN)
        result = client.emit({**VALID_EVENT, "event_type": "dataops.sync_failed"})
        assert result.id is not None


# ── HMAC signing ───────────────────────────────────────────────────────────────

class TestHmacSigning:
    def test_sets_hmac_signature_header_when_secret_provided(self, httpx_mock):
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v1/events",
            status_code=202,
            json=ACCEPTED_202,
        )
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN, hmac_secret=HMAC_SECRET)
        client.emit(VALID_EVENT)

        request = httpx_mock.get_request()
        body_str = request.content.decode("utf-8")
        expected_sig = sign_body(body_str, HMAC_SECRET)
        assert request.headers["x-morphe-signature"] == expected_sig

    def test_omits_hmac_signature_when_no_secret(self, httpx_mock):
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v1/events",
            status_code=202,
            json=ACCEPTED_202,
        )
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN)
        client.emit(VALID_EVENT)

        request = httpx_mock.get_request()
        assert "x-morphe-signature" not in request.headers


# ── Error handling ─────────────────────────────────────────────────────────────

class TestErrorHandling:
    def test_raises_api_error_with_json_api_fields_on_422(self, httpx_mock):
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v1/events",
            status_code=422,
            json={"errors": [{"code": "invalid_event", "title": "event_type is required", "detail": "provide it"}]},
        )
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN)
        with pytest.raises(ApiError) as exc_info:
            client.emit(VALID_EVENT)

        err = exc_info.value
        assert err.status == 422
        assert err.code == "invalid_event"
        assert err.title == "event_type is required"
        assert err.detail == "provide it"

    def test_raises_api_error_on_401(self, httpx_mock):
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v1/events",
            status_code=401,
            json={"errors": [{"code": "unauthorized", "title": "Invalid token"}]},
        )
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN)
        with pytest.raises(ApiError) as exc_info:
            client.emit(VALID_EVENT)
        assert exc_info.value.status == 401

    def test_raises_network_error_on_connection_failure(self, httpx_mock):
        import httpx as _httpx
        httpx_mock.add_exception(_httpx.ConnectError("connection refused"))
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN, max_retries=0)
        with pytest.raises(NetworkError):
            client.emit(VALID_EVENT)


# ── Retry behaviour ────────────────────────────────────────────────────────────

class TestRetryBehaviour:
    def test_retries_on_500_and_eventually_succeeds(self, httpx_mock):
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v1/events",
            status_code=500,
            json={"errors": []},
        )
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v1/events",
            status_code=202,
            json=ACCEPTED_202,
        )
        noop_sleep = MagicMock()
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN, max_retries=3)
        # Patch retry's sleep via monkeypatch isn't needed — injectable _sleep
        # Instead, test observable behavior: 2 requests made
        # We can't inject _sleep via public API, so just verify retry works end-to-end
        result = client.emit(VALID_EVENT)
        assert result.id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"

    def test_does_not_retry_on_422(self, httpx_mock):
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v1/events",
            status_code=422,
            json={"errors": [{"code": "invalid_event", "title": "bad"}]},
        )
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN, max_retries=3)
        with pytest.raises(ApiError) as exc_info:
            client.emit(VALID_EVENT)
        assert exc_info.value.status == 422
        # Only one request registered = no retry
        assert len(httpx_mock.get_requests()) == 1

    def test_raises_after_max_retries_exhausted(self, httpx_mock):
        # 4 responses: 1 initial + 3 retries
        for _ in range(4):
            httpx_mock.add_response(
                method="POST",
                url=f"{ENDPOINT}/api/v1/events",
                status_code=500,
                json={"errors": []},
            )
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN, max_retries=3)
        with pytest.raises(ApiError) as exc_info:
            client.emit(VALID_EVENT)
        assert exc_info.value.status == 500
        assert len(httpx_mock.get_requests()) == 4


# ── Schema validation ──────────────────────────────────────────────────────────

class TestSchemaValidation:
    def test_fetches_schema_and_validates_before_emitting(self, httpx_mock):
        httpx_mock.add_response(
            method="GET",
            url=f"{ENDPOINT}/api/v1/schemas/dataops.v1.sync_failed",
            status_code=200,
            json=SCHEMA_RESPONSE,
        )
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v1/events",
            status_code=202,
            json=ACCEPTED_202,
        )
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN, validate_schema=True)
        result = client.emit(VALID_EVENT)
        assert result.id is not None
        assert len(httpx_mock.get_requests()) == 2

    def test_raises_schema_validation_error_before_http_on_invalid_payload(self, httpx_mock):
        httpx_mock.add_response(
            method="GET",
            url=f"{ENDPOINT}/api/v1/schemas/dataops.v1.sync_failed",
            status_code=200,
            json=SCHEMA_RESPONSE,
        )
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN, validate_schema=True)
        with pytest.raises(SchemaValidationError) as exc_info:
            client.emit({**VALID_EVENT, "payload": {"records": "not-a-number"}})

        # Only schema GET was made — no POST
        assert len(httpx_mock.get_requests()) == 1
        err = exc_info.value
        assert len(err.failures) > 0
        assert "field" in err.failures[0]
        assert "message" in err.failures[0]

    def test_caches_schema_does_not_refetch_on_second_emit(self, httpx_mock):
        httpx_mock.add_response(
            method="GET",
            url=f"{ENDPOINT}/api/v1/schemas/dataops.v1.sync_failed",
            status_code=200,
            json=SCHEMA_RESPONSE,
        )
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v1/events",
            status_code=202,
            json=ACCEPTED_202,
        )
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v1/events",
            status_code=202,
            json=ACCEPTED_202,
        )
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN, validate_schema=True)
        client.emit(VALID_EVENT)
        client.emit(VALID_EVENT)
        # 1 GET + 2 POST = 3 total
        assert len(httpx_mock.get_requests()) == 3

    def test_raises_api_error_when_schema_fetch_fails(self, httpx_mock):
        httpx_mock.add_response(
            method="GET",
            url=f"{ENDPOINT}/api/v1/schemas/dataops.v1.sync_failed",
            status_code=404,
            json={"errors": [{"code": "not_found", "title": "Schema not found"}]},
        )
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN, validate_schema=True)
        with pytest.raises(ApiError) as exc_info:
            client.emit(VALID_EVENT)
        assert exc_info.value.status == 404
        assert exc_info.value.code == "not_found"

    def test_schema_validation_error_missing_required_field(self, httpx_mock):
        httpx_mock.add_response(
            method="GET",
            url=f"{ENDPOINT}/api/v1/schemas/dataops.v1.sync_failed",
            status_code=200,
            json=SCHEMA_RESPONSE,
        )
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN, validate_schema=True)
        with pytest.raises(SchemaValidationError) as exc_info:
            # Missing required 'records' field
            client.emit({**VALID_EVENT, "payload": {}})

        err = exc_info.value
        assert len(err.failures) > 0

    def test_context_manager_closes_http_client(self, httpx_mock):
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v1/events",
            status_code=202,
            json=ACCEPTED_202,
        )
        with MorpheClient(endpoint=ENDPOINT, token=TOKEN) as client:
            result = client.emit(VALID_EVENT)
        assert result.id is not None


# ── Constructor endpoint resolution ────────────────────────────────────────────

class TestEndpointResolution:
    def test_uses_morphe_endpoint_env_var_when_endpoint_omitted(self, httpx_mock, monkeypatch):
        monkeypatch.setenv("MORPHE_ENDPOINT", ENDPOINT)
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v1/events",
            status_code=202,
            json=ACCEPTED_202,
        )
        client = MorpheClient(token=TOKEN)
        result = client.emit(VALID_EVENT)
        assert result.id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"

    def test_prefers_explicit_endpoint_over_env_var(self, httpx_mock, monkeypatch):
        monkeypatch.setenv("MORPHE_ENDPOINT", "https://should-not-be-used.example.com")
        httpx_mock.add_response(
            method="POST",
            url=f"{ENDPOINT}/api/v1/events",
            status_code=202,
            json=ACCEPTED_202,
        )
        client = MorpheClient(endpoint=ENDPOINT, token=TOKEN)
        result = client.emit(VALID_EVENT)
        assert result.id is not None

    def test_uses_production_endpoint_when_neither_option_nor_env_var_set(self, httpx_mock, monkeypatch):
        monkeypatch.delenv("MORPHE_ENDPOINT", raising=False)
        httpx_mock.add_response(
            method="POST",
            url="https://api.morphe.io/api/v1/events",
            status_code=202,
            json=ACCEPTED_202,
        )
        client = MorpheClient(token=TOKEN)
        result = client.emit(VALID_EVENT)
        assert result.id is not None
