"""Comprehensive unit tests for MCAClient (core/client.py).

Tests cover initialization paths, record_prediction, record_metric, trace,
predict decorator (sync/async), buffer_stats, thresholds, shutdown,
context manager, error handling, registry hydration, and credential sanitization.
Target: >95% coverage for mca_sdk.core.client module.
"""

import asyncio
import logging
import os
import threading
import time
import warnings
from unittest.mock import Mock, MagicMock, patch, PropertyMock

import pytest

from mca_sdk.core.client import (
    MCAClient,
    _sanitize_error_message,
    _prediction_context,
)
from mca_sdk.config.settings import MCAConfig
from mca_sdk.utils.exceptions import (
    ConfigurationError,
    RegistryAuthError,
    RegistryConnectionError,
)


# ---------------------------------------------------------------------------
# Helper: create a client without real OTel exporters hitting localhost
# ---------------------------------------------------------------------------
def make_client(**kwargs):
    """Create MCAClient with defaults suitable for testing."""
    defaults = {
        "service_name": "test-service",
        "model_id": "mdl-001",
        "team_name": "test-team",
        "model_type": "regression",
        "collector_endpoint": "http://localhost:4318",
        "strict_validation": True,
    }
    defaults.update(kwargs)
    return MCAClient(**defaults)


# ---------------------------------------------------------------------------
# _sanitize_error_message Tests
# ---------------------------------------------------------------------------
class TestSanitizeErrorMessage:
    def test_truncation(self):
        msg = "A" * 300
        result = _sanitize_error_message(msg)
        assert len(result) < 300
        assert "...[truncated]" in result

    def test_no_truncation_short_message(self):
        msg = "Short error"
        assert _sanitize_error_message(msg) == msg


# ---------------------------------------------------------------------------
# MCAClient Initialization Tests
# ---------------------------------------------------------------------------
class TestMCAClientInit:
    def test_init_minimal(self):
        client = make_client()
        assert client.config.service_name == "test-service"
        client.shutdown()

    def test_init_with_config_object(self):
        config = MCAConfig(
            service_name="cfg-test",
            model_id="mdl-cfg",
            team_name="team-cfg",
        )
        client = MCAClient(config=config)
        assert client.config.service_name == "cfg-test"
        client.shutdown()

    def test_init_with_debug_mode(self):
        client = make_client(debug_mode=True)
        assert client.config.debug_mode is True
        client.shutdown()

    def test_init_with_buffering(self):
        client = make_client(buffering_enabled=True)
        assert client._retry_policy is not None
        assert client._circuit_breaker_retry_policy is not None
        client.shutdown()

    def test_init_buffering_without_circuit_breaker(self):
        client = make_client(buffering_enabled=True, circuit_breaker_enabled=False)
        assert client._retry_policy is not None
        # Circuit breaker disabled: retry policy used directly
        assert client._circuit_breaker_retry_policy is client._retry_policy
        client.shutdown()

    def test_init_no_buffering(self):
        client = make_client(buffering_enabled=False)
        assert client._retry_policy is None
        assert client._circuit_breaker_retry_policy is None
        client.shutdown()

    def test_init_model_version_defaults_to_package_version(self):
        from mca_sdk import __version__

        client = make_client()
        assert client.config.model_version == __version__
        client.shutdown()

    def test_init_custom_model_version(self):
        client = make_client(model_version="2.0.0")
        assert client.config.model_version == "2.0.0"
        client.shutdown()

    def test_init_permissive_validation(self):
        """Test init with strict_validation=False (permissive mode)."""
        client = MCAClient(
            service_name="test",
            strict_validation=False,
        )
        assert client.config.strict_validation is False
        client.shutdown()

    def test_init_persist_queue_debug(self):
        """Test debug logging for queue persistence."""
        client = make_client(
            debug_mode=True,
            buffering_enabled=True,
            persist_queue=True,
        )
        assert client.config.persist_queue is True
        client.shutdown()

    def test_init_genai_model_type(self):
        client = MCAClient(
            service_name="genai-svc",
            model_type="generative",
            model_category="vendor",
            llm_provider="openai",
            llm_model="gpt-4",
            strict_validation=False,
        )
        assert client._metric_prefix is not None
        client.shutdown()


