import os
import tempfile
import unittest
from io import StringIO
from unittest.mock import Mock, patch

from tests.helpers import PluginsTestCase, temporary_root
from tutor import config as tutor_config
from tutor import env, exceptions, fmt, plugins
from tutor.__about__ import __version__
from tutor.plugins.v0 import DictPlugin
from tutor.types import Config


class EnvTests(PluginsTestCase):
    def test_walk_templates(self) -> None:
        renderer = env.Renderer()
        templates = list(renderer.walk_templates("local"))
        self.assertIn("local/docker-compose.yml", templates)

    def test_walk_templates_partials_are_ignored(self) -> None:
        template_name = "apps/openedx/settings/partials/common_all.py"
        renderer = env.Renderer()
        templates = list(renderer.walk_templates("apps"))
        self.assertIn(template_name, renderer.environment.loader.list_templates())
        self.assertNotIn(template_name, templates)

    def test_files_are_rendered(self) -> None:
        self.assertTrue(env.is_rendered("some/file"))
        self.assertFalse(env.is_rendered(".git"))
        self.assertFalse(env.is_rendered(".git/subdir"))
        self.assertFalse(env.is_rendered("directory/.git"))
        self.assertFalse(env.is_rendered("directory/.git/somefile"))
        self.assertFalse(env.is_rendered("directory/somefile.pyc"))
        self.assertTrue(env.is_rendered("directory/somedir.pyc/somefile"))
        self.assertFalse(env.is_rendered("directory/__pycache__"))
        self.assertFalse(env.is_rendered("directory/__pycache__/somefile"))
        self.assertFalse(env.is_rendered("directory/partials/extra.scss"))
        self.assertFalse(env.is_rendered("directory/partials"))
        self.assertFalse(env.is_rendered("partials/somefile"))

    def test_is_binary_file(self) -> None:
        self.assertTrue(env.is_binary_file("/home/somefile.ico"))

    def test_find_os_path(self) -> None:
        environment = env.JinjaEnvironment()
        path = environment.find_os_path("local/docker-compose.yml")
        self.assertTrue(os.path.exists(path))

    def test_pathjoin(self) -> None:
        with temporary_root() as root:
            self.assertEqual(
                os.path.join(env.base_dir(root), "dummy"), env.pathjoin(root, "dummy")
            )

    def test_render_str(self) -> None:
        self.assertEqual(
            "hello world", env.render_str({"name": "world"}, "hello {{ name }}")
        )

    def test_render_unknown(self) -> None:
        config: Config = {
            "var1": "a",
        }
        self.assertEqual("ab", env.render_unknown(config, "{{ var1 }}b"))
        self.assertEqual({"x": "ac"}, env.render_unknown(config, {"x": "{{ var1 }}c"}))
        self.assertEqual(["x", "ac"], env.render_unknown(config, ["x", "{{ var1 }}c"]))

    def test_common_domain(self) -> None:
        self.assertEqual(
            "mydomain.com",
            env.render_str(
                {"d1": "d1.mydomain.com", "d2": "d2.mydomain.com"},
                "{{ d1|common_domain(d2) }}",
            ),
        )

    def test_render_str_missing_configuration(self) -> None:
        self.assertRaises(exceptions.TutorError, env.render_str, {}, "hello {{ name }}")

    def test_render_file(self) -> None:
        config: Config = {}
        tutor_config.update_with_base(config)
        tutor_config.update_with_defaults(config)
        tutor_config.render_full(config)

        config["MYSQL_ROOT_PASSWORD"] = "testpassword"
        rendered = env.render_file(config, "jobs", "init", "mysql.sh")
        self.assertIn("testpassword", rendered)

    @patch.object(fmt, "echo")
    def test_render_file_missing_configuration(self, _: Mock) -> None:
        self.assertRaises(
            exceptions.TutorError, env.render_file, {}, "local", "docker-compose.yml"
        )

    def test_save_full(self) -> None:
        with temporary_root() as root:
            config = tutor_config.load_full(root)
            with patch.object(fmt, "STDOUT"):
                env.save(root, config)
            self.assertTrue(
                os.path.exists(
                    os.path.join(env.base_dir(root), "local", "docker-compose.yml")
                )
            )

    def test_save_full_with_https(self) -> None:
        with temporary_root() as root:
            config = tutor_config.load_full(root)
            config["ENABLE_HTTPS"] = True
            with patch.object(fmt, "STDOUT"):
                env.save(root, config)
                with open(
                    os.path.join(env.base_dir(root), "apps", "caddy", "Caddyfile"),
                    encoding="utf-8",
                ) as f:
                    self.assertIn("www.myopenedx.com{$default_site_port}", f.read())

    def test_patch(self) -> None:
        patches = {"plugin1": "abcd", "plugin2": "efgh"}
        with patch.object(
            plugins, "iter_patches", return_value=patches.values()
        ) as mock_iter_patches:
            rendered = env.render_str({}, '{{ patch("location") }}')
            mock_iter_patches.assert_called_once_with("location")
        self.assertEqual("abcd\nefgh", rendered)

    def test_patch_separator_suffix(self) -> None:
        patches = {"plugin1": "abcd", "plugin2": "efgh"}
        with patch.object(plugins, "iter_patches", return_value=patches.values()):
            rendered = env.render_str(
                {}, '{{ patch("location", separator=",\n", suffix=",") }}'
            )
        self.assertEqual("abcd,\nefgh,", rendered)

    def test_plugin_templates(self) -> None:
        with tempfile.TemporaryDirectory() as plugin_templates:
            DictPlugin(
                {"name": "plugin1", "version": "0", "templates": plugin_templates}
            )
            # Create two templates
            os.makedirs(os.path.join(plugin_templates, "plugin1", "apps"))
            with open(
                os.path.join(plugin_templates, "plugin1", "unrendered.txt"),
                "w",
                encoding="utf-8",
            ) as f:
                f.write("This file should not be rendered")
            with open(
                os.path.join(plugin_templates, "plugin1", "apps", "rendered.txt"),
                "w",
                encoding="utf-8",
            ) as f:
                f.write("Hello my ID is {{ ID }}")

            # Render templates
            with temporary_root() as root:
                # Create configuration
                config: Config = tutor_config.load_full(root)
                config["ID"] = "Hector Rumblethorpe"
                plugins.load("plugin1")
                tutor_config.save_enabled_plugins(config)

                # Render environment
                with patch.object(fmt, "STDOUT"):
                    env.save(root, config)

                # Check that plugin template was rendered
                root_env = os.path.join(root, "env")
                dst_unrendered = os.path.join(
                    root_env, "plugins", "plugin1", "unrendered.txt"
                )
                dst_rendered = os.path.join(
                    root_env, "plugins", "plugin1", "apps", "rendered.txt"
                )
                self.assertFalse(os.path.exists(dst_unrendered))
                self.assertTrue(os.path.exists(dst_rendered))
                with open(dst_rendered, encoding="utf-8") as f:
                    self.assertEqual("Hello my ID is Hector Rumblethorpe", f.read())

    def test_renderer_is_reset_on_config_change(self) -> None:
        with tempfile.TemporaryDirectory() as plugin_templates:
            plugin1 = DictPlugin(
                {"name": "plugin1", "version": "0", "templates": plugin_templates}
            )

            # Create one template
            os.makedirs(os.path.join(plugin_templates, plugin1.name))
            with open(
                os.path.join(plugin_templates, plugin1.name, "myplugin.txt"),
                "w",
                encoding="utf-8",
            ) as f:
                f.write("some content")

            # Load env once
            config: Config = {"PLUGINS": []}
            env1 = env.Renderer(config).environment

            # Enable plugins
            plugins.load("plugin1")

            # Load env a second time
            config["PLUGINS"] = ["myplugin"]
            env2 = env.Renderer(config).environment

            self.assertNotIn("plugin1/myplugin.txt", env1.loader.list_templates())
            self.assertIn("plugin1/myplugin.txt", env2.loader.list_templates())

    def test_iter_values_named(self) -> None:
        config: Config = {
            "something0_test_app": 0,
            "something1_test_not_app": 1,
            "notsomething_test_app": 2,
            "something3_test_app": 3,
        }
        renderer = env.Renderer(config)
        self.assertEqual([2, 3], list(renderer.iter_values_named(suffix="test_app")))
        self.assertEqual([1, 3], list(renderer.iter_values_named(prefix="something")))
        self.assertEqual(
            [0, 3],
            list(
                renderer.iter_values_named(
                    prefix="something", suffix="test_app", allow_empty=True
                )
            ),
        )


