"""Unit tests for telemetry attribute validation (Story 3.6).

Tests validation of attributes passed to record_prediction() and similar
telemetry recording methods.
"""

import pytest

from mca_sdk.validation import TelemetryAttributeValidator
from mca_sdk.utils.exceptions import ValidationError


class TestTelemetryAttributeValidator:
    """Test suite for TelemetryAttributeValidator class."""

    def test_valid_attributes_pass_validation(self):
        """Test that valid attributes pass validation in strict mode."""
        attributes = {
            "model_id": "my-model",
            "version": "1.0.0",
            "environment": "production",
        }

        result = TelemetryAttributeValidator.validate(attributes, strict=True)

        assert result == {
            "model_id": "my-model",
            "version": "1.0.0",
            "environment": "production",
        }

    def test_attribute_value_length_exceeds_limit_strict_mode(self):
        """Test that long attribute values raise ValidationError in strict mode (AC #1)."""
        max_len = TelemetryAttributeValidator.MAX_VALUE_LENGTH
        long_value = "x" * (max_len + 1)  # Exceeds limit
        attributes = {"description": long_value}

        with pytest.raises(ValidationError, match="value length.*exceeds maximum"):
            TelemetryAttributeValidator.validate(attributes, strict=True)

    def test_attribute_value_length_exceeds_limit_lenient_mode(self):
        """Test that long attribute values are truncated in lenient mode (AC #1)."""
        max_len = TelemetryAttributeValidator.MAX_VALUE_LENGTH
        long_value = "x" * (max_len + 1)  # Exceeds limit
        attributes = {"description": long_value}

        result = TelemetryAttributeValidator.validate(attributes, strict=False)

        # Result should be exactly max_len chars: (max_len - 11) x's + "[truncated]"
        assert len(result["description"]) == max_len
        truncate_suffix_len = len("[truncated]")
        expected_x_count = max_len - truncate_suffix_len
        assert result["description"] == ("x" * expected_x_count) + "[truncated]"

    def test_attribute_value_at_exact_limit_passes(self):
        """Test that attribute values at exactly MAX_VALUE_LENGTH chars pass validation."""
        max_len = TelemetryAttributeValidator.MAX_VALUE_LENGTH
        exact_limit_value = "x" * max_len
        attributes = {"description": exact_limit_value}

        result = TelemetryAttributeValidator.validate(attributes, strict=True)

        assert result["description"] == exact_limit_value

    def test_invalid_attribute_name_strict_mode(self):
        """Test that invalid attribute names raise ValidationError in strict mode (AC #2)."""
        # Test various invalid names
        invalid_names = [
            "123invalid",  # Starts with number
            "invalid-name",  # Contains hyphen
            "invalid name",  # Contains space
            "invalid@name",  # Contains special char
            "invalid#name",  # Contains special char
        ]

        for invalid_name in invalid_names:
            attributes = {invalid_name: "value"}
            with pytest.raises(ValidationError, match="Invalid attribute name"):
                TelemetryAttributeValidator.validate(attributes, strict=True)

    def test_invalid_attribute_name_lenient_mode(self):
        """Test that invalid attribute names are skipped in lenient mode (AC #2)."""
        attributes = {
            "valid_name": "kept",
            "123invalid": "dropped",
            "also_valid": "kept",
            "invalid-name": "dropped",
        }

        result = TelemetryAttributeValidator.validate(attributes, strict=False)

        assert result == {
            "valid_name": "kept",
            "also_valid": "kept",
        }

    def test_valid_attribute_names_with_dots_and_underscores(self):
        """Test that attribute names with dots and underscores are valid (AC #2)."""
        attributes = {
            "model_id": "value1",
            "model.version": "value2",
            "team_name": "value3",
            "service.name": "value4",
        }

        result = TelemetryAttributeValidator.validate(attributes, strict=True)

        assert result == attributes

    def test_attribute_count_exceeds_limit_strict_mode(self):
        """Test that too many attributes raise ValidationError in strict mode (AC #3)."""
        # Create 101 attributes (exceeds 100 limit)
        attributes = {f"attr_{i}": f"value_{i}" for i in range(101)}

        with pytest.raises(ValidationError, match="Attribute count .* exceeds maximum"):
            TelemetryAttributeValidator.validate(attributes, strict=True)

    def test_attribute_count_exceeds_limit_lenient_mode(self):
        """Test that excess attributes are dropped in lenient mode (AC #3)."""
        # Create 105 attributes (exceeds 100 limit)
        attributes = {f"attr_{i}": f"value_{i}" for i in range(105)}

        result = TelemetryAttributeValidator.validate(attributes, strict=False)

        # Should keep only first 100
        assert len(result) == 100
        # Verify first 100 alphabetically are kept (implementation sorts for determinism)
        # Sorted order: attr_0, attr_1, attr_10, ..., attr_19, attr_2, attr_20, ..., attr_99
        sorted_keys = sorted(attributes.keys())
        expected_keys = sorted_keys[:100]
        for key in expected_keys:
            assert key in result

    def test_attribute_count_at_exact_limit_passes(self):
        """Test that exactly 100 attributes pass validation."""
        attributes = {f"attr_{i}": f"value_{i}" for i in range(100)}

        result = TelemetryAttributeValidator.validate(attributes, strict=True)

        assert len(result) == 100

    def test_empty_attributes_pass_validation(self):
        """Test that empty attribute dict passes validation."""
        attributes = {}

        result = TelemetryAttributeValidator.validate(attributes, strict=True)

        assert result == {}

    def test_non_string_values_preserved(self):
        """Test that native OpenTelemetry types (int, float, bool) are preserved."""
        attributes = {
            "count": 42,
            "ratio": 3.14,
            "enabled": True,
            "items": None,
        }

        result = TelemetryAttributeValidator.validate(attributes, strict=True)

        # Native OTel types preserved, None converted to empty string
        assert result["count"] == 42
        assert result["ratio"] == 3.14
        assert result["enabled"] is True
        assert result["items"] == ""

    def test_numeric_values_preserved_not_validated_for_length(self):
        """Test that numeric types bypass string length validation but enforce bounds (Security fix)."""
        # Create a reasonable large number within safe bounds
        large_number = 2**50  # Large but within JavaScript safe integer range

        attributes = {"large_value": large_number}

        # Should NOT raise - numeric value is within safe bounds
        result = TelemetryAttributeValidator.validate(attributes, strict=True)
        assert result["large_value"] == large_number

    def test_numeric_values_out_of_bounds_raises_error(self):
        """Test that extremely large numeric values are rejected (DoS prevention)."""
        # Create a massive number that exceeds safe bounds
        massive_number = 10**1025  # Far exceeds JavaScript safe integer range

        attributes = {"large_value": massive_number}

        # Should raise - numeric value out of safe bounds
        with pytest.raises(ValidationError, match="integer value out of safe bounds"):
            TelemetryAttributeValidator.validate(attributes, strict=True)

    def test_numeric_values_out_of_bounds_clamped_lenient(self):
        """Test that huge numbers are clamped in lenient mode (DoS prevention)."""
        # Create massive numbers that exceed safe bounds
        massive_positive = 10**1025
        massive_negative = -(10**1025)

        attributes = {
            "huge_positive": massive_positive,
            "huge_negative": massive_negative,
        }

        # Should clamp, not raise
        result = TelemetryAttributeValidator.validate(attributes, strict=False)

        # Verify clamping to JavaScript safe integer limits
        assert result["huge_positive"] == TelemetryAttributeValidator.MAX_INT_VALUE
        assert result["huge_negative"] == TelemetryAttributeValidator.MIN_INT_VALUE

    def test_float_values_out_of_bounds_clamped_lenient(self):
        """Test that huge floats are clamped in lenient mode."""
        # Use sys.float_info.max which is finite but larger than MAX_FLOAT_ABS
        import sys

        massive_float = sys.float_info.max  # ~1.8e308, exceeds MAX_FLOAT_ABS = 1e308

        attributes = {
            "huge_float_positive": massive_float,
            "huge_float_negative": -massive_float,
        }

        result = TelemetryAttributeValidator.validate(attributes, strict=False)

        # Verify clamping to float limits
        assert result["huge_float_positive"] == TelemetryAttributeValidator.MAX_FLOAT_ABS
        assert result["huge_float_negative"] == -TelemetryAttributeValidator.MAX_FLOAT_ABS

    def test_truncation_with_very_small_limit(self):
        """Test truncation when max_value_length < len('[truncated]') - edge case fix."""
        # Test edge case: limit too small for suffix
        attributes = {"test": "x" * 20}

        result = TelemetryAttributeValidator.validate(
            attributes,
            strict=False,
            max_value_length=5,  # Less than len("[truncated]") = 11
        )

        # Should truncate hard without suffix when limit is too small
        assert len(result["test"]) == 5
        assert result["test"] == "xxxxx"
        assert "[truncated]" not in result["test"]

    def test_truncation_at_suffix_boundary(self):
        """Test truncation exactly at suffix length boundary."""
        # Test at boundary: limit equals suffix length
        attributes = {"test": "x" * 20}

        result = TelemetryAttributeValidator.validate(
            attributes,
            strict=False,
            max_value_length=11,  # Exactly len("[truncated]")
        )

        # Should use suffix since there's exactly enough room
        assert len(result["test"]) == 11
        assert result["test"] == "[truncated]"

    def test_combined_validation_errors_strict_mode(self):
        """Test that first validation error is raised when multiple violations exist."""
        # Multiple violations: too many attributes AND long values
        attributes = {f"attr_{i}": "x" * 1025 for i in range(101)}

        # Should fail on attribute count first (checked before individual values)
        with pytest.raises(ValidationError, match="Attribute count"):
            TelemetryAttributeValidator.validate(attributes, strict=True)

    def test_combined_validation_errors_lenient_mode(self):
        """Test that all validation errors are handled in lenient mode."""
        attributes = {
            "valid_attr": "valid_value",
            "123invalid": "dropped_name",
            "long_attr": "x" * 1025,  # Will be truncated
        }

        result = TelemetryAttributeValidator.validate(attributes, strict=False)

        assert "valid_attr" in result
        assert "123invalid" not in result  # Dropped due to invalid name
        assert "long_attr" in result
        # Truncated to exactly 1024: (1024-11) x's + "[truncated]"
        assert len(result["long_attr"]) == 1024
        assert result["long_attr"] == ("x" * 1013) + "[truncated]"

    def test_valid_name_pattern_edge_cases(self):
        """Test edge cases for valid attribute name pattern."""
        valid_names = {
            "a": "single_letter",
            "A": "uppercase_letter",
            "a1": "letter_then_number",
            "a_b": "underscore",
            "a.b": "dot",
            "a1_b2.c3": "mixed",
            "modelId": "camelCase",
            "model_id": "snake_case",
            "model.id": "dot_notation",
        }

        result = TelemetryAttributeValidator.validate(valid_names, strict=True)

        assert result == {k: v for k, v in valid_names.items()}

    def test_validation_constants_match_spec(self):
        """Test that validation constants match story requirements."""
        assert TelemetryAttributeValidator.MAX_VALUE_LENGTH == 1024
        assert TelemetryAttributeValidator.MAX_ATTRIBUTE_COUNT == 100
        assert (
            TelemetryAttributeValidator.VALID_NAME_PATTERN.pattern == r"^[a-zA-Z_][a-zA-Z0-9_.]*$"
        )

    # Additional tests for missing coverage

    def test_non_dict_input_strict_mode(self):
        """Test that non-dict input raises ValidationError in strict mode."""
        with pytest.raises(ValidationError, match="must be a dictionary"):
            TelemetryAttributeValidator.validate("not_a_dict", strict=True)

        with pytest.raises(ValidationError, match="must be a dictionary"):
            TelemetryAttributeValidator.validate([("key", "value")], strict=True)

    def test_non_dict_input_lenient_mode(self):
        """Test that non-dict input returns empty dict in lenient mode."""
        result = TelemetryAttributeValidator.validate("not_a_dict", strict=False)
        assert result == {}

        result = TelemetryAttributeValidator.validate([("key", "value")], strict=False)
        assert result == {}

    def test_list_validation_with_primitives(self):
        """Test that lists of primitives are accepted (OpenTelemetry array support)."""
        attributes = {
            "tags": ["tag1", "tag2", "tag3"],
            "scores": [1, 2, 3, 4, 5],
            "flags": [True, False, True],
            "mixed": ["a", 1, True, None],
        }

        result = TelemetryAttributeValidator.validate(attributes, strict=True)

        assert result["tags"] == ["tag1", "tag2", "tag3"]
        assert result["scores"] == [1, 2, 3, 4, 5]
        assert result["flags"] == [True, False, True]
        assert result["mixed"] == ["a", 1, True, None]

    def test_list_validation_empty_list(self):
        """Test that empty lists are accepted."""
        attributes = {"empty": []}

        result = TelemetryAttributeValidator.validate(attributes, strict=True)

        assert result["empty"] == []

    def test_list_validation_with_long_strings_strict(self):
        """Test that lists with long strings raise error in strict mode."""
        attributes = {"tags": ["short", "x" * 1025]}  # Second item exceeds limit

        with pytest.raises(ValidationError, match="list item.*exceeds maximum length"):
            TelemetryAttributeValidator.validate(attributes, strict=True)

    def test_list_validation_with_long_strings_lenient(self):
        """Test that lists with long strings are truncated in lenient mode."""
        attributes = {"tags": ["short", "x" * 1025]}  # Second item exceeds limit

        result = TelemetryAttributeValidator.validate(attributes, strict=False)

        assert result["tags"][0] == "short"
        assert len(result["tags"][1]) == 1024
        assert result["tags"][1].endswith("[truncated]")

    def test_list_validation_with_non_primitives_strict(self):
        """Test that lists with non-primitives raise error in strict mode."""
        attributes = {"invalid": ["valid", {"nested": "dict"}]}  # Dict not allowed

        with pytest.raises(ValidationError, match="non-primitive type"):
            TelemetryAttributeValidator.validate(attributes, strict=True)

    def test_list_validation_with_non_primitives_lenient(self):
        """Test that lists with non-primitives skip invalid items in lenient mode."""
        attributes = {"mixed": ["valid", {"nested": "dict"}, "also_valid", [1, 2, 3]]}

        result = TelemetryAttributeValidator.validate(attributes, strict=False)

        # Non-primitives (dict and nested list) skipped
        assert result["mixed"] == ["valid", "also_valid"]

    def test_list_item_truncation_very_small_limit(self):
        """Test list item truncation when limit < suffix length."""
        attributes = {"tags": ["short", "x" * 20]}

        result = TelemetryAttributeValidator.validate(
            attributes,
            strict=False,
            max_value_length=5,  # Less than "[truncated]" length
        )

        assert result["tags"][0] == "short"
        assert result["tags"][1] == "xxxxx"  # Hard truncate without suffix

    def test_tuple_treated_as_list(self):
        """Test that tuples are validated like lists."""
        attributes = {"tuple_attr": ("a", "b", "c")}

        result = TelemetryAttributeValidator.validate(attributes, strict=True)

        assert result["tuple_attr"] == ["a", "b", "c"]

    def test_infinity_values_rejected_in_strict(self):
        """Test that infinity values are rejected in strict mode."""
        attributes = {
            "pos_inf": float("inf"),
            "neg_inf": float("-inf"),
        }

        with pytest.raises(ValidationError, match="float value is inf or nan"):
            TelemetryAttributeValidator.validate(attributes, strict=True)

        # Lenient mode clamps
        result = TelemetryAttributeValidator.validate(attributes, strict=False)
        assert result["pos_inf"] == TelemetryAttributeValidator.MAX_FLOAT_ABS
        assert result["neg_inf"] == -TelemetryAttributeValidator.MAX_FLOAT_ABS

    def test_large_non_string_type_exceeds_estimated_size_strict(self):
        """Test that large objects are rejected based on size estimation in strict mode."""
        # Create a large dict that would exceed limit when converted to string
        large_dict = {f"key_{i}": f"value_{i}" for i in range(1000)}

        attributes = {"large_obj": large_dict}

        with pytest.raises(ValidationError, match="estimated size.*exceeds maximum"):
            TelemetryAttributeValidator.validate(attributes, strict=True, max_value_length=100)

    def test_large_non_string_type_exceeds_estimated_size_lenient(self):
        """Test that large objects get placeholder in lenient mode."""
        # Create a large dict
        large_dict = {f"key_{i}": f"value_{i}" for i in range(1000)}

        attributes = {"large_obj": large_dict}

        result = TelemetryAttributeValidator.validate(
            attributes, strict=False, max_value_length=100
        )

        # Should use placeholder instead of converting
        assert "<dict:too_large>" in result["large_obj"]

    def test_string_conversion_error_strict(self):
        """Test that objects that can't be converted to string raise error in strict mode."""

        bad_dict = {}
        bad_dict["self"] = bad_dict  # Circular reference

        attributes = {"bad_obj": bad_dict}

        with pytest.raises(ValidationError, match="cannot be converted to string"):
            TelemetryAttributeValidator.validate(attributes, strict=True)

    def test_string_conversion_error_lenient(self):
        """Test that objects that can't be converted get placeholder in lenient mode."""

        class UnconvertibleObject:
            def __str__(self):
                raise RuntimeError("Cannot convert to string")

        attributes = {"bad_obj": UnconvertibleObject()}

        result = TelemetryAttributeValidator.validate(attributes, strict=False)

        # Should use error placeholder
        assert "<UnconvertibleObject:error>" in result["bad_obj"]

    def test_size_estimation_with_sizeof(self):
        """Test size estimation for objects with __sizeof__."""

        # Custom object with __sizeof__ but not __len__
        class SizedObject:
            def __sizeof__(self):
                return 50

            def __str__(self):
                return "sized_object"

        attributes = {"sized_obj": SizedObject()}

        # Should not raise - uses __sizeof__ for estimation in lenient mode
        result = TelemetryAttributeValidator.validate(
            attributes, strict=False, max_value_length=1024
        )

        # Should be converted to string
        assert result["sized_obj"] == "sized_object"

    def test_size_estimation_with_len(self):
        """Test size estimation for objects with __len__."""

        # Custom object with __len__
        class CustomCollection:
            def __len__(self):
                return 50

            def __str__(self):
                return "custom"

        attributes = {"custom": CustomCollection()}

        result = TelemetryAttributeValidator.validate(
            attributes, strict=False, max_value_length=1024
        )

        assert result["custom"] == "custom"

    def test_non_string_value_truncation_after_conversion(self):
        """Test that non-string values are truncated after str() conversion if needed."""

        # Object that converts to very long string
        class LongStringObject:
            def __str__(self):
                return "x" * 1025

        attributes = {"obj": LongStringObject()}

        result = TelemetryAttributeValidator.validate(attributes, strict=False)

        assert len(result["obj"]) == 1024
        assert result["obj"].endswith("[truncated]")


class TestTelemetryAttributeEdgeCases:
    """Additional tests for edge cases and missing coverage."""

    def test_none_value_skipped_in_validation(self):
        """Test that None values are skipped during validation."""
        attributes = {
            "valid_key": "valid_value",
            "none_key": None,
            "another_valid": 123,
        }

        result = TelemetryAttributeValidator.validate(attributes, strict=True)

        assert "valid_key" in result
        assert "another_valid" in result

    def test_string_truncation_without_suffix(self):
        """Test string truncation when max_value_length is very small."""
        attributes = {"key": "This is a long string that needs truncation"}

        result = TelemetryAttributeValidator.validate(attributes, strict=False, max_value_length=10)

        assert len(result["key"]) == 10

    def test_estimate_size_for_known_type(self):
        """Test _estimate_size returns correct estimate for known types."""
        size_bool = TelemetryAttributeValidator._estimate_size(True)
        size_int = TelemetryAttributeValidator._estimate_size(42)
        size_float = TelemetryAttributeValidator._estimate_size(3.14)

        assert isinstance(size_bool, int)
        assert isinstance(size_int, int)
        assert isinstance(size_float, int)