# ---------------------------------------------------------------------------
# Properties Tests
# ---------------------------------------------------------------------------
class TestProperties:
    def test_meter_property(self):
        client = make_client()
        assert client.meter is not None
        client.shutdown()

    def test_logger_property(self):
        client = make_client()
        assert client.logger is not None
        assert isinstance(client.logger, logging.Logger)
        client.shutdown()

    def test_tracer_property(self):
        client = make_client()
        assert client.tracer is not None
        client.shutdown()


# ---------------------------------------------------------------------------
# Thresholds Property Tests
# ---------------------------------------------------------------------------
class TestThresholds:
    def test_thresholds_no_registry(self):
        client = make_client()
        assert client.thresholds == {}
        client.shutdown()

    def test_thresholds_with_registry_config(self):
        client = make_client()
        # Mock registry config
        mock_config = Mock()
        mock_config.thresholds = {"accuracy": 0.9, "latency": 0.5}
        mock_config.model_id = "mdl-001"
        mock_config.model_version = "1.0"

        mock_registry = Mock()
        mock_registry.fetch_model_config.return_value = mock_config

        client._registry_client = mock_registry
        client._registry_config = mock_config

        result = client.thresholds
        assert result == {"accuracy": 0.9, "latency": 0.5}
        client.shutdown()

    def test_thresholds_fallback_on_fetch_failure(self):
        client = make_client()
        mock_config = Mock()
        mock_config.thresholds = {"accuracy": 0.8}
        mock_config.model_id = "mdl-001"
        mock_config.model_version = "1.0"

        mock_registry = Mock()
        mock_registry.fetch_model_config.side_effect = Exception("fetch failed")

        client._registry_client = mock_registry
        client._registry_config = mock_config

        result = client.thresholds
        assert result == {"accuracy": 0.8}
        client.shutdown()


# ---------------------------------------------------------------------------
# buffer_stats Tests
# ---------------------------------------------------------------------------
class TestBufferStats:
    def test_buffer_stats_disabled(self):
        client = make_client(buffering_enabled=False)
        assert client.buffer_stats() is None
        client.shutdown()

    def test_buffer_stats_enabled(self):
        client = make_client(buffering_enabled=True)
        stats = client.buffer_stats()
        assert stats is not None
        assert stats["enabled"] is True
        assert "per_signal_stats" in stats
        assert "total_queued_items" in stats
        client.shutdown()

    def test_buffer_stats_with_circuit_breaker(self):
        client = make_client(buffering_enabled=True, circuit_breaker_enabled=True)
        stats = client.buffer_stats()
        assert "circuit_breaker" in stats
        client.shutdown()

    def test_buffer_stats_without_circuit_breaker(self):
        client = make_client(buffering_enabled=True, circuit_breaker_enabled=False)
        stats = client.buffer_stats()
        assert stats["circuit_breaker"] is None
        client.shutdown()


# ---------------------------------------------------------------------------
# record_prediction Tests
# ---------------------------------------------------------------------------
class TestRecordPrediction:
    def test_record_prediction_basic(self):
        client = make_client()
        client.record_prediction(latency=0.15)
        client.shutdown()

    def test_record_prediction_with_attributes(self):
        client = make_client()
        client.record_prediction(latency=0.2, status="success", model="v1")
        client.shutdown()

    def test_record_prediction_no_latency(self):
        client = make_client()
        client.record_prediction()
        client.shutdown()

    def test_record_prediction_with_debug(self):
        client = make_client(debug_mode=True)
        client.record_prediction(latency=0.1, custom_attr="test")
        client.shutdown()

    def test_record_prediction_with_long_attribute_debug(self):
        """Test that long attributes are truncated in lenient mode (Story 3.6)."""
        client = make_client(debug_mode=True, strict_validation=False)
        client.record_prediction(long_field="x" * 2000)
        client.shutdown()

    def test_record_prediction_error_does_not_crash(self):
        """Telemetry failure should not crash the application."""
        client = make_client()
        # Break the counter to force an exception
        client._predictions_counter = Mock(side_effect=RuntimeError("otel broken"))
        # Should not raise
        client.record_prediction(latency=0.1)
        client.shutdown()


