# Copyright 2014 Mirantis Inc.
# All Rights Reserved.
#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

import collections
import copy
import datetime
import re
import unittest
from typing import Any, ClassVar
from unittest import mock


from osprofiler import profiler
from osprofiler.tests import test


class ProfilerGlobMethodsTestCase(test.TestCase):
    def test_get_profiler_not_inited(self):
        profiler.clean()
        self.assertIsNone(profiler.get())

    def test_get_profiler_and_init(self):
        p = profiler.init("secret", base_id="1", parent_id="2")
        self.assertEqual(profiler.get(), p)

        self.assertEqual(p.get_base_id(), "1")
        # NOTE(boris-42): until we make first start we don't have
        self.assertEqual(p.get_id(), "2")

    def test_start_not_inited(self):
        profiler.clean()
        profiler.start("name")

    def test_start(self):
        p = profiler.init("secret", base_id="1", parent_id="2")
        p.start = mock.MagicMock()  # type: ignore[method-assign]
        profiler.start("name", info="info")  # type: ignore[arg-type]
        p.start.assert_called_once_with("name", info="info")

    def test_stop_not_inited(self):
        profiler.clean()
        profiler.stop()

    def test_stop(self):
        p = profiler.init("secret", base_id="1", parent_id="2")
        p.stop = mock.MagicMock()  # type: ignore[method-assign]
        profiler.stop(info="info")  # type: ignore[arg-type]
        p.stop.assert_called_once_with(info="info")


class ProfilerTestCase(test.TestCase):
    def test_profiler_get_shorten_id(self):
        uuid_id = "4e3e0ec6-2938-40b1-8504-09eb1d4b0dee"
        prof = profiler._Profiler("secret", base_id="1", parent_id="2")
        result = prof.get_shorten_id(uuid_id)
        expected = "850409eb1d4b0dee"
        self.assertEqual(expected, result)

    def test_profiler_get_shorten_id_int(self):
        short_id_int = 42
        prof = profiler._Profiler("secret", base_id="1", parent_id="2")
        result = prof.get_shorten_id(short_id_int)
        expected = "2a"
        self.assertEqual(expected, result)

    def test_profiler_get_base_id(self):
        prof = profiler._Profiler("secret", base_id="1", parent_id="2")
        self.assertEqual(prof.get_base_id(), "1")

    @mock.patch("osprofiler.profiler.uuidutils.generate_uuid")
    def test_profiler_get_parent_id(self, mock_generate_uuid):
        mock_generate_uuid.return_value = "42"
        prof = profiler._Profiler("secret", base_id="1", parent_id="2")
        prof.start("test")
        self.assertEqual(prof.get_parent_id(), "2")

    @mock.patch("osprofiler.profiler.uuidutils.generate_uuid")
    def test_profiler_get_base_id_unset_case(self, mock_generate_uuid):
        mock_generate_uuid.return_value = "42"
        prof = profiler._Profiler("secret")
        self.assertEqual(prof.get_base_id(), "42")
        self.assertEqual(prof.get_parent_id(), "42")

    @mock.patch("osprofiler.profiler.uuidutils.generate_uuid")
    def test_profiler_get_id(self, mock_generate_uuid):
        mock_generate_uuid.return_value = "43"
        prof = profiler._Profiler("secret")
        prof.start("test")
        self.assertEqual(prof.get_id(), "43")

    @mock.patch("osprofiler.profiler.timeutils")
    @mock.patch("osprofiler.profiler.uuidutils.generate_uuid")
    @mock.patch("osprofiler.profiler.notifier.notify")
    def test_profiler_start(
        self, mock_notify, mock_generate_uuid, mock_timeutils
    ):
        mock_generate_uuid.return_value = "44"
        now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
        mock_timeutils.utcnow.return_value = now

        info = {"some": "info"}
        payload = {
            "name": "test-start",
            "base_id": "1",
            "parent_id": "2",
            "trace_id": "44",
            "info": info,
            "timestamp": now.strftime("%Y-%m-%dT%H:%M:%S.%f"),
        }

        prof = profiler._Profiler("secret", base_id="1", parent_id="2")
        prof.start("test", info=info)

        mock_notify.assert_called_once_with(payload)

    @mock.patch("osprofiler.profiler.timeutils")
    @mock.patch("osprofiler.profiler.notifier.notify")
    def test_profiler_stop(self, mock_notify, mock_timeutils):
        now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
        mock_timeutils.utcnow.return_value = now
        prof = profiler._Profiler("secret", base_id="1", parent_id="2")
        prof._trace_stack.append("44")
        prof._name.append("abc")

        info = {"some": "info"}
        prof.stop(info=info)

        payload = {
            "name": "abc-stop",
            "base_id": "1",
            "parent_id": "2",
            "trace_id": "44",
            "info": info,
            "timestamp": now.strftime("%Y-%m-%dT%H:%M:%S.%f"),
        }

        mock_notify.assert_called_once_with(payload)
        self.assertEqual(len(prof._name), 0)
        self.assertEqual(prof._trace_stack, collections.deque(["1", "2"]))

    @mock.patch("osprofiler.profiler.notifier.notify")
    def test_profiler_stop_empty_stack(self, mock_notify):
        """Test stop() gracefully handles empty profiler stack"""
        prof = profiler._Profiler("secret", base_id="1", parent_id="2")
        # Don't append anything to _name or _trace_stack
        # This simulates stop() being called without matching start()

        # Should not raise IndexError, just return silently
        prof.stop(info={"some": "info"})

        # No notification should be sent
        mock_notify.assert_not_called()
        # Stacks should remain empty
        self.assertEqual(len(prof._name), 0)
        self.assertEqual(len(prof._trace_stack), 2)  # base_id and parent_id

    def test_profiler_hmac(self):
        hmac = "secret"
        prof = profiler._Profiler(hmac, base_id="1", parent_id="2")
        self.assertEqual(hmac, prof.hmac_key)


