# -*- coding: utf-8 -*-
# ------------------------------------------------------------------------------
#
#   Copyright 2018-2019 Fetch.AI Limited
#
#   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.
#
# ------------------------------------------------------------------------------
"""This module contains the tests for the helper module."""

import os
import platform
import re
import signal
import time
from pathlib import Path
from subprocess import Popen  # nosec
from typing import Dict, Set
from unittest.mock import patch

import pytest

from aea.helpers.base import (
    MaxRetriesError,
    RegexConstrainedString,
    exception_log_and_reraise,
    find_topological_order,
    load_env_file,
    load_module,
    locate,
    recursive_update,
    retry_decorator,
    send_control_c,
    try_decorator,
    win_popen_kwargs,
)

from packages.fetchai.connections.oef.connection import OEFConnection

from tests.conftest import CUR_PATH, ROOT_DIR, skip_test_windows


class TestHelpersBase:
    """Test the helper functions."""

    def test_locate(self):
        """Test the locate function to locate modules."""
        cwd = os.getcwd()
        os.chdir(os.path.join(CUR_PATH, ".."))
        gym_package = locate(
            "packages.fetchai.connections.gym.connection.GymConnection"
        )
        non_existing_package = locate(
            "packages.fetchai.connections.non_existing_connection"
        )
        os.chdir(cwd)
        assert gym_package is not None
        assert non_existing_package is None

    def test_locate_class(self):
        """Test the locate function to locate classes."""
        cwd = os.getcwd()
        os.chdir(os.path.join(CUR_PATH, ".."))
        expected_class = OEFConnection
        actual_class = locate(
            "packages.fetchai.connections.oef.connection.OEFConnection"
        )
        os.chdir(cwd)
        # although they are the same class, they are different instances in memory
        # and the build-in default "__eq__" method does not compare the attributes.
        # so compare the names
        assert actual_class is not None
        assert expected_class.__name__ == actual_class.__name__

    def test_locate_with_builtins(self):
        """Test that locate function returns the built-in."""
        result = locate("int.bit_length")
        assert int.bit_length == result

    def test_locate_when_path_does_not_exist(self):
        """Test that locate function returns None when the dotted path does not exist."""
        result = locate("aea.not.existing.path")
        assert result is None

        result = locate("ThisClassDoesNotExist")
        assert result is None


def test_regex_constrained_string_initialization():
    """Test we can initialize a regex constrained with the default regex."""
    RegexConstrainedString("")
    RegexConstrainedString("abcde")
    RegexConstrainedString(b"")
    RegexConstrainedString(b"abcde")
    RegexConstrainedString(RegexConstrainedString(""))
    RegexConstrainedString(RegexConstrainedString("abcde"))


def test_load_module():
    """Test load module from filepath and dotted notation."""
    load_module(
        "packages.fetchai.connections.gym.connection",
        Path(ROOT_DIR)
        / "packages"
        / "fetchai"
        / "connections"
        / "gym"
        / "connection.py",
    )


def test_load_env_file():
    """Test load env file updates process environment variables."""
    load_env_file(Path(ROOT_DIR) / "tests" / "data" / "dot_env_file")
    assert os.getenv("TEST") == "yes"


def test_reg_exp_not_match():
    """Test regexp checks."""
    # for pydocstyle
    class MyReString(RegexConstrainedString):
        REGEX = re.compile(r"[0-9]+")

    with pytest.raises(ValueError):
        MyReString("anystring")


def test_try_decorator():
    """Test try and log decorator."""
    # for pydocstyle
    @try_decorator("oops", default_return="failed")
    def fn():
        raise Exception("expected")

    assert fn() == "failed"


def test_retry_decorator():
    """Test auto retry decorator."""
    num_calls = 0
    retries = 3

    @retry_decorator(retries, "oops. expected")
    def fn():
        nonlocal num_calls
        num_calls += 1
        raise Exception("expected")

    with pytest.raises(MaxRetriesError):
        fn()
    assert num_calls == retries


def test_log_and_reraise():
    """Test log and reraise context manager."""
    log_msg = None

    def fn(msg):
        nonlocal log_msg
        log_msg = msg

    with pytest.raises(ValueError):
        with exception_log_and_reraise(fn, "oops"):
            raise ValueError()

    assert log_msg == "oops"


@skip_test_windows
def test_send_control_c_group():
    """Test send control c to process group."""
    # Can't test process group id kill directly,
    # because o/w pytest would be stopped.
    process = Popen(["sleep", "1"])  # nosec
    pgid = os.getpgid(process.pid)
    time.sleep(0.1)
    with patch("os.killpg") as mock_killpg:
        send_control_c(process, kill_group=True)
        process.communicate(timeout=3)
        mock_killpg.assert_called_with(pgid, signal.SIGINT)