class CurrentVersionTests(unittest.TestCase):
    def test_current_version_in_empty_env(self) -> None:
        with temporary_root() as root:
            self.assertIsNone(env.current_version(root))
            self.assertIsNone(env.get_env_release(root))
            self.assertIsNone(env.should_upgrade_from_release(root))
            self.assertTrue(env.is_up_to_date(root))

    def test_current_version_in_lilac_env(self) -> None:
        with temporary_root() as root:
            os.makedirs(env.base_dir(root))
            with open(
                os.path.join(env.base_dir(root), env.VERSION_FILENAME),
                "w",
                encoding="utf-8",
            ) as f:
                f.write("12.0.46")
            self.assertEqual("12.0.46", env.current_version(root))
            self.assertEqual("lilac", env.get_env_release(root))
            self.assertEqual("lilac", env.should_upgrade_from_release(root))
            self.assertFalse(env.is_up_to_date(root))

    def test_current_version_in_latest_env(self) -> None:
        with temporary_root() as root:
            os.makedirs(env.base_dir(root))
            with open(
                os.path.join(env.base_dir(root), env.VERSION_FILENAME),
                "w",
                encoding="utf-8",
            ) as f:
                f.write(__version__)
            self.assertEqual(__version__, env.current_version(root))
            self.assertEqual("palm", env.get_env_release(root))
            self.assertIsNone(env.should_upgrade_from_release(root))
            self.assertTrue(env.is_up_to_date(root))


