# Copyright (C) 2023 Linaro Limited
#
# Author: Remi Duraffort <remi.duraffort@linaro.org>
#
# SPDX-License-Identifier: GPL-2.0-or-later

from unittest.mock import MagicMock, call, patch

from lava_common.exceptions import ConnectionClosedError, TestError
from lava_dispatcher.actions.test.shell import TestShellAction
from lava_dispatcher.actions.test_strategy import TestShell

from ...test_basic import LavaDispatcherTestCase


class Mockmatch:
    def __init__(self, data):
        self.data = data

    def groups(self):
        return self.data


class MockConnection:
    def __init__(self, data):
        self.match = Mockmatch(data)


class TestTestShell(LavaDispatcherTestCase):
    def test_accepts(self):
        self.assertEqual(
            TestShell.accepts(None, {}), (False, '"definitions" not in parameters')
        )
        self.assertEqual(
            TestShell.accepts(None, {"definitions": {}}), (True, "accepted")
        )

    def test_check_patterns(self):
        job = self.create_simple_job()
        # "exit"
        action = TestShellAction(job)
        with self.assertLogs(action.logger) as action_logs:
            self.assertIs(action.check_patterns("exit", None), False)
        self.assertEqual(
            [(r.levelname, r.message) for r in action_logs.records],
            [("INFO", "ok: lava_test_shell seems to have completed")],
        )

        # "eof"
        action = TestShellAction(job)
        with self.assertRaises(ConnectionClosedError):
            action.check_patterns("eof", None)

        # "timeout"
        action = TestShellAction(job)
        with self.assertRaisesRegex(AssertionError, "no logs"), self.assertLogs(
            action.logger
        ) as action_logs:
            self.assertIs(action.check_patterns("timeout", None), True)

    def test_signal_start_run(self):
        job = self.create_simple_job()

        # "signal.STARTRUN"
        action = TestShellAction(job)
        action.parameters = {"namespace": "common"}
        action.data = {}
        action.set_namespace_data(
            action="test-definition",
            label="test-definition",
            key="testdef_index",
            value=["DEFINITION"],
        )
        action.set_namespace_data(
            action="repo-action", label="repo-action", key="uuid-list", value=["UUID"]
        )

        data = ("STARTRUN", "0_DEFINITION UUID")
        with self.assertLogs(action.logger, level="DEBUG") as action_logs:
            self.assertIs(action.check_patterns("signal", MockConnection(data)), True)
        self.assertEqual(
            [(r.levelname, r.message) for r in action_logs.records],
            [
                ("DEBUG", "Received signal: <STARTRUN> 0_DEFINITION UUID"),
                ("INFO", "Starting test lava.0_DEFINITION (UUID)"),
                ("INFO", "Skipping test definition patterns."),
            ],
        )
        self.assertEqual(
            action.current_run,
            {
                "case": "0_DEFINITION",
                "definition": "lava",
                "result": "fail",
                "uuid": "UUID",
            },
        )
        self.assertEqual(action.patterns, {})

        # "signal.STARTRUN exception"
        action = TestShellAction(job)

        data = ("STARTRUN", "0_DEFINITIO")
        with self.assertRaises(TestError):
            self.assertIs(action.check_patterns("signal", MockConnection(data)), True)

    def test_signal_end_run(self):
        counts = 0

        def monotonic():
            nonlocal counts
            counts += 1
            return counts

        job = self.create_simple_job()

        # "signal.ENDRUN"
        action = TestShellAction(job)
        action.logger.results = MagicMock()
        action.parameters = {"namespace": "common"}
        action.data = {}
        action.set_namespace_data(
            action="test-definition",
            label="test-definition",
            key="testdef_index",
            value=["DEFINITION"],
        )
        action.set_namespace_data(
            action="repo-action", label="repo-action", key="uuid-list", value=["UUID"]
        )

        test_suite = "0_DEFINITION"
        action.reports = {f"{test_suite}": {"results": {}, "ran": False}}
        data = ("ENDRUN", f"{test_suite} UUID")
        with self.assertLogs(action.logger, "DEBUG") as action_logs, patch(
            "time.monotonic", monotonic
        ):
            self.assertIs(action.check_patterns("signal", MockConnection(data)), True)
        self.assertEqual(
            [(r.levelname, r.message) for r in action_logs.records],
            [
                ("DEBUG", f"Received signal: <ENDRUN> {test_suite} UUID"),
                ("INFO", "Ending use of test pattern."),
                ("INFO", f"Ending test lava.{test_suite} (UUID), duration 1.00"),
            ],
        )
        action.logger.results.assert_called_once_with(
            {
                "definition": "lava",
                "case": test_suite,
                "uuid": "UUID",
                "repository": None,
                "path": None,
                "duration": "2.00",
                "result": "pass",
                "revision": "unspecified",
                "namespace": "common",
            }
        )
        self.assertIsNone(action.current_run)
        self.assertTrue(action.reports[test_suite]["ran"])

        # "signal.ENDRUN exception"
        action = TestShellAction(job)

        data = ("ENDRUN", "0_DEFINITIO")
        with self.assertRaises(TestError):
            self.assertIs(action.check_patterns("signal", MockConnection(data)), True)

    def test_signal_end_run_handle_expected(self):
        job = self.create_simple_job()
        action = TestShellAction(job)
        expected_tcs = ["tc1", "tc2", "tc3", "tc4"]
        action.report = {
            "tc1": "pass",
            "tc2": "fail",
        }

        action.logger.results = MagicMock()
        action.parameters = {"namespace": "common"}
        action.data = {}
        action.set_namespace_data(
            action="test",
            label="UUID",
            key="testdef_expected",
            value=expected_tcs,
        )

        # The expected test cases list handler is triggered on "signal.ENDRUN".
        test_suite = "0_DEFINITION"
        action.reports = {f"{test_suite}": {"results": {}, "ran": False}}
        params = [test_suite, "UUID"]
        with self.assertLogs(action.logger, "DEBUG") as action_logs:
            self.assertIs(action.signal_end_run(params), None)

        # Test that missing test case are sorted and reported as 'fail'.
        action.logger.results.assert_has_calls(
            [
                call(
                    {
                        "definition": test_suite,
                        "case": "tc3",
                        "result": "fail",
                        "level": None,
                        "extra": {
                            "reason": "missing expected test cases are reported as 'fail' by LAVA."
                        },
                    }
                ),
                call(
                    {
                        "definition": test_suite,
                        "case": "tc4",
                        "result": "fail",
                        "level": None,
                        "extra": {
                            "reason": "missing expected test cases are reported as 'fail' by LAVA."
                        },
                    }
                ),
            ],
            any_order=False,
        )

        actual_logs = [(r.levelname, r.message) for r in action_logs.records]
        self.assertIn(
            ("WARNING", "Reporting missing expected test cases as 'fail' ..."),
            actual_logs,
        )

        self.assertTrue(action.reports[test_suite]["ran"])

    def test_signal_start_end_tc(self):
        job = self.create_simple_job()

        # "signal.STARTTC"
        action = TestShellAction(job)
        action.logger.marker = MagicMock()

        data = ("STARTTC", "TESTCASE")
        with self.assertLogs(action.logger, "DEBUG") as action_logs:
            self.assertIs(action.check_patterns("signal", MockConnection(data)), True)
        self.assertEqual(
            [(r.levelname, r.message) for r in action_logs.records],
            [("DEBUG", "Received signal: <STARTTC> TESTCASE")],
        )
        action.logger.marker.assert_called_once_with(
            {"case": "TESTCASE", "type": "start_test_case"}
        )

        # "signal.ENDTC"
        action = TestShellAction(job)
        action.logger.marker = MagicMock()

        data = ("ENDTC", "TESTCASE")
        with self.assertLogs(action.logger, "DEBUG") as action_logs:
            self.assertIs(action.check_patterns("signal", MockConnection(data)), True)
        self.assertEqual(
            [(r.levelname, r.message) for r in action_logs.records],
            [("DEBUG", "Received signal: <ENDTC> TESTCASE")],
        )
        action.logger.marker.assert_called_once_with(
            {"case": "TESTCASE", "type": "end_test_case"}
        )

    def test_signal_testcase(self):
        job = self.create_simple_job()

        # "signal.TESTCASE without test_uuid"
        action = TestShellAction(job)
        action.logger.marker = MagicMock()

        data = ("TESTCASE", "hello")
        with self.assertRaises(TestError), self.assertLogs(
            action.logger, "DEBUG"
        ) as action_logs:
            action.check_patterns("signal", MockConnection(data))
        self.assertEqual(
            [(r.levelname, r.message) for r in action_logs.records],
            [
                ("DEBUG", "Received signal: <TESTCASE> hello"),
                (
                    "ERROR",
                    "Unknown test uuid. The STARTRUN signal for this test action was not received correctly.",
                ),
            ],
        )
        action.logger.marker.assert_called_once_with(
            {"case": "hello", "type": "test_case"}
        )

        # "signal.TESTCASE malformed parameters"
        action = TestShellAction(job)
        action.logger.marker = MagicMock()
        action.signal_director.test_uuid = "UUID"
        action.parameters = {"namespace": "common"}

        data = ("TESTCASE", "hello")
        with self.assertLogs(action.logger, "DEBUG") as action_logs:
            self.assertIs(action.check_patterns("signal", MockConnection(data)), True)
        self.assertEqual(
            [(r.levelname, r.message) for r in action_logs.records],
            [
                ("DEBUG", "Received signal: <TESTCASE> hello"),
                ("ERROR", 'Ignoring malformed parameter for signal: "hello". '),
            ],
        )
        action.logger.marker.assert_called_once_with(
            {"case": "hello", "type": "test_case"}
        )

        # "signal.TESTCASE missing TEST_CASE_ID"
        action = TestShellAction(job)
        action.logger.marker = MagicMock()
        action.signal_director.test_uuid = "UUID"

        data = ("TESTCASE", "TEST_CASE=e")
        with self.assertLogs(action.logger, "DEBUG") as action_logs:
            self.assertIs(action.check_patterns("signal", MockConnection(data)), True)
        self.assertEqual(
            [(r.levelname, r.message) for r in action_logs.records],
            [
                ("DEBUG", "Received signal: <TESTCASE> TEST_CASE=e"),
                (
                    "ERROR",
                    "Test case results without test_case_id (probably a sign of an incorrect parsing pattern being used): {'test_case': 'e'}",
                ),
            ],
        )
        action.logger.marker.assert_called_once_with(
            {"case": "TEST_CASE=e", "type": "test_case"}
        )

        # "signal.TESTCASE missing RESULT"
        action = TestShellAction(job)
        action.logger.marker = MagicMock()
        action.signal_director.test_uuid = "UUID"

        data = ("TESTCASE", "TEST_CASE_ID=case-id")
        with self.assertLogs(action.logger, "DEBUG") as action_logs:
            self.assertIs(action.check_patterns("signal", MockConnection(data)), True)
        self.assertEqual(
            [(r.levelname, r.message) for r in action_logs.records],
            [
                ("DEBUG", "Received signal: <TESTCASE> TEST_CASE_ID=case-id"),
                (
                    "ERROR",
                    "Test case results without result (probably a sign of an incorrect parsing pattern being used): {'test_case_id': 'case-id', 'result': 'unknown'}",
                ),
            ],
        )
        action.logger.marker.assert_called_once_with(
            {"case": "case-id", "type": "test_case"}
        )

        # "signal.TESTCASE"
        action = TestShellAction(job)
        action.logger.marker = MagicMock()
        action.logger.results = MagicMock()
        action.signal_director.test_uuid = "UUID"
        action.parameters = {"namespace": "common"}

        data = ("TESTCASE", "RESULT=pass TEST_CASE_ID=case_id")
        with self.assertLogs(action.logger, "DEBUG") as action_logs:
            self.assertIs(action.check_patterns("signal", MockConnection(data)), True)
        self.assertEqual(
            [(r.levelname, r.message) for r in action_logs.records],
            [
                (
                    "DEBUG",
                    "Received signal: <TESTCASE> RESULT=pass TEST_CASE_ID=case_id",
                ),
            ],
        )
        action.logger.marker.assert_called_once_with(
            {"case": "RESULT=pass", "type": "test_case"}
        )
        action.logger.results.assert_called_once_with(
            {"definition": None, "case": "case_id", "result": "pass"}
        )

        # "signal.TESTCASE with measurement"
        action = TestShellAction(job)
        action.logger.marker = MagicMock()
        action.logger.results = MagicMock()
        action.signal_director.test_uuid = "UUID"
        action.parameters = {"namespace": "common"}

        data = ("TESTCASE", "RESULT=pass TEST_CASE_ID=case_id MEASUREMENT=1234")
        with self.assertLogs(action.logger, "DEBUG") as action_logs:
            self.assertIs(action.check_patterns("signal", MockConnection(data)), True)
        self.assertEqual(
            [(r.levelname, r.message) for r in action_logs.records],
            [
                (
                    "DEBUG",
                    "Received signal: <TESTCASE> RESULT=pass TEST_CASE_ID=case_id MEASUREMENT=1234",
                )
            ],
        )
        action.logger.marker.assert_called_once_with(
            {"case": "RESULT=pass", "type": "test_case"}
        )
        action.logger.results.assert_called_once_with(
            {
                "definition": None,
                "case": "case_id",
                "result": "pass",
                "measurement": 1234.0,
            }
        )

        # "signal.TESTCASE with measurement and unit"
        action = TestShellAction(job)
        action.logger.marker = MagicMock()
        action.logger.results = MagicMock()
        action.signal_director.test_uuid = "UUID"
        action.parameters = {"namespace": "common"}

        data = ("TESTCASE", "RESULT=pass TEST_CASE_ID=case_id MEASUREMENT=1234 UNITS=s")
        with self.assertLogs(action.logger, "DEBUG") as action_logs:
            self.assertIs(action.check_patterns("signal", MockConnection(data)), True)
        self.assertEqual(
            [(r.levelname, r.message) for r in action_logs.records],
            [
                (
                    "DEBUG",
                    "Received signal: <TESTCASE> RESULT=pass TEST_CASE_ID=case_id MEASUREMENT=1234 UNITS=s",
                )
            ],
        )
        action.logger.marker.assert_called_once_with(
            {"case": "RESULT=pass", "type": "test_case"}
        )
        action.logger.results.assert_called_once_with(
            {
                "definition": None,
                "case": "case_id",
                "result": "pass",
                "measurement": 1234.0,
                "units": "s",
            }
        )

    def test_signal_testcase_handle_unexpected(self):
        job = self.create_simple_job()

        action = TestShellAction(job)
        action.logger.marker = MagicMock()
        action.logger.results = MagicMock()
        action.signal_director.test_uuid = "UUID"
        action.parameters = {"namespace": "common"}
        action.set_namespace_data(
            action="test",
            label="UUID",
            key="testdef_expected",
            value=["tc1"],
        )

        data = ("TESTCASE", "RESULT=pass TEST_CASE_ID=tc2")
        with self.assertLogs(action.logger, "DEBUG") as action_logs:
            self.assertIs(action.check_patterns("signal", MockConnection(data)), True)

        actual_logs = [(r.levelname, r.message) for r in action_logs.records]
        self.assertIn(
            ("WARNING", "'tc2' not found in expected test case list!"),
            actual_logs,
        )
        self.assertIn(
            (
                "WARNING",
                "Forcing unexpected 'tc2' result 'pass' to 'fail' ...",
            ),
            actual_logs,
        )
        action.logger.results.assert_called_once_with(
            {"definition": None, "case": "tc2", "result": "fail"}
        )
        self.assertEqual(action.report, {"tc2": "fail"})

    def test_signal_test_feedback(self):
        job = self.create_simple_job()

        # "signal.TESTFEEDBACK missing ns"
        action = TestShellAction(job)

        data = ("TESTFEEDBACK", "FEED1")
        with self.assertLogs(action.logger, "DEBUG") as action_logs:
            self.assertIs(action.check_patterns("signal", MockConnection(data)), True)
        self.assertEqual(
            [(r.levelname, r.message) for r in action_logs.records],
            [
                ("DEBUG", "Received signal: <TESTFEEDBACK> FEED1"),
                ("ERROR", "%s is not a valid namespace"),
            ],
        )

    def test_signal_test_reference(self):
        job = self.create_simple_job()

        # "signal.TESTREFERENCE missing parameters"
        action = TestShellAction(job)

        data = ("TESTREFERENCE", "")
        with self.assertRaises(TestError), self.assertLogs(
            action.logger, "DEBUG"
        ) as action_logs:
            action.check_patterns("signal", MockConnection(data))

        self.assertEqual(
            [(r.levelname, r.message) for r in action_logs.records],
            [
                ("DEBUG", "Received signal: <TESTREFERENCE> "),
            ],
        )

        # "signal.TESTREFERENCE"
        action = TestShellAction(job)
        action.logger.results = MagicMock()

        data = ("TESTREFERENCE", "case-id pass http://example.com")
        with self.assertLogs(action.logger, "DEBUG") as action_logs:
            self.assertIs(action.check_patterns("signal", MockConnection(data)), True)
        self.assertEqual(
            [(r.levelname, r.message) for r in action_logs.records],
            [
                (
                    "DEBUG",
                    "Received signal: <TESTREFERENCE> case-id pass http://example.com",
                )
            ],
        )
        action.logger.results.assert_called_once_with(
            {
                "case": "case-id",
                "definition": None,
                "result": "pass",
                "reference": "http://example.com",
            }
        )

    def test_cleanup_no_run(self):
        job = self.create_simple_job()
        action = TestShellAction(job)
        action.parameters = {"namespace": "common"}
        testdefs = [
            {
                "name": "test1",
                "from": "git",
                "expected": ["tc1", "tc2"],
            },
            {
                "name": "test2",
                "from": "git",
                "expected": ["tc3", "tc4"],
            },
        ]
        action.parameters = {
            "definitions": testdefs,
            "stage": 0,
        }
        action.set_namespace_data(
            action="test-definition",
            label="test-definition",
            key="test_list",
            value=[testdefs],
        )
        # No report for test '1_test2'
        action.reports = {
            "0_test1": {"results": {"tc1": "pass", "tc2": "pass"}, "ran": True},
        }

        with patch.object(action, "handle_expected") as mock_handle_expected:
            action.cleanup(None, None)
            self.assertEqual(action.report, {})
            mock_handle_expected.assert_called_once_with(["tc3", "tc4"], "1_test2")

    def test_cleanup_incomplete_run(self):
        job = self.create_simple_job()
        action = TestShellAction(job)
        action.parameters = {"namespace": "common"}
        testdefs = [
            {
                "name": "test1",
                "from": "git",
                "expected": ["tc1", "tc2"],
            },
            {
                "name": "test2",
                "from": "git",
                "expected": ["tc3", "tc4"],
            },
        ]
        action.parameters = {
            "definitions": testdefs,
            "stage": 0,
        }
        action.set_namespace_data(
            action="test-definition",
            label="test-definition",
            key="test_list",
            value=[testdefs],
        )
        action.reports = {
            "0_test1": {"results": {"tc1": "pass", "tc2": "pass"}, "ran": True},
            # Test fails to finish and only one of two results saved.
            "1_test2": {"results": {"tc3": "pass"}, "ran": False},
        }

        with patch.object(action, "handle_expected") as mock_handle_expected:
            action.cleanup(None, None)
            self.assertEqual(action.report, {"tc3": "pass"})
            mock_handle_expected.assert_called_once_with(["tc3", "tc4"], "1_test2")

    def test_cleanup_multi_stages(self):
        job = self.create_simple_job()
        action = TestShellAction(job)
        action.parameters = {"namespace": "common"}
        testdefs = [
            {
                "name": "test3",
                "from": "git",
                "expected": ["tc5", "tc6"],
            },
            {
                "name": "test4",
                "from": "git",
                "expected": ["tc7", "tc8"],
            },
        ]
        action.parameters = {
            "definitions": testdefs,
            "stage": 1,
        }
        action.set_namespace_data(
            action="test-definition",
            label="test-definition",
            key="test_list",
            value=[
                [
                    {
                        "name": "test1",
                        "from": "git",
                        "expected": ["tc1", "tc2"],
                    },
                    {
                        "name": "test2",
                        "from": "git",
                        "expected": ["tc3", "tc4"],
                    },
                ],
                testdefs,
            ],
        )
        # No run.
        action.reports = {}

        with patch.object(action, "handle_expected") as mock_handle_expected:
            action.cleanup(None, None)

            self.assertEqual(mock_handle_expected.call_count, 2)
            mock_handle_expected.assert_any_call(["tc5", "tc6"], "2_test3")
            mock_handle_expected.assert_any_call(["tc7", "tc8"], "3_test4")
