# Copyright The OpenTelemetry Authors
# SPDX-License-Identifier: Apache-2.0

# type: ignore

import copy
import logging
import threading
import unittest
import unittest.mock
from collections.abc import MutableSequence

from opentelemetry.attributes import (
    BoundedAttributes,
    _clean_attribute,
    _clean_extended_attribute,
    _clean_extended_attribute_value,
)


class TestAttributes(unittest.TestCase):
    # pylint: disable=invalid-name
    def assertValid(self, value, key="k"):
        expected = value
        if isinstance(value, MutableSequence):
            expected = tuple(value)
        self.assertEqual(_clean_attribute(key, value, None), expected)

    def assertInvalid(self, value, key="k"):
        self.assertIsNone(_clean_attribute(key, value, None))

    def test_attribute_key_validation(self):
        # only non-empty strings are valid keys
        self.assertInvalid(1, "")
        self.assertInvalid(1, 1)
        self.assertInvalid(1, {})
        self.assertInvalid(1, [])
        self.assertInvalid(1, b"1")
        self.assertValid(1, "k")
        self.assertValid(1, "1")

    def test_clean_attribute(self):
        self.assertInvalid([1, 2, 3.4, "ss", 4])
        self.assertInvalid([{}, 1, 2, 3.4, 4])
        self.assertInvalid(["sw", "lf", 3.4, "ss"])
        self.assertInvalid([1, 2, 3.4, 5])
        self.assertInvalid({})
        self.assertInvalid([1, True])
        self.assertValid(True)
        self.assertValid("hi")
        self.assertValid(3.4)
        self.assertValid(15)
        self.assertValid([1, 2, 3, 5])
        self.assertValid([1.2, 2.3, 3.4, 4.5])
        self.assertValid([True, False])
        self.assertValid(["ss", "dw", "fw"])
        self.assertValid([])
        # None in sequences are valid
        self.assertValid(["A", None, None])
        self.assertValid(["A", None, None, "B"])
        self.assertValid([None, None])
        self.assertInvalid(["A", None, 1])
        self.assertInvalid([None, "A", None, 1])

        # test keys
        self.assertValid("value", "key")
        self.assertInvalid("value", "")
        self.assertInvalid("value", None)

    def test_sequence_attr_decode(self):
        seq = [
            None,
            b"Content-Disposition",
            b"Content-Type",
            b"\x81",
            b"Keep-Alive",
        ]
        expected = [
            None,
            "Content-Disposition",
            "Content-Type",
            None,
            "Keep-Alive",
        ]
        self.assertEqual(
            _clean_attribute("headers", seq, None), tuple(expected)
        )


