# This file is part of cloud-init. See LICENSE file for license information.

import contextlib
import io
import os
from collections import namedtuple

from cloudinit.cmd import main as cli
from cloudinit.util import load_file, load_json
from tests.unittests import helpers as test_helpers

mock = test_helpers.mock


class TestCLI(test_helpers.FilesystemMockingTestCase):

    with_logs = True

    def setUp(self):
        super(TestCLI, self).setUp()
        self.stderr = io.StringIO()
        self.patchStdoutAndStderr(stderr=self.stderr)

    def _call_main(self, sysv_args=None):
        if not sysv_args:
            sysv_args = ["cloud-init"]
        try:
            return cli.main(sysv_args=sysv_args)
        except SystemExit as e:
            return e.code

    def test_status_wrapper_errors_on_invalid_name(self):
        """status_wrapper will error when the name parameter is not valid.

        Valid name values are only init and modules.
        """
        tmpd = self.tmp_dir()
        data_d = self.tmp_path("data", tmpd)
        link_d = self.tmp_path("link", tmpd)
        FakeArgs = namedtuple("FakeArgs", ["action", "local", "mode"])

        def myaction():
            raise Exception("Should not call myaction")

        myargs = FakeArgs(("doesnotmatter", myaction), False, "bogusmode")
        with self.assertRaises(ValueError) as cm:
            cli.status_wrapper("init1", myargs, data_d, link_d)
        self.assertEqual("unknown name: init1", str(cm.exception))
        self.assertNotIn("Should not call myaction", self.logs.getvalue())

    def test_status_wrapper_errors_on_invalid_modes(self):
        """status_wrapper will error if a parameter combination is invalid."""
        tmpd = self.tmp_dir()
        data_d = self.tmp_path("data", tmpd)
        link_d = self.tmp_path("link", tmpd)
        FakeArgs = namedtuple("FakeArgs", ["action", "local", "mode"])

        def myaction():
            raise Exception("Should not call myaction")

        myargs = FakeArgs(("modules_name", myaction), False, "bogusmode")
        with self.assertRaises(ValueError) as cm:
            cli.status_wrapper("modules", myargs, data_d, link_d)
        self.assertEqual(
            "Invalid cloud init mode specified 'modules-bogusmode'",
            str(cm.exception),
        )
        self.assertNotIn("Should not call myaction", self.logs.getvalue())

    def test_status_wrapper_init_local_writes_fresh_status_info(self):
        """When running in init-local mode, status_wrapper writes status.json.

        Old status and results artifacts are also removed.
        """
        tmpd = self.tmp_dir()
        data_d = self.tmp_path("data", tmpd)
        link_d = self.tmp_path("link", tmpd)
        status_link = self.tmp_path("status.json", link_d)
        # Write old artifacts which will be removed or updated.
        for _dir in data_d, link_d:
            test_helpers.populate_dir(
                _dir, {"status.json": "old", "result.json": "old"}
            )

        FakeArgs = namedtuple("FakeArgs", ["action", "local", "mode"])

        def myaction(name, args):
            # Return an error to watch status capture them
            return "SomeDatasource", ["an error"]

        myargs = FakeArgs(("ignored_name", myaction), True, "bogusmode")
        cli.status_wrapper("init", myargs, data_d, link_d)
        # No errors reported in status
        status_v1 = load_json(load_file(status_link))["v1"]
        self.assertEqual(["an error"], status_v1["init-local"]["errors"])
        self.assertEqual("SomeDatasource", status_v1["datasource"])
        self.assertFalse(
            os.path.exists(self.tmp_path("result.json", data_d)),
            "unexpected result.json found",
        )
        self.assertFalse(
            os.path.exists(self.tmp_path("result.json", link_d)),
            "unexpected result.json link found",
        )

    def test_no_arguments_shows_usage(self):
        exit_code = self._call_main()
        self.assertIn("usage: cloud-init", self.stderr.getvalue())
        self.assertEqual(2, exit_code)

    def test_no_arguments_shows_error_message(self):
        exit_code = self._call_main()
        missing_subcommand_message = [
            "too few arguments",  # python2.7 msg
            "the following arguments are required: subcommand",  # python3 msg
        ]
        error = self.stderr.getvalue()
        matches = [msg in error for msg in missing_subcommand_message]
        self.assertTrue(
            any(matches), "Did not find error message for missing subcommand"
        )
        self.assertEqual(2, exit_code)

    def test_all_subcommands_represented_in_help(self):
        """All known subparsers are represented in the cloud-int help doc."""
        self._call_main()
        error = self.stderr.getvalue()
        expected_subcommands = [
            "analyze",
            "clean",
            "devel",
            "dhclient-hook",
            "features",
            "init",
            "modules",
            "single",
        ]
        for subcommand in expected_subcommands:
            self.assertIn(subcommand, error)

    @mock.patch("cloudinit.cmd.main.status_wrapper")
    def test_init_subcommand_parser(self, m_status_wrapper):
        """The subcommand 'init' calls status_wrapper passing init."""
        self._call_main(["cloud-init", "init"])
        (name, parseargs) = m_status_wrapper.call_args_list[0][0]
        self.assertEqual("init", name)
        self.assertEqual("init", parseargs.subcommand)
        self.assertEqual("init", parseargs.action[0])
        self.assertEqual("main_init", parseargs.action[1].__name__)

    @mock.patch("cloudinit.cmd.main.status_wrapper")
    def test_modules_subcommand_parser(self, m_status_wrapper):
        """The subcommand 'modules' calls status_wrapper passing modules."""
        self._call_main(["cloud-init", "modules"])
        (name, parseargs) = m_status_wrapper.call_args_list[0][0]
        self.assertEqual("modules", name)
        self.assertEqual("modules", parseargs.subcommand)
        self.assertEqual("modules", parseargs.action[0])
        self.assertEqual("main_modules", parseargs.action[1].__name__)

    def test_conditional_subcommands_from_entry_point_sys_argv(self):
        """Subcommands from entry-point are properly parsed from sys.argv."""
        stdout = io.StringIO()
        self.patchStdoutAndStderr(stdout=stdout)

        expected_errors = [
            "usage: cloud-init analyze",
            "usage: cloud-init clean",
            "usage: cloud-init collect-logs",
            "usage: cloud-init devel",
            "usage: cloud-init status",
        ]
        conditional_subcommands = [
            "analyze",
            "clean",
            "collect-logs",
            "devel",
            "status",
        ]
        # The cloud-init entrypoint calls main without passing sys_argv
        for subcommand in conditional_subcommands:
            with mock.patch("sys.argv", ["cloud-init", subcommand, "-h"]):
                try:
                    cli.main()
                except SystemExit as e:
                    self.assertEqual(0, e.code)  # exit 2 on proper -h usage
        for error_message in expected_errors:
            self.assertIn(error_message, stdout.getvalue())

    def test_analyze_subcommand_parser(self):
        """The subcommand cloud-init analyze calls the correct subparser."""
        self._call_main(["cloud-init", "analyze"])
        # These subcommands only valid for cloud-init analyze script
        expected_subcommands = ["blame", "show", "dump"]
        error = self.stderr.getvalue()
        for subcommand in expected_subcommands:
            self.assertIn(subcommand, error)

    def test_collect_logs_subcommand_parser(self):
        """The subcommand cloud-init collect-logs calls the subparser."""
        # Provide -h param to collect-logs to avoid having to mock behavior.
        stdout = io.StringIO()
        self.patchStdoutAndStderr(stdout=stdout)
        self._call_main(["cloud-init", "collect-logs", "-h"])
        self.assertIn("usage: cloud-init collect-log", stdout.getvalue())

    def test_clean_subcommand_parser(self):
        """The subcommand cloud-init clean calls the subparser."""
        # Provide -h param to clean to avoid having to mock behavior.
        stdout = io.StringIO()
        self.patchStdoutAndStderr(stdout=stdout)
        self._call_main(["cloud-init", "clean", "-h"])
        self.assertIn("usage: cloud-init clean", stdout.getvalue())

    def test_status_subcommand_parser(self):
        """The subcommand cloud-init status calls the subparser."""
        # Provide -h param to clean to avoid having to mock behavior.
        stdout = io.StringIO()
        self.patchStdoutAndStderr(stdout=stdout)
        self._call_main(["cloud-init", "status", "-h"])
        self.assertIn("usage: cloud-init status", stdout.getvalue())

    def test_devel_subcommand_parser(self):
        """The subcommand cloud-init devel calls the correct subparser."""
        self._call_main(["cloud-init", "devel"])
        # These subcommands only valid for cloud-init schema script
        expected_subcommands = ["schema"]
        error = self.stderr.getvalue()
        for subcommand in expected_subcommands:
            self.assertIn(subcommand, error)

    def test_wb_devel_schema_subcommand_parser(self):
        """The subcommand cloud-init schema calls the correct subparser."""
        exit_code = self._call_main(["cloud-init", "devel", "schema"])
        self.assertEqual(1, exit_code)
        # Known whitebox output from schema subcommand
        self.assertEqual(
            "Error:\n"
            "Expected one of --config-file, --system or --docs arguments\n",
            self.stderr.getvalue(),
        )

    def test_wb_devel_schema_subcommand_doc_all_spot_check(self):
        """Validate that doc content has correct values from known examples.

        Ensure that schema doc is returned
        """

        # Note: patchStdoutAndStderr() is convenient for reducing boilerplate,
        # but inspecting the code for debugging is not ideal
        # contextlib.redirect_stdout() provides similar behavior as a context
        # manager
        stdout = io.StringIO()
        with contextlib.redirect_stdout(stdout):
            self._call_main(["cloud-init", "devel", "schema", "--docs", "all"])
            expected_doc_sections = [
                "**Supported distros:** all",
                "**Supported distros:** almalinux, alpine, centos, "
                "cloudlinux, debian, eurolinux, fedora, miraclelinux, "
                "openEuler, opensuse, photon, rhel, rocky, sles, ubuntu, "
                "virtuozzo",
                "**Config schema**:\n    **resize_rootfs:** "
                "(true/false/noblock)",
                "**Examples**::\n\n    runcmd:\n        - [ ls, -l, / ]\n",
            ]
        stdout = stdout.getvalue()
        for expected in expected_doc_sections:
            self.assertIn(expected, stdout)

    def test_wb_devel_schema_subcommand_single_spot_check(self):
        """Validate that doc content has correct values from known example.

        Validate 'all' arg
        """

        # Note: patchStdoutAndStderr() is convenient for reducing boilerplate,
        # but inspecting the code for debugging is not ideal
        # contextlib.redirect_stdout() provides similar behavior as a context
        # manager
        stdout = io.StringIO()
        with contextlib.redirect_stdout(stdout):
            self._call_main(
                ["cloud-init", "devel", "schema", "--docs", "cc_runcmd"]
            )
            expected_doc_sections = [
                "Runcmd\n------\n**Summary:** Run arbitrary commands"
            ]
        stdout = stdout.getvalue()
        for expected in expected_doc_sections:
            self.assertIn(expected, stdout)

    def test_wb_devel_schema_subcommand_multiple_spot_check(self):
        """Validate that doc content has correct values from known example.

        Validate single arg
        """

        stdout = io.StringIO()
        with contextlib.redirect_stdout(stdout):
            self._call_main(
                [
                    "cloud-init",
                    "devel",
                    "schema",
                    "--docs",
                    "cc_runcmd",
                    "cc_resizefs",
                ]
            )
            expected_doc_sections = [
                "Runcmd\n------\n**Summary:** Run arbitrary commands",
                "Resizefs\n--------\n**Summary:** Resize filesystem",
            ]
        stdout = stdout.getvalue()
        for expected in expected_doc_sections:
            self.assertIn(expected, stdout)

    def test_wb_devel_schema_subcommand_bad_arg_fails(self):
        """Validate that doc content has correct values from known example.

        Validate multiple args
        """

        # Note: patchStdoutAndStderr() is convenient for reducing boilerplate,
        # but inspecting the code for debugging is not ideal
        # contextlib.redirect_stdout() provides similar behavior as a context
        # manager
        stderr = io.StringIO()
        with contextlib.redirect_stderr(stderr):
            self._call_main(
                ["cloud-init", "devel", "schema", "--docs", "garbage_value"]
            )
            expected_doc_sections = ["Invalid --docs value"]
        stderr = stderr.getvalue()
        for expected in expected_doc_sections:
            self.assertIn(expected, stderr)

    @mock.patch("cloudinit.cmd.main.main_single")
    def test_single_subcommand(self, m_main_single):
        """The subcommand 'single' calls main_single with valid args."""
        self._call_main(["cloud-init", "single", "--name", "cc_ntp"])
        (name, parseargs) = m_main_single.call_args_list[0][0]
        self.assertEqual("single", name)
        self.assertEqual("single", parseargs.subcommand)
        self.assertEqual("single", parseargs.action[0])
        self.assertFalse(parseargs.debug)
        self.assertFalse(parseargs.force)
        self.assertIsNone(parseargs.frequency)
        self.assertEqual("cc_ntp", parseargs.name)
        self.assertFalse(parseargs.report)

    @mock.patch("cloudinit.cmd.main.dhclient_hook.handle_args")
    def test_dhclient_hook_subcommand(self, m_handle_args):
        """The subcommand 'dhclient-hook' calls dhclient_hook with args."""
        self._call_main(["cloud-init", "dhclient-hook", "up", "eth0"])
        (name, parseargs) = m_handle_args.call_args_list[0][0]
        self.assertEqual("dhclient-hook", name)
        self.assertEqual("dhclient-hook", parseargs.subcommand)
        self.assertEqual("dhclient-hook", parseargs.action[0])
        self.assertFalse(parseargs.debug)
        self.assertFalse(parseargs.force)
        self.assertEqual("up", parseargs.event)
        self.assertEqual("eth0", parseargs.interface)

    @mock.patch("cloudinit.cmd.main.main_features")
    def test_features_hook_subcommand(self, m_features):
        """The subcommand 'features' calls main_features with args."""
        self._call_main(["cloud-init", "features"])
        (name, parseargs) = m_features.call_args_list[0][0]
        self.assertEqual("features", name)
        self.assertEqual("features", parseargs.subcommand)
        self.assertEqual("features", parseargs.action[0])
        self.assertFalse(parseargs.debug)
        self.assertFalse(parseargs.force)


# : ts=4 expandtab