def test_send_control_c():
    """Test send control c to process."""
    # Can't test process group id kill directly,
    # because o/w pytest would be stopped.
    process = Popen(  # nosec
        ["timeout" if platform.system() == "Windows" else "sleep", "5"],
        **win_popen_kwargs(),
    )
    time.sleep(0.001)
    send_control_c(process)
    process.communicate(timeout=3)
    assert process.returncode != 0


@skip_test_windows
def test_send_control_c_windows():
    """Test send control c on Windows."""
    process = Popen(  # nosec
        ["timeout" if platform.system() == "Windows" else "sleep", "5"]
    )
    time.sleep(0.001)
    pid = process.pid
    with patch("aea.helpers.base.signal") as mock_signal:
        mock_signal.CTRL_C_EVENT = "mock"
        with patch("platform.system", return_value="Windows"):
            with patch("os.kill") as mock_kill:
                send_control_c(process)
                mock_kill.assert_called_with(pid, mock_signal.CTRL_C_EVENT)


def test_recursive_update_no_recursion():
    """Test the 'recursive update' utility, in the case there's no recursion."""
    to_update = dict(not_updated=0, an_integer=1, a_list=[1, 2, 3], a_tuple=(1, 2, 3))

    new_integer, new_list, new_tuple = 2, [3], (3,)
    new_values = dict(an_integer=new_integer, a_list=new_list, a_tuple=new_tuple)
    recursive_update(to_update, new_values)
    assert to_update == dict(
        not_updated=0, an_integer=new_integer, a_list=new_list, a_tuple=new_tuple
    )


def test_recursive_update_with_recursion():
    """Test the 'recursive update' utility with recursion."""
    # here we try to update an integer and add a new value
    to_update = dict(subdict=dict(to_update=1))
    new_values = dict(subdict=dict(to_update=2))

    recursive_update(to_update, new_values)
    assert to_update == dict(subdict=dict(to_update=2))


def test_recursive_update_negative_different_type():
    """Test the 'recursive update' utility, when the types are different."""
    # here we try to update an integer with a boolean - it raises error.
    to_update = dict(subdict=dict(to_update=1))
    new_values = dict(subdict=dict(to_update=False))

    with pytest.raises(
        ValueError,
        match="Trying to replace value '1' with value 'False' which is of different type.",
    ):
        recursive_update(to_update, new_values)


def test_recursive_update_negative_unknown_field():
    """Test the 'recursive update' utility, when there are unknown fields."""
    # here we try to update an integer with a boolean - it raises error.
    to_update = dict(subdict=dict(field=1))
    new_values = dict(subdict=dict(new_field=False))

    with pytest.raises(
        ValueError,
        match="Key 'new_field' is not contained in the dictionary to update.",
    ):
        recursive_update(to_update, new_values)


class TestTopologicalOrder:
    """Test the computation of topological order."""

    def test_empty_graph(self):
        """Test the function with empty input."""
        order = find_topological_order({})
        assert order == []

    def test_one_node(self):
        """Test the function with only one node."""
        order = find_topological_order({0: set()})
        assert order == [0]

    def test_one_node_with_cycle(self):
        """Test the function with only one node and a loop."""
        with pytest.raises(ValueError, match="Graph has at least one cycle."):
            find_topological_order({0: {0}})

    def test_two_nodes_no_edges(self):
        """Test the function with two nodes, but no edges."""
        order = find_topological_order({0: set(), 1: set()})
        assert order == [0, 1]

    def test_two_nodes_no_cycle(self):
        """Test the function with two nodes, but no cycles."""
        order = find_topological_order({0: {1}})
        assert order == [0, 1]

    def test_two_nodes_with_cycle(self):
        """Test the function with two nodes and a cycle between them."""
        with pytest.raises(ValueError, match="Graph has at least one cycle."):
            find_topological_order({0: {1}, 1: {0}})

    def test_two_nodes_clique(self):
        """Test the function with a clique of two nodes."""
        with pytest.raises(ValueError, match="Graph has at least one cycle."):
            find_topological_order({0: {1, 0}, 1: {0, 1}})

    @pytest.mark.parametrize("chain_length", [3, 5, 10, 100])
    def test_chain(self, chain_length):
        """Test the function with a chain."""
        adj_list: Dict[int, Set[int]] = {}
        for i in range(chain_length - 1):
            adj_list[i] = {i + 1}
        adj_list[chain_length - 1] = set()

        order = find_topological_order(adj_list)
        assert order == list(range(chain_length))