class WithTraceTestCase(test.TestCase):
    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_with_trace(self, mock_start, mock_stop):

        with profiler.Trace("a", info="a1"):  # type: ignore[arg-type]
            mock_start.assert_called_once_with("a", info="a1")
            mock_start.reset_mock()
            with profiler.Trace("b", info="b1"):  # type: ignore[arg-type]
                mock_start.assert_called_once_with("b", info="b1")
            mock_stop.assert_called_once_with(info=None)
            mock_stop.reset_mock()
        mock_stop.assert_called_once_with(info=None)

    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_with_trace_etype(self, mock_start, mock_stop):

        def foo():
            with profiler.Trace("foo"):
                raise ValueError("bar")

        self.assertRaises(ValueError, foo)
        mock_start.assert_called_once_with("foo", info=None)
        mock_stop.assert_called_once_with(
            info={"etype": "ValueError", "message": "bar"}
        )


@profiler.trace("function", info={"info": "some_info"})
def traced_func(i):
    return i


@profiler.trace("hide_args", hide_args=True)
def trace_hide_args_func(a, i=10):
    return (a, i)


@profiler.trace("foo", hide_args=True)
def test_fn_exc():
    raise ValueError()


@profiler.trace("hide_result", hide_result=False)
def trace_with_result_func(a, i=10):
    return (a, i)