# ---------------------------------------------------------------------------
# record_metric Tests
# ---------------------------------------------------------------------------
class TestRecordMetric:
    def test_record_metric_basic(self):
        client = make_client()
        client.record_metric("emms_model_accuracy_ratio", 0.95)
        client.shutdown()

    def test_record_metric_with_attributes(self):
        client = make_client()
        client.record_metric("emms_model_accuracy_ratio", 0.95, dataset="test")
        client.shutdown()

    def test_record_metric_cached(self):
        """Second call reuses cached metric instance."""
        client = make_client()
        client.record_metric("emms_model_accuracy_ratio", 0.95)
        client.record_metric("emms_model_accuracy_ratio", 0.92)
        assert "emms_model_accuracy_ratio" in client._cached_metrics
        client.shutdown()


# ---------------------------------------------------------------------------
# trace Tests
# ---------------------------------------------------------------------------
class TestTrace:
    def test_trace_basic(self):
        client = make_client()
        with client.trace("test_operation") as span:
            assert span is not None
        client.shutdown()

    def test_trace_with_attributes(self):
        client = make_client()
        with client.trace("test_op", model_version="1.0", stage="inference"):
            pass
        client.shutdown()


# ---------------------------------------------------------------------------
# predict Decorator Tests
# ---------------------------------------------------------------------------
class TestPredictDecorator:
    def test_sync_decorator_basic(self):
        client = make_client()

        @client.predict()
        def my_predict(data):
            return {"result": data * 2}

        result = my_predict(5)
        assert result == {"result": 10}
        client.shutdown()

    def test_sync_decorator_with_capture(self):
        client = make_client()

        @client.predict(capture_input=True, capture_output=True)
        def my_predict(data):
            return {"result": data}

        result = my_predict("input")
        assert result == {"result": "input"}
        client.shutdown()

    def test_sync_decorator_with_span_name(self):
        client = make_client()

        @client.predict(span_name="custom_span")
        def my_predict():
            return 42

        result = my_predict()
        assert result == 42
        client.shutdown()

    def test_sync_decorator_with_metric_attributes(self):
        client = make_client()

        @client.predict(model_version="2.0")
        def my_predict():
            return "ok"

        result = my_predict()
        assert result == "ok"
        client.shutdown()

    def test_sync_decorator_error_handling(self):
        client = make_client()

        @client.predict()
        def my_predict():
            raise ValueError("prediction failed for patient 123-45-6789")

        with pytest.raises(ValueError, match="prediction failed"):
            my_predict()
        client.shutdown()

    def test_sync_decorator_error_with_capture(self):
        client = make_client()

        @client.predict(capture_input=True)
        def my_predict(data):
            raise RuntimeError("model error")

        with pytest.raises(RuntimeError, match="model error"):
            my_predict("test_data")
        client.shutdown()