class TestExtendedAttributes(unittest.TestCase):
    # pylint: disable=invalid-name
    def assertValid(self, value, key="k"):
        expected = value
        if isinstance(value, MutableSequence):
            expected = tuple(value)
        self.assertEqual(_clean_extended_attribute(key, value, None), expected)

    def assertInvalid(self, value, key="k"):
        self.assertIsNone(_clean_extended_attribute(key, value, None))

    def test_attribute_key_validation(self):
        # only non-empty strings are valid keys
        self.assertInvalid(1, "")
        self.assertInvalid(1, 1)
        self.assertInvalid(1, {})
        self.assertInvalid(1, [])
        self.assertInvalid(1, b"1")
        self.assertValid(1, "k")
        self.assertValid(1, "1")

    def test_clean_extended_attribute(self):
        self.assertInvalid([1, 2, 3.4, "ss", 4])
        self.assertInvalid([{}, 1, 2, 3.4, 4])
        self.assertInvalid(["sw", "lf", 3.4, "ss"])
        self.assertInvalid([1, 2, 3.4, 5])
        self.assertInvalid([1, True])
        self.assertValid(None)
        self.assertValid(True)
        self.assertValid("hi")
        self.assertValid(3.4)
        self.assertValid(15)
        self.assertValid([1, 2, 3, 5])
        self.assertValid([1.2, 2.3, 3.4, 4.5])
        self.assertValid([True, False])
        self.assertValid(["ss", "dw", "fw"])
        self.assertValid([])
        # None in sequences are valid
        self.assertValid(["A", None, None])
        self.assertValid(["A", None, None, "B"])
        self.assertValid([None, None])
        self.assertInvalid(["A", None, 1])
        self.assertInvalid([None, "A", None, 1])
        # mappings
        self.assertValid({})
        self.assertValid({"k": "v"})
        # mappings in sequences
        self.assertValid([{"k": "v"}])

        # test keys
        self.assertValid("value", "key")
        self.assertInvalid("value", "")
        self.assertInvalid("value", None)

    def test_sequence_attr_decode(self):
        seq = [
            None,
            b"Content-Disposition",
            b"Content-Type",
            b"\x81",
            b"Keep-Alive",
        ]
        self.assertEqual(
            _clean_extended_attribute("headers", seq, None), tuple(seq)
        )

    def test_mapping(self):
        mapping = {
            "": "invalid",
            b"bytes": "invalid",
            "none": {"": "invalid"},
            "valid_primitive": "str",
            "valid_sequence": ["str"],
            "invalid_sequence": ["str", 1],
            "valid_mapping": {"str": 1},
            "invalid_mapping": {"": 1},
        }
        expected = {
            "none": {},
            "valid_primitive": "str",
            "valid_sequence": ("str",),
            "invalid_sequence": None,
            "valid_mapping": {"str": 1},
            "invalid_mapping": {},
        }
        self.assertEqual(
            _clean_extended_attribute("headers", mapping, None), expected
        )