class TraceDecoratorTestCase(test.TestCase):
    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_duplicate_trace_disallow(self, mock_start, mock_stop):

        @profiler.trace("test")
        def trace_me():
            pass

        self.assertRaises(
            ValueError,
            profiler.trace("test-again", allow_multiple_trace=False),
            trace_me,
        )

    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_with_args(self, mock_start, mock_stop):
        self.assertEqual(1, traced_func(1))
        expected_info = {
            "info": "some_info",
            "function": {
                "name": "osprofiler.tests.unit.test_profiler.traced_func",
                "args": str((1,)),
                "kwargs": str({}),
            },
        }
        mock_start.assert_called_once_with("function", info=expected_info)
        mock_stop.assert_called_once_with(info=None)

    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_without_args(self, mock_start, mock_stop):
        self.assertEqual((1, 2), trace_hide_args_func(1, i=2))
        expected_info = {
            "function": {
                "name": "osprofiler.tests.unit.test_profiler"
                ".trace_hide_args_func"
            }
        }
        mock_start.assert_called_once_with("hide_args", info=expected_info)
        mock_stop.assert_called_once_with(info=None)

    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_with_exception(self, mock_start, mock_stop):

        self.assertRaises(ValueError, test_fn_exc)
        expected_info = {
            "function": {
                "name": "osprofiler.tests.unit.test_profiler.test_fn_exc"
            }
        }
        expected_stop_info = {"etype": "ValueError", "message": ""}
        mock_start.assert_called_once_with("foo", info=expected_info)
        mock_stop.assert_called_once_with(info=expected_stop_info)

    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_with_result(self, mock_start, mock_stop):
        self.assertEqual((1, 2), trace_with_result_func(1, i=2))
        start_info = {
            "function": {
                "name": "osprofiler.tests.unit.test_profiler"
                ".trace_with_result_func",
                "args": str((1,)),
                "kwargs": str({"i": 2}),
            }
        }

        stop_info = {"function": {"result": str((1, 2))}}
        mock_start.assert_called_once_with("hide_result", info=start_info)
        mock_stop.assert_called_once_with(info=stop_info)


class FakeTracedCls:
    def method1(self, a, b, c=10):
        return a + b + c

    def method2(self, d, e):
        return d - e

    def method3(self, g=10, h=20):
        return g * h

    def _method(self, i):
        return i


@profiler.trace_cls("rpc", info={"a": 10})
class FakeTraceClassWithInfo(FakeTracedCls):
    pass


@profiler.trace_cls("a", info={"b": 20}, hide_args=True)
class FakeTraceClassHideArgs(FakeTracedCls):
    pass


@profiler.trace_cls("rpc", trace_private=True)
class FakeTracePrivate(FakeTracedCls):
    pass


class FakeTraceStaticMethodBase(FakeTracedCls):
    @staticmethod
    def static_method(arg):
        return arg


@profiler.trace_cls("rpc", trace_static_methods=True)
class FakeTraceStaticMethod(FakeTraceStaticMethodBase):
    pass


@profiler.trace_cls("rpc")
class FakeTraceStaticMethodSkip(FakeTraceStaticMethodBase):
    pass


class FakeTraceClassMethodBase(FakeTracedCls):
    @classmethod
    def class_method(cls, arg):
        return arg


@profiler.trace_cls("rpc")
class FakeTraceClassMethodSkip(FakeTraceClassMethodBase):
    pass


def py3_info(info):
    # NOTE(boris-42): py33 I hate you.
    info_py3 = copy.deepcopy(info)
    new_name = re.sub(
        "FakeTrace[^.]*", "FakeTracedCls", info_py3["function"]["name"]
    )
    info_py3["function"]["name"] = new_name
    return info_py3


def possible_mock_calls(name, info):
    # NOTE(boris-42): py33 I hate you.
    return [mock.call(name, info=info), mock.call(name, info=py3_info(info))]