# ---------------------------------------------------------------------------
# Registry Hydration Tests
# ---------------------------------------------------------------------------
class TestRegistryHydration:
    @patch("mca_sdk.core.client.RegistryClient")
    @patch.dict("os.environ", {"MCA_REGISTRY_TOKEN": "test-token"})
    def test_hydration_success(self, MockRegistryClient):
        mock_config = Mock()
        mock_config.model_id = "mdl-001"
        mock_config.model_version = "1.0"
        mock_config.team_name = "ai-team"
        mock_config.service_name = "test-service"
        mock_config.thresholds = {"accuracy": 0.9}
        mock_config.extra_resource = {"env": "prod"}

        mock_client = Mock()
        mock_client.fetch_model_config.return_value = mock_config
        MockRegistryClient.return_value = mock_client

        client = make_client(
            registry_url="https://registry.example.com",
            allow_insecure_registry=False,
        )
        assert client._registry_config is not None
        assert client._thresholds == {"accuracy": 0.9}
        client.shutdown()

    @patch("mca_sdk.core.client.RegistryClient")
    @patch.dict("os.environ", {"MCA_REGISTRY_TOKEN": "test-token"})
    def test_hydration_auth_error_raises(self, MockRegistryClient):
        mock_client = Mock()
        mock_client.fetch_model_config.side_effect = RegistryAuthError("unauthorized")
        MockRegistryClient.return_value = mock_client

        with pytest.raises(RegistryAuthError):
            make_client(registry_url="https://registry.example.com")

    @patch("mca_sdk.core.client.RegistryClient")
    @patch.dict("os.environ", {"MCA_REGISTRY_TOKEN": "test-token"})
    def test_hydration_connection_error_fallback(self, MockRegistryClient):
        mock_client = Mock()
        mock_client.fetch_model_config.side_effect = RegistryConnectionError("timeout")
        MockRegistryClient.return_value = mock_client

        client = make_client(registry_url="https://registry.example.com")
        assert client._registry_config is None  # Falls back to local config
        client.shutdown()

    @patch("mca_sdk.core.client.RegistryClient")
    @patch.dict("os.environ", {"MCA_REGISTRY_TOKEN": "test-token"})
    def test_hydration_unexpected_error_fallback(self, MockRegistryClient):
        mock_client = Mock()
        mock_client.fetch_model_config.side_effect = RuntimeError("unexpected")
        MockRegistryClient.return_value = mock_client

        client = make_client(registry_url="https://registry.example.com")
        assert client._registry_config is None
        client.shutdown()

    @patch("mca_sdk.core.client.RegistryClient")
    @patch.dict("os.environ", {"MCA_REGISTRY_TOKEN": "test-token"})
    def test_hydration_with_deployment_id(self, MockRegistryClient):
        mock_config = Mock()
        mock_config.model_id = "mdl-001"
        mock_config.model_version = "1.0"
        mock_config.team_name = None
        mock_config.service_name = "test-service"
        mock_config.thresholds = {}
        mock_config.extra_resource = None

        mock_deployment = Mock()
        mock_deployment.resource_overrides = {"env": "staging"}

        mock_client = Mock()
        mock_client.fetch_model_config.return_value = mock_config
        mock_client.fetch_deployment_config.return_value = mock_deployment
        MockRegistryClient.return_value = mock_client

        client = make_client(
            registry_url="https://registry.example.com",
            deployment_id="deploy-123",
        )
        mock_client.fetch_deployment_config.assert_called_once_with("deploy-123")
        client.shutdown()

    @patch("mca_sdk.core.client.RegistryClient")
    @patch.dict("os.environ", {"MCA_REGISTRY_TOKEN": "test-token"})
    def test_hydration_service_name_mismatch_warns(self, MockRegistryClient, caplog):
        mock_config = Mock()
        mock_config.model_id = "mdl-001"
        mock_config.model_version = "1.0"
        mock_config.team_name = None
        mock_config.service_name = "different-service"
        mock_config.thresholds = {}
        mock_config.extra_resource = None

        mock_client = Mock()
        mock_client.fetch_model_config.return_value = mock_config
        MockRegistryClient.return_value = mock_client

        with caplog.at_level(logging.WARNING):
            client = make_client(registry_url="https://registry.example.com")
        assert any("differs" in r.message for r in caplog.records)
        client.shutdown()

    @patch("mca_sdk.core.client.RegistryClient")
    @patch.dict("os.environ", {"MCA_REGISTRY_TOKEN": "test-token"})
    def test_hydration_debug_mode(self, MockRegistryClient):
        mock_config = Mock()
        mock_config.model_id = "mdl-001"
        mock_config.model_version = "1.0"
        mock_config.team_name = "team"
        mock_config.service_name = "test-service"
        mock_config.thresholds = {"a": 1}
        mock_config.extra_resource = None

        mock_client = Mock()
        mock_client.fetch_model_config.return_value = mock_config
        MockRegistryClient.return_value = mock_client

        client = make_client(
            registry_url="https://registry.example.com",
            debug_mode=True,
        )
        client.shutdown()

    @patch("mca_sdk.core.client.RegistryClient")
    @patch.dict("os.environ", {"MCA_REGISTRY_TOKEN": "test-token"})
    def test_thresholds_added_to_resource_attributes(self, MockRegistryClient):
        """Test that thresholds from registry are added as resource attributes."""
        mock_config = Mock()
        mock_config.model_id = "mdl-001"
        mock_config.model_version = "1.0"
        mock_config.team_name = "ai-team"
        mock_config.service_name = "test-service"
        mock_config.thresholds = {"latency_ms": 500, "accuracy_min": 0.85, "error_rate_warn": 0.05}
        mock_config.extra_resource = {"env": "production"}

        mock_client = Mock()
        mock_client.fetch_model_config.return_value = mock_config
        MockRegistryClient.return_value = mock_client

        client = make_client(registry_url="https://registry.example.com")

        # Verify thresholds are stored
        assert client._thresholds == {
            "latency_ms": 500,
            "accuracy_min": 0.85,
            "error_rate_warn": 0.05,
        }

        # Verify resource attributes include thresholds with "threshold." prefix
        resource_attrs = client._resource.attributes
        assert "threshold.latency_ms" in resource_attrs
        assert resource_attrs["threshold.latency_ms"] == 500
        assert "threshold.accuracy_min" in resource_attrs
        assert resource_attrs["threshold.accuracy_min"] == 0.85
        assert "threshold.error_rate_warn" in resource_attrs
        assert resource_attrs["threshold.error_rate_warn"] == 0.05

        # Verify extra_resource attributes are also present
        assert "env" in resource_attrs
        assert resource_attrs["env"] == "production"

        client.shutdown()

    def test_convert_thresholds_to_attributes(self):
        """Test the threshold conversion helper method."""
        client = make_client()

        thresholds = {"latency_ms": 500, "accuracy_min": 0.85, "cost_per_1k": 0.002}

        result = client._convert_thresholds_to_attributes(thresholds)

        assert result == {
            "threshold.latency_ms": 500,
            "threshold.accuracy_min": 0.85,
            "threshold.cost_per_1k": 0.002,
        }

        client.shutdown()


