# SPDX-License-Identifier: BSD-3-Clause
# Copyright(c) 2010-2014 Intel Corporation
# Copyright(c) 2023 PANTHEON.tech s.r.o.

"""
Base class for creating DTS test cases.
"""

import importlib
import inspect
import re
from ipaddress import IPv4Interface, IPv6Interface, ip_interface
from types import MethodType
from typing import Union

from scapy.layers.inet import IP  # type: ignore[import]
from scapy.layers.l2 import Ether  # type: ignore[import]
from scapy.packet import Packet, Padding  # type: ignore[import]

from .exception import (
    BlockingTestSuiteError,
    ConfigurationError,
    SSHTimeoutError,
    TestCaseVerifyError,
)
from .logger import DTSLOG, getLogger
from .settings import SETTINGS
from .test_result import BuildTargetResult, Result, TestCaseResult, TestSuiteResult
from .testbed_model import SutNode, TGNode
from .testbed_model.hw.port import Port, PortLink
from .utils import get_packet_summaries


class TestSuite(object):
    """
    The base TestSuite class provides methods for handling basic flow of a test suite:
    * test case filtering and collection
    * test suite setup/cleanup
    * test setup/cleanup
    * test case execution
    * error handling and results storage
    Test cases are implemented by derived classes. Test cases are all methods
    starting with test_, further divided into performance test cases
    (starting with test_perf_) and functional test cases (all other test cases).
    By default, all test cases will be executed. A list of testcase str names
    may be specified in conf.yaml or on the command line
    to filter which test cases to run.
    The methods named [set_up|tear_down]_[suite|test_case] should be overridden
    in derived classes if the appropriate suite/test case fixtures are needed.
    """

    sut_node: SutNode
    is_blocking = False
    _logger: DTSLOG
    _test_cases_to_run: list[str]
    _func: bool
    _result: TestSuiteResult
    _port_links: list[PortLink]
    _sut_port_ingress: Port
    _sut_port_egress: Port
    _sut_ip_address_ingress: Union[IPv4Interface, IPv6Interface]
    _sut_ip_address_egress: Union[IPv4Interface, IPv6Interface]
    _tg_port_ingress: Port
    _tg_port_egress: Port
    _tg_ip_address_ingress: Union[IPv4Interface, IPv6Interface]
    _tg_ip_address_egress: Union[IPv4Interface, IPv6Interface]

    def __init__(
        self,
        sut_node: SutNode,
        tg_node: TGNode,
        test_cases: list[str],
        func: bool,
        build_target_result: BuildTargetResult,
    ):
        self.sut_node = sut_node
        self.tg_node = tg_node
        self._logger = getLogger(self.__class__.__name__)
        self._test_cases_to_run = test_cases
        self._test_cases_to_run.extend(SETTINGS.test_cases)
        self._func = func
        self._result = build_target_result.add_test_suite(self.__class__.__name__)
        self._port_links = []
        self._process_links()
        self._sut_port_ingress, self._tg_port_egress = (
            self._port_links[0].sut_port,
            self._port_links[0].tg_port,
        )
        self._sut_port_egress, self._tg_port_ingress = (
            self._port_links[1].sut_port,
            self._port_links[1].tg_port,
        )
        self._sut_ip_address_ingress = ip_interface("192.168.100.2/24")
        self._sut_ip_address_egress = ip_interface("192.168.101.2/24")
        self._tg_ip_address_egress = ip_interface("192.168.100.3/24")
        self._tg_ip_address_ingress = ip_interface("192.168.101.3/24")

    def _process_links(self) -> None:
        for sut_port in self.sut_node.ports:
            for tg_port in self.tg_node.ports:
                if (sut_port.identifier, sut_port.peer) == (
                    tg_port.peer,
                    tg_port.identifier,
                ):
                    self._port_links.append(PortLink(sut_port=sut_port, tg_port=tg_port))

    def set_up_suite(self) -> None:
        """
        Set up test fixtures common to all test cases; this is done before
        any test case is run.
        """

    def tear_down_suite(self) -> None:
        """
        Tear down the previously created test fixtures common to all test cases.
        """

    def set_up_test_case(self) -> None:
        """
        Set up test fixtures before each test case.
        """

    def tear_down_test_case(self) -> None:
        """
        Tear down the previously created test fixtures after each test case.
        """

    def configure_testbed_ipv4(self, restore: bool = False) -> None:
        delete = True if restore else False
        enable = False if restore else True
        self._configure_ipv4_forwarding(enable)
        self.sut_node.configure_port_ip_address(
            self._sut_ip_address_egress, self._sut_port_egress, delete
        )
        self.sut_node.configure_port_state(self._sut_port_egress, enable)
        self.sut_node.configure_port_ip_address(
            self._sut_ip_address_ingress, self._sut_port_ingress, delete
        )
        self.sut_node.configure_port_state(self._sut_port_ingress, enable)
        self.tg_node.configure_port_ip_address(
            self._tg_ip_address_ingress, self._tg_port_ingress, delete
        )
        self.tg_node.configure_port_state(self._tg_port_ingress, enable)
        self.tg_node.configure_port_ip_address(
            self._tg_ip_address_egress, self._tg_port_egress, delete
        )
        self.tg_node.configure_port_state(self._tg_port_egress, enable)

    def _configure_ipv4_forwarding(self, enable: bool) -> None:
        self.sut_node.configure_ipv4_forwarding(enable)

    def send_packet_and_capture(self, packet: Packet, duration: float = 1) -> list[Packet]:
        """
        Send a packet through the appropriate interface and
        receive on the appropriate interface.
        Modify the packet with l3/l2 addresses corresponding
        to the testbed and desired traffic.
        """
        packet = self._adjust_addresses(packet)
        return self.tg_node.send_packet_and_capture(
            packet, self._tg_port_egress, self._tg_port_ingress, duration
        )

    def get_expected_packet(self, packet: Packet) -> Packet:
        return self._adjust_addresses(packet, expected=True)

    def _adjust_addresses(self, packet: Packet, expected: bool = False) -> Packet:
        """
        Assumptions:
            Two links between SUT and TG, one link is TG -> SUT,
            the other SUT -> TG.
        """
        if expected:
            # The packet enters the TG from SUT
            # update l2 addresses
            packet.src = self._sut_port_egress.mac_address
            packet.dst = self._tg_port_ingress.mac_address

            # The packet is routed from TG egress to TG ingress
            # update l3 addresses
            packet.payload.src = self._tg_ip_address_egress.ip.exploded
            packet.payload.dst = self._tg_ip_address_ingress.ip.exploded
        else:
            # The packet leaves TG towards SUT
            # update l2 addresses
            packet.src = self._tg_port_egress.mac_address
            packet.dst = self._sut_port_ingress.mac_address

            # The packet is routed from TG egress to TG ingress
            # update l3 addresses
            packet.payload.src = self._tg_ip_address_egress.ip.exploded
            packet.payload.dst = self._tg_ip_address_ingress.ip.exploded

        return Ether(packet.build())

    def verify(self, condition: bool, failure_description: str) -> None:
        if not condition:
            self._fail_test_case_verify(failure_description)

    def _fail_test_case_verify(self, failure_description: str) -> None:
        self._logger.debug("A test case failed, showing the last 10 commands executed on SUT:")
        for command_res in self.sut_node.main_session.remote_session.history[-10:]:
            self._logger.debug(command_res.command)
        self._logger.debug("A test case failed, showing the last 10 commands executed on TG:")
        for command_res in self.tg_node.main_session.remote_session.history[-10:]:
            self._logger.debug(command_res.command)
        raise TestCaseVerifyError(failure_description)

    def verify_packets(self, expected_packet: Packet, received_packets: list[Packet]) -> None:
        for received_packet in received_packets:
            if self._compare_packets(expected_packet, received_packet):
                break
        else:
            self._logger.debug(
                f"The expected packet {get_packet_summaries(expected_packet)} "
                f"not found among received {get_packet_summaries(received_packets)}"
            )
            self._fail_test_case_verify("An expected packet not found among received packets.")

    def _compare_packets(self, expected_packet: Packet, received_packet: Packet) -> bool:
        self._logger.debug(
            f"Comparing packets: \n{expected_packet.summary()}\n{received_packet.summary()}"
        )

        l3 = IP in expected_packet.layers()
        self._logger.debug("Found l3 layer")

        received_payload = received_packet
        expected_payload = expected_packet
        while received_payload and expected_payload:
            self._logger.debug("Comparing payloads:")
            self._logger.debug(f"Received: {received_payload}")
            self._logger.debug(f"Expected: {expected_payload}")
            if received_payload.__class__ == expected_payload.__class__:
                self._logger.debug("The layers are the same.")
                if received_payload.__class__ == Ether:
                    if not self._verify_l2_frame(received_payload, l3):
                        return False
                elif received_payload.__class__ == IP:
                    if not self._verify_l3_packet(received_payload, expected_payload):
                        return False
            else:
                # Different layers => different packets
                return False
            received_payload = received_payload.payload
            expected_payload = expected_payload.payload

        if expected_payload:
            self._logger.debug(f"The expected packet did not contain {expected_payload}.")
            return False
        if received_payload and received_payload.__class__ != Padding:
            self._logger.debug("The received payload had extra layers which were not padding.")
            return False
        return True

    def _verify_l2_frame(self, received_packet: Ether, l3: bool) -> bool:
        self._logger.debug("Looking at the Ether layer.")
        self._logger.debug(
            f"Comparing received dst mac '{received_packet.dst}' "
            f"with expected '{self._tg_port_ingress.mac_address}'."
        )
        if received_packet.dst != self._tg_port_ingress.mac_address:
            return False

        expected_src_mac = self._tg_port_egress.mac_address
        if l3:
            expected_src_mac = self._sut_port_egress.mac_address
        self._logger.debug(
            f"Comparing received src mac '{received_packet.src}' "
            f"with expected '{expected_src_mac}'."
        )
        if received_packet.src != expected_src_mac:
            return False

        return True

    def _verify_l3_packet(self, received_packet: IP, expected_packet: IP) -> bool:
        self._logger.debug("Looking at the IP layer.")
        if received_packet.src != expected_packet.src or received_packet.dst != expected_packet.dst:
            return False
        return True

    def run(self) -> None:
        """
        Setup, execute and teardown the whole suite.
        Suite execution consists of running all test cases scheduled to be executed.
        A test cast run consists of setup, execution and teardown of said test case.
        """
        test_suite_name = self.__class__.__name__

        try:
            self._logger.info(f"Starting test suite setup: {test_suite_name}")
            self.set_up_suite()
            self._result.update_setup(Result.PASS)
            self._logger.info(f"Test suite setup successful: {test_suite_name}")
        except Exception as e:
            self._logger.exception(f"Test suite setup ERROR: {test_suite_name}")
            self._result.update_setup(Result.ERROR, e)

        else:
            self._execute_test_suite()

        finally:
            try:
                self.tear_down_suite()
                self.sut_node.kill_cleanup_dpdk_apps()
                self._result.update_teardown(Result.PASS)
            except Exception as e:
                self._logger.exception(f"Test suite teardown ERROR: {test_suite_name}")
                self._logger.warning(
                    f"Test suite '{test_suite_name}' teardown failed, "
                    f"the next test suite may be affected."
                )
                self._result.update_setup(Result.ERROR, e)
            if len(self._result.get_errors()) > 0 and self.is_blocking:
                raise BlockingTestSuiteError(test_suite_name)

    def _execute_test_suite(self) -> None:
        """
        Execute all test cases scheduled to be executed in this suite.
        """
        if self._func:
            for test_case_method in self._get_functional_test_cases():
                test_case_name = test_case_method.__name__
                test_case_result = self._result.add_test_case(test_case_name)
                all_attempts = SETTINGS.re_run + 1
                attempt_nr = 1
                self._run_test_case(test_case_method, test_case_result)
                while not test_case_result and attempt_nr < all_attempts:
                    attempt_nr += 1
                    self._logger.info(
                        f"Re-running FAILED test case '{test_case_name}'. "
                        f"Attempt number {attempt_nr} out of {all_attempts}."
                    )
                    self._run_test_case(test_case_method, test_case_result)

    def _get_functional_test_cases(self) -> list[MethodType]:
        """
        Get all functional test cases.
        """
        return self._get_test_cases(r"test_(?!perf_)")

    def _get_test_cases(self, test_case_regex: str) -> list[MethodType]:
        """
        Return a list of test cases matching test_case_regex.
        """
        self._logger.debug(f"Searching for test cases in {self.__class__.__name__}.")
        filtered_test_cases = []
        for test_case_name, test_case in inspect.getmembers(self, inspect.ismethod):
            if self._should_be_executed(test_case_name, test_case_regex):
                filtered_test_cases.append(test_case)
        cases_str = ", ".join((x.__name__ for x in filtered_test_cases))
        self._logger.debug(f"Found test cases '{cases_str}' in {self.__class__.__name__}.")
        return filtered_test_cases

    def _should_be_executed(self, test_case_name: str, test_case_regex: str) -> bool:
        """
        Check whether the test case should be executed.
        """
        match = bool(re.match(test_case_regex, test_case_name))
        if self._test_cases_to_run:
            return match and test_case_name in self._test_cases_to_run

        return match

    def _run_test_case(
        self, test_case_method: MethodType, test_case_result: TestCaseResult
    ) -> None:
        """
        Setup, execute and teardown a test case in this suite.
        Exceptions are caught and recorded in logs and results.
        """
        test_case_name = test_case_method.__name__

        try:
            # run set_up function for each case
            self.set_up_test_case()
            test_case_result.update_setup(Result.PASS)
        except SSHTimeoutError as e:
            self._logger.exception(f"Test case setup FAILED: {test_case_name}")
            test_case_result.update_setup(Result.FAIL, e)
        except Exception as e:
            self._logger.exception(f"Test case setup ERROR: {test_case_name}")
            test_case_result.update_setup(Result.ERROR, e)

        else:
            # run test case if setup was successful
            self._execute_test_case(test_case_method, test_case_result)

        finally:
            try:
                self.tear_down_test_case()
                test_case_result.update_teardown(Result.PASS)
            except Exception as e:
                self._logger.exception(f"Test case teardown ERROR: {test_case_name}")
                self._logger.warning(
                    f"Test case '{test_case_name}' teardown failed, "
                    f"the next test case may be affected."
                )
                test_case_result.update_teardown(Result.ERROR, e)
                test_case_result.update(Result.ERROR)

    def _execute_test_case(
        self, test_case_method: MethodType, test_case_result: TestCaseResult
    ) -> None:
        """
        Execute one test case and handle failures.
        """
        test_case_name = test_case_method.__name__
        try:
            self._logger.info(f"Starting test case execution: {test_case_name}")
            test_case_method()
            test_case_result.update(Result.PASS)
            self._logger.info(f"Test case execution PASSED: {test_case_name}")

        except TestCaseVerifyError as e:
            self._logger.exception(f"Test case execution FAILED: {test_case_name}")
            test_case_result.update(Result.FAIL, e)
        except Exception as e:
            self._logger.exception(f"Test case execution ERROR: {test_case_name}")
            test_case_result.update(Result.ERROR, e)
        except KeyboardInterrupt:
            self._logger.error(f"Test case execution INTERRUPTED by user: {test_case_name}")
            test_case_result.update(Result.SKIP)
            raise KeyboardInterrupt("Stop DTS")


def get_test_suites(testsuite_module_path: str) -> list[type[TestSuite]]:
    def is_test_suite(object) -> bool:
        try:
            if issubclass(object, TestSuite) and object is not TestSuite:
                return True
        except TypeError:
            return False
        return False

    try:
        testcase_module = importlib.import_module(testsuite_module_path)
    except ModuleNotFoundError as e:
        raise ConfigurationError(f"Test suite '{testsuite_module_path}' not found.") from e
    return [
        test_suite_class
        for _, test_suite_class in inspect.getmembers(testcase_module, is_test_suite)
    ]