class TraceClsDecoratorTestCase(test.TestCase):
    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_args(self, mock_start, mock_stop):
        fake_cls = FakeTraceClassWithInfo()
        self.assertEqual(30, fake_cls.method1(5, 15))
        expected_info = {
            "a": 10,
            "function": {
                "name": (
                    "osprofiler.tests.unit.test_profiler"
                    ".FakeTraceClassWithInfo.method1"
                ),
                "args": str((fake_cls, 5, 15)),
                "kwargs": str({}),
            },
        }
        mock_start.assert_called_once()
        self.assertIn(
            mock_start.call_args_list[0],
            possible_mock_calls("rpc", expected_info),
        )
        mock_stop.assert_called_once_with(info=None)

    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_kwargs(self, mock_start, mock_stop):
        fake_cls = FakeTraceClassWithInfo()
        self.assertEqual(50, fake_cls.method3(g=5, h=10))
        expected_info = {
            "a": 10,
            "function": {
                "name": (
                    "osprofiler.tests.unit.test_profiler"
                    ".FakeTraceClassWithInfo.method3"
                ),
                "args": str((fake_cls,)),
                "kwargs": str({"g": 5, "h": 10}),
            },
        }
        mock_start.assert_called_once()
        self.assertIn(
            mock_start.call_args_list[0],
            possible_mock_calls("rpc", expected_info),
        )
        mock_stop.assert_called_once_with(info=None)

    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_without_private(self, mock_start, mock_stop):
        fake_cls = FakeTraceClassHideArgs()
        self.assertEqual(10, fake_cls._method(10))
        mock_start.assert_not_called()
        mock_stop.assert_not_called()

    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_without_args(self, mock_start, mock_stop):
        fake_cls = FakeTraceClassHideArgs()
        self.assertEqual(40, fake_cls.method1(5, 15, c=20))
        expected_info = {
            "b": 20,
            "function": {
                "name": (
                    "osprofiler.tests.unit.test_profiler"
                    ".FakeTraceClassHideArgs.method1"
                ),
            },
        }

        mock_start.assert_called_once()
        self.assertIn(
            mock_start.call_args_list[0],
            possible_mock_calls("a", expected_info),
        )
        mock_stop.assert_called_once_with(info=None)

    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_private_methods(self, mock_start, mock_stop):
        fake_cls = FakeTracePrivate()
        self.assertEqual(5, fake_cls._method(5))

        expected_info = {
            "function": {
                "name": (
                    "osprofiler.tests.unit.test_profiler"
                    ".FakeTracePrivate._method"
                ),
                "args": str((fake_cls, 5)),
                "kwargs": str({}),
            }
        }

        mock_start.assert_called_once()
        self.assertIn(
            mock_start.call_args_list[0],
            possible_mock_calls("rpc", expected_info),
        )
        mock_stop.assert_called_once_with(info=None)

    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    @unittest.skip(
        "Static method tracing was disabled due the bug. This test should be "
        "skipped until we find the way to address it."
    )
    def test_static(self, mock_start, mock_stop):
        fake_cls = FakeTraceStaticMethod()

        self.assertEqual(25, fake_cls.static_method(25))

        expected_info = {
            "function": {
                # fixme(boris-42): Static methods are treated differently in
                #                  Python 2.x and Python 3.x. So in PY2 we
                #                  expect to see method4 because method is
                #                  static and doesn't have reference to class
                #                  - and FakeTraceStatic.method4 in PY3
                "name": "osprofiler.tests.unit.test_profiler"
                "osprofiler.tests.unit.test_profiler.FakeTraceStatic"
                ".method4",
                "args": str((25,)),
                "kwargs": str({}),
            }
        }

        mock_start.assert_called_once()
        self.assertIn(
            mock_start.call_args_list[0],
            possible_mock_calls("rpc", expected_info),
        )
        mock_stop.assert_called_once_with(info=None)

    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_static_method_skip(self, mock_start, mock_stop):
        self.assertEqual(25, FakeTraceStaticMethodSkip.static_method(25))
        mock_start.assert_not_called()
        mock_stop.assert_not_called()

    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_class_method_skip(self, mock_start, mock_stop):
        self.assertEqual("foo", FakeTraceClassMethodSkip.class_method("foo"))
        mock_start.assert_not_called()
        mock_stop.assert_not_called()


class FakeTraceWithMetaclassBase(metaclass=profiler.TracedMeta):
    __trace_args__: ClassVar[dict[str, Any]] = {
        "name": "rpc",
        "info": {"a": 10},
    }

    def method1(self, a, b, c=10):
        return a + b + c

    def method2(self, d, e):
        return d - e

    def method3(self, g=10, h=20):
        return g * h

    def _method(self, i):
        return i