class TestBoundedAttributes(unittest.TestCase):
    # pylint: disable=consider-using-dict-items
    base = {
        "name": "Firulais",
        "age": 7,
        "weight": 13,
        "vaccinated": True,
    }

    def test_negative_maxlen(self):
        with self.assertRaises(ValueError):
            BoundedAttributes(-1)

    def test_from_map(self):
        dic_len = len(self.base)
        base_copy = self.base.copy()
        bdict = BoundedAttributes(dic_len, base_copy)

        self.assertEqual(len(bdict), dic_len)

        # modify base_copy and test that bdict is not changed
        base_copy["name"] = "Bruno"
        base_copy["age"] = 3

        for key in self.base:
            self.assertEqual(bdict[key], self.base[key])

        # test that iter yields the correct number of elements
        self.assertEqual(len(tuple(bdict)), dic_len)

        # map too big
        half_len = dic_len // 2
        bdict = BoundedAttributes(half_len, self.base)
        self.assertEqual(len(tuple(bdict)), half_len)
        self.assertEqual(bdict.dropped, dic_len - half_len)

    def test_bounded_dict(self):
        # create empty dict
        dic_len = len(self.base)
        bdict = BoundedAttributes(dic_len, immutable=False)
        self.assertEqual(len(bdict), 0)

        # fill dict
        for key in self.base:
            bdict[key] = self.base[key]

        self.assertEqual(len(bdict), dic_len)
        self.assertEqual(bdict.dropped, 0)

        for key in self.base:
            self.assertEqual(bdict[key], self.base[key])

        # test __iter__ in BoundedAttributes
        for key in bdict:
            self.assertEqual(bdict[key], self.base[key])

        # updating an existing element should not drop
        bdict["name"] = "Bruno"
        self.assertEqual(bdict.dropped, 0)

        # try to append more elements
        for key in self.base:
            bdict["new-" + key] = self.base[key]

        self.assertEqual(len(bdict), dic_len)
        self.assertEqual(bdict.dropped, dic_len)
        # Invalid values shouldn't be considered for `dropped`
        bdict["invalid-seq"] = [None, 1, "2"]
        self.assertEqual(bdict.dropped, dic_len)

        # test that elements in the dict are the new ones
        for key in self.base:
            self.assertEqual(bdict["new-" + key], self.base[key])

        # delete an element
        del bdict["new-name"]
        self.assertEqual(len(bdict), dic_len - 1)

        with self.assertRaises(KeyError):
            _ = bdict["new-name"]

    def test_no_limit_code(self):
        bdict = BoundedAttributes(maxlen=None, immutable=False)
        for num in range(100):
            bdict[str(num)] = num

        for num in range(100):
            self.assertEqual(bdict[str(num)], num)

    def test_immutable(self):
        bdict = BoundedAttributes()
        with self.assertRaises(TypeError):
            bdict["should-not-work"] = "dict immutable"

    def test_no_deadlock_on_reentrant_logging(self):
        """Regression test for #3858.

        The deadlock scenario: a logging handler intercepts the warning
        emitted by _clean_attribute for an invalid value and calls __setitem__
        on the same BoundedAttributes instance from the same thread.
        With _clean_attribute called inside the lock this caused a deadlock.
        With _clean_attribute called before the lock is acquired, no deadlock
        occurs.
        """
        bdict = BoundedAttributes(immutable=False)

        class ReentrantHandler(logging.Handler):
            def emit(self, _record):
                # Simulates Sentry intercepting the OTel warning and writing
                # back into the same BoundedAttributes on the same thread.
                bdict["reentrant.key"] = "set_by_handler"

        otel_logger = logging.getLogger("opentelemetry.attributes")
        handler = ReentrantHandler()
        otel_logger.addHandler(handler)
        try:
            completed = threading.Event()

            def run():
                # None is an invalid attribute value and triggers _logger.warning
                # in _clean_attribute, which fires the ReentrantHandler above.
                bdict["trigger.key"] = None
                completed.set()

            thread = threading.Thread(target=run, daemon=True)
            thread.start()
            thread.join(timeout=2.0)

            self.assertTrue(
                completed.is_set(),
                "Deadlock detected: __setitem__ did not complete within 2s",
            )
            self.assertEqual(bdict.get("reentrant.key"), "set_by_handler")
        finally:
            otel_logger.removeHandler(handler)

    # pylint: disable=no-self-use
    def test_extended_attributes(self):
        bdict = BoundedAttributes(extended_attributes=True, immutable=False)
        with unittest.mock.patch(
            "opentelemetry.attributes._clean_extended_attribute",
            return_value="mock_value",
        ) as clean_extended_attribute_mock:
            bdict["key"] = "value"

        clean_extended_attribute_mock.assert_called_once()

    def test_wsgi_request_conversion_to_string(self):
        """Test that WSGI request objects are converted to strings when _clean_extended_attribute is called."""

        class DummyWSGIRequest:
            def __str__(self):
                return "<DummyWSGIRequest method=GET path=/example/>"

        wsgi_request = DummyWSGIRequest()

        cleaned_value = _clean_extended_attribute(
            "request", wsgi_request, None
        )

        # Verify we get a string back from the cleaner
        self.assertIsInstance(cleaned_value, str)
        self.assertEqual(
            "<DummyWSGIRequest method=GET path=/example/>", cleaned_value
        )

    def test_invalid_anyvalue_type_raises_typeerror(self):
        class BadStr:
            def __str__(self):
                raise RuntimeError("boom")

        with self.assertRaises(TypeError):
            _clean_extended_attribute_value(BadStr(), None)

    def test_deepcopy(self):
        bdict = BoundedAttributes(4, self.base, immutable=False)
        bdict.dropped = 10
        bdict_copy = copy.deepcopy(bdict)

        for key in bdict_copy:
            self.assertEqual(bdict_copy[key], bdict[key])

        self.assertEqual(bdict_copy.dropped, bdict.dropped)
        self.assertEqual(bdict_copy.maxlen, bdict.maxlen)
        self.assertEqual(bdict_copy.max_value_len, bdict.max_value_len)

        bdict_copy["name"] = "Bob"
        self.assertNotEqual(bdict_copy["name"], bdict["name"])

        bdict["age"] = 99
        self.assertNotEqual(bdict["age"], bdict_copy["age"])

    def test_deepcopy_preserves_immutability(self):
        bdict = BoundedAttributes(
            maxlen=4, attributes=self.base, immutable=True
        )
        bdict_copy = copy.deepcopy(bdict)

        with self.assertRaises(TypeError):
            bdict_copy["invalid"] = "invalid"