# ---------------------------------------------------------------------------
# _validate_resource_attributes Tests
# ---------------------------------------------------------------------------
class TestValidateResourceAttributes:
    def test_strict_validation_pass(self):
        client = make_client(
            model_id="mdl-001",
            team_name="ai-team",
            strict_validation=True,
        )
        client.shutdown()

    def test_permissive_validation_missing_fields(self):
        """Permissive mode logs warnings but doesn't raise."""
        client = MCAClient(
            service_name="test",
            strict_validation=False,
        )
        client.shutdown()

    def test_strict_validation_failure_raises(self):
        """Strict mode with invalid model category should fail."""
        with pytest.raises((ConfigurationError, Exception)):
            make_client(
                service_name="test",
                model_category="invalid",
            )


# ---------------------------------------------------------------------------
# Shutdown Tests
# ---------------------------------------------------------------------------
class TestShutdown:
    def test_shutdown_basic(self):
        client = make_client()
        client.shutdown()

    def test_shutdown_with_registry(self):
        client = make_client()
        mock_registry = Mock()
        client._registry_client = mock_registry
        client.shutdown()
        mock_registry.stop_refresh_thread.assert_called_once()
        mock_registry.close.assert_called_once()

    def test_shutdown_custom_timeout(self):
        client = make_client()
        client.shutdown(timeout_millis=5000)


# ---------------------------------------------------------------------------
# Context Manager Tests
# ---------------------------------------------------------------------------
class TestContextManager:
    def test_context_manager_enter_exit(self):
        with make_client() as client:
            assert client is not None
            client.record_prediction(latency=0.1)
        # shutdown called automatically

    def test_context_manager_on_exception(self):
        """Ensure shutdown is called even when exception occurs."""
        with pytest.raises(ValueError):
            with make_client() as client:
                raise ValueError("test error")


# ---------------------------------------------------------------------------
# Async Predict Decorator Tests
# ---------------------------------------------------------------------------
class TestAsyncPredictDecorator:
    def test_async_decorator_basic(self):
        client = make_client()

        @client.predict()
        async def my_predict(data):
            return {"result": data * 2}

        result = asyncio.run(my_predict(5))
        assert result == {"result": 10}
        client.shutdown()

    def test_async_decorator_with_capture(self):
        client = make_client()

        @client.predict(capture_input=True, capture_output=True)
        async def my_predict(data):
            return {"result": data}

        result = asyncio.run(my_predict("test"))
        assert result == {"result": "test"}
        client.shutdown()

    def test_async_decorator_error_handling(self):
        client = make_client()

        @client.predict()
        async def my_predict():
            raise ValueError("async prediction failed")

        with pytest.raises(ValueError, match="async prediction failed"):
            asyncio.run(my_predict())
        client.shutdown()

    def test_async_decorator_with_metric_attributes(self):
        client = make_client()

        @client.predict(span_name="async_op", model_version="3.0")
        async def my_predict():
            return "async ok"

        result = asyncio.run(my_predict())
        assert result == "async ok"
        client.shutdown()