class PatchRendererTests(unittest.TestCase):
    def setUp(self) -> None:
        self.render = env.PatchRenderer()
        self.render.current_template = "current_template"
        return super().setUp()

    @patch("tutor.env.Renderer.render_template")
    def test_render_template(self, render_template_mock: Mock) -> None:
        """Test that render_template changes the current template and
        calls once render_template from Renderer with the current template."""
        self.render.render_template("new_template")

        self.assertEqual(self.render.current_template, "new_template")
        render_template_mock.assert_called_once_with("new_template")

    @patch("tutor.env.Renderer.patch")
    def test_patch_with_first_patch(self, patch_mock: Mock) -> None:
        """Test that patch is called from Renderer and adds patches_locations
        when we didn't have that patch."""
        self.render.patches_locations = {}

        self.render.patch("first_patch")

        patch_mock.assert_called_once_with("first_patch", separator="\n", suffix="")
        self.assertEqual(
            self.render.patches_locations,
            {"first_patch": [self.render.current_template]},
        )

    def test_patch_with_patch_multiple_locations(self) -> None:
        """Test add more locations to a patch."""
        self.render.patches_locations = {"first_patch": ["template_1"]}

        self.render.patch("first_patch")

        self.assertEqual(
            self.render.patches_locations,
            {"first_patch": ["template_1", "current_template"]},
        )

    @patch("tutor.env.plugins.iter_patches")
    def test_patch_with_custom_patch_in_a_plugin_patch(
        self, iter_patches_mock: Mock
    ) -> None:
        """Test the patch function with a plugin with a custom patch.
        Examples:
        - When first_patch is in a plugin patches and has a 'custom_patch',
        the patches_locations will reflect that 'custom_patch' is from
        first_patch location.
        - If in tutor-mfe/tutormfe/patches/caddyfile you add a custom patch
        inside the caddyfile patch, the patches_locations will reflect that.

        Expected behavior:
        - Process the first_patch and find the custom_patch in a plugin with
        first_patch patch.
        - Process the custom_patch and add "within patch: first_patch" in the
        patches_locations."""
        iter_patches_mock.side_effect = [
            ["""{{ patch('custom_patch')|indent(4) }}"""],
            [],
        ]
        self.render.patches_locations = {}
        calls = [unittest.mock.call("first_patch"), unittest.mock.call("custom_patch")]

        self.render.patch("first_patch")

        iter_patches_mock.assert_has_calls(calls)
        self.assertEqual(
            self.render.patches_locations,
            {
                "first_patch": ["current_template"],
                "custom_patch": ["within patch: first_patch"],
            },
        )

    @patch("tutor.env.plugins.iter_patches")
    def test_patch_with_processed_patch_in_a_plugin_patch(
        self, iter_patches_mock: Mock
    ) -> None:
        """Test the patch function with a plugin with a processed patch.
        Example:
        - When first_patch was processed and the second_patch is used in a
        plugin and call the first_patch again. Then the patches_locations will
        reflect that first_patch also have a location from second_patch."""
        iter_patches_mock.side_effect = [
            ["""{{ patch('first_patch')|indent(4) }}"""],
            [],
        ]
        self.render.patches_locations = {"first_patch": ["current_template"]}

        self.render.patch("second_patch")

        self.assertEqual(
            self.render.patches_locations,
            {
                "first_patch": ["current_template", "within patch: second_patch"],
                "second_patch": ["current_template"],
            },
        )

    @patch("tutor.env.Renderer.iter_templates_in")
    @patch("tutor.env.PatchRenderer.render_template")
    def test_render_all(
        self, render_template_mock: Mock, iter_templates_in_mock: Mock
    ) -> None:
        """Test render_template was called for templates in iter_templates_in."""
        iter_templates_in_mock.return_value = ["template_1", "template_2"]
        calls = [unittest.mock.call("template_1"), unittest.mock.call("template_2")]

        self.render.render_all()

        iter_templates_in_mock.assert_called_once()
        render_template_mock.assert_has_calls(calls)

    @patch("sys.stdout", new_callable=StringIO)
    @patch("tutor.env.PatchRenderer.render_all")
    def test_print_patches_locations(
        self, render_all_mock: Mock, stdout_mock: Mock
    ) -> None:
        """Test render_all was called and the output of print_patches_locations."""
        self.render.patches_locations = {"first_patch": ["template_1", "template_2"]}

        self.render.print_patches_locations()

        render_all_mock.assert_called_once()
        self.assertEqual(
            """
PATCH      	LOCATIONS
first_patch	template_1
           	template_2
""".strip(),
            stdout_mock.getvalue().strip(),
        )