class FakeTraceDummy(FakeTraceWithMetaclassBase):
    def method4(self, j):
        return j


class FakeTraceWithMetaclassHideArgs(FakeTraceWithMetaclassBase):
    __trace_args__: ClassVar[dict[str, Any]] = {
        "name": "a",
        "info": {"b": 20},
        "hide_args": True,
    }

    def method5(self, k, l):
        return k + l


class FakeTraceWithMetaclassPrivate(FakeTraceWithMetaclassBase):
    __trace_args__: ClassVar[dict[str, Any]] = {
        "name": "rpc",
        "trace_private": True,
    }

    def _new_private_method(self, m):
        return 2 * m


class TraceWithMetaclassTestCase(test.TestCase):
    def test_no_name_exception(self):
        def define_class_with_no_name():
            class FakeTraceWithMetaclassNoName(
                FakeTracedCls, metaclass=profiler.TracedMeta
            ):
                pass

        self.assertRaises(TypeError, define_class_with_no_name, 1)

    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_args(self, mock_start, mock_stop):
        fake_cls = FakeTraceWithMetaclassBase()
        self.assertEqual(30, fake_cls.method1(5, 15))
        expected_info = {
            "a": 10,
            "function": {
                "name": (
                    "osprofiler.tests.unit.test_profiler"
                    ".FakeTraceWithMetaclassBase.method1"
                ),
                "args": str((fake_cls, 5, 15)),
                "kwargs": str({}),
            },
        }
        mock_start.assert_called_once()
        self.assertIn(
            mock_start.call_args_list[0],
            possible_mock_calls("rpc", expected_info),
        )
        mock_stop.assert_called_once_with(info=None)

    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_kwargs(self, mock_start, mock_stop):
        fake_cls = FakeTraceWithMetaclassBase()
        self.assertEqual(50, fake_cls.method3(g=5, h=10))
        expected_info = {
            "a": 10,
            "function": {
                "name": (
                    "osprofiler.tests.unit.test_profiler"
                    ".FakeTraceWithMetaclassBase.method3"
                ),
                "args": str((fake_cls,)),
                "kwargs": str({"g": 5, "h": 10}),
            },
        }
        mock_start.assert_called_once()
        self.assertIn(
            mock_start.call_args_list[0],
            possible_mock_calls("rpc", expected_info),
        )
        mock_stop.assert_called_once_with(info=None)

    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_without_private(self, mock_start, mock_stop):
        fake_cls = FakeTraceWithMetaclassHideArgs()
        self.assertEqual(10, fake_cls._method(10))
        mock_start.assert_not_called()
        mock_stop.assert_not_called()

    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_without_args(self, mock_start, mock_stop):
        fake_cls = FakeTraceWithMetaclassHideArgs()
        self.assertEqual(20, fake_cls.method5(5, 15))
        expected_info = {
            "b": 20,
            "function": {
                "name": (
                    "osprofiler.tests.unit.test_profiler"
                    ".FakeTraceWithMetaclassHideArgs.method5"
                )
            },
        }

        mock_start.assert_called_once()
        self.assertIn(
            mock_start.call_args_list[0],
            possible_mock_calls("a", expected_info),
        )
        mock_stop.assert_called_once_with(info=None)

    @mock.patch("osprofiler.profiler.stop")
    @mock.patch("osprofiler.profiler.start")
    def test_private_methods(self, mock_start, mock_stop):
        fake_cls = FakeTraceWithMetaclassPrivate()
        self.assertEqual(10, fake_cls._new_private_method(5))

        expected_info = {
            "function": {
                "name": (
                    "osprofiler.tests.unit.test_profiler"
                    ".FakeTraceWithMetaclassPrivate._new_private_method"
                ),
                "args": str((fake_cls, 5)),
                "kwargs": str({}),
            }
        }

        mock_start.assert_called_once()
        self.assertIn(
            mock_start.call_args_list[0],
            possible_mock_calls("rpc", expected_info),
        )
        mock_stop.assert_called_once_with(info=None)