# ---------------------------------------------------------------------------
# Validate Resource Attributes - Permissive mode detailed Tests
# ---------------------------------------------------------------------------
class TestValidateResourceAttributesPermissive:
    def test_permissive_with_validation_warnings(self, caplog):
        """Permissive mode should log warnings, not raise."""
        with caplog.at_level(logging.WARNING):
            client = MCAClient(
                service_name="test",
                strict_validation=False,
            )
        client.shutdown()

    def test_permissive_with_invalid_model_category(self, caplog):
        """Permissive mode should handle ValueError from unknown model_category."""
        with caplog.at_level(logging.WARNING):
            # model_category validation happens in MCAConfig.__post_init__, not in
            # _validate_resource_attributes. So this tests strict=False allowing partial config.
            client = MCAClient(
                service_name="test",
                strict_validation=False,
                model_type="generative",
                model_category="vendor",
                llm_provider="openai",
                llm_model="gpt-4",
            )
        client.shutdown()


# ---------------------------------------------------------------------------
# Registry Hydration Debug Error Paths Tests
# ---------------------------------------------------------------------------
class TestRegistryDebugPaths:
    @patch("mca_sdk.core.client.RegistryClient")
    @patch.dict("os.environ", {"MCA_REGISTRY_TOKEN": "test-token"})
    def test_hydration_auth_error_debug_mode(self, MockRegistryClient):
        mock_client = Mock()
        mock_client.fetch_model_config.side_effect = RegistryAuthError("bad token")
        MockRegistryClient.return_value = mock_client

        with pytest.raises(RegistryAuthError):
            make_client(
                registry_url="https://registry.example.com",
                debug_mode=True,
            )

    @patch("mca_sdk.core.client.RegistryClient")
    @patch.dict("os.environ", {"MCA_REGISTRY_TOKEN": "test-token"})
    def test_hydration_connection_error_debug_mode(self, MockRegistryClient):
        mock_client = Mock()
        mock_client.fetch_model_config.side_effect = RegistryConnectionError("timeout")
        MockRegistryClient.return_value = mock_client

        client = make_client(
            registry_url="https://registry.example.com",
            debug_mode=True,
        )
        client.shutdown()

    @patch("mca_sdk.core.client.RegistryClient")
    @patch.dict("os.environ", {"MCA_REGISTRY_TOKEN": "test-token"})
    def test_hydration_unexpected_error_debug_mode(self, MockRegistryClient):
        mock_client = Mock()
        mock_client.fetch_model_config.side_effect = RuntimeError("unexpected")
        MockRegistryClient.return_value = mock_client

        client = make_client(
            registry_url="https://registry.example.com",
            debug_mode=True,
        )
        client.shutdown()


# ---------------------------------------------------------------------------
# buffer_stats edge case
# ---------------------------------------------------------------------------
class TestBufferStatsEdge:
    def test_buffer_stats_no_circuit_breaker_stats(self):
        """Test buffer_stats when circuit_breaker_retry_policy has no get_stats."""
        client = make_client(buffering_enabled=True, circuit_breaker_enabled=True)
        # Remove get_stats to simulate missing method
        if hasattr(client._circuit_breaker_retry_policy, "get_stats"):
            delattr(type(client._circuit_breaker_retry_policy), "get_stats")
        stats = client.buffer_stats()
        assert stats is not None
        client.shutdown()


class TestFlush:
    def test_flush_calls_providers(self):
        client = make_client()
        mock_meter = Mock()
        mock_logger = Mock()
        mock_tracer = Mock()
        client._meter_provider = mock_meter
        client._logger_provider = mock_logger
        client._tracer_provider = mock_tracer

        success = client.flush(timeout_millis=1000)

        assert success is True
        mock_meter.force_flush.assert_called_once_with(timeout_millis=1000)
        mock_logger.force_flush.assert_called_once_with(timeout_millis=1000)
        mock_tracer.force_flush.assert_called_once_with(timeout_millis=1000)

    def test_flush_after_shutdown(self):
        client = make_client()
        client.shutdown()
        assert client.flush() is False
