# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# *                                                                         *
# *   Copyright (c) 2022 FreeCAD Project Association                        *
# *                                                                         *
# *   This file is part of FreeCAD.                                         *
# *                                                                         *
# *   FreeCAD is free software: you can redistribute it and/or modify it    *
# *   under the terms of the GNU Lesser General Public License as           *
# *   published by the Free Software Foundation, either version 2.1 of the  *
# *   License, or (at your option) any later version.                       *
# *                                                                         *
# *   FreeCAD is distributed in the hope that it will be useful, but        *
# *   WITHOUT ANY WARRANTY; without even the implied warranty of            *
# *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU      *
# *   Lesser General Public License for more details.                       *
# *                                                                         *
# *   You should have received a copy of the GNU Lesser General Public      *
# *   License along with FreeCAD. If not, see                               *
# *   <https://www.gnu.org/licenses/>.                                      *
# *                                                                         *
# ***************************************************************************

"""Contains the unit test class for addonmanager_uninstaller.py non-GUI functionality."""

import functools
import os
from stat import S_IREAD, S_IRGRP, S_IROTH, S_IWUSR
import tempfile
import unittest

import FreeCAD

from addonmanager_uninstaller import AddonUninstaller, MacroUninstaller

from Addon import Addon
from AddonManagerTest.app.mocks import MockAddon, MockMacro


class TestAddonUninstaller(unittest.TestCase):
    """Test class for addonmanager_uninstaller.py non-GUI functionality"""

    MODULE = "test_uninstaller"  # file name without extension

    def setUp(self):
        """Initialize data needed for all tests"""
        self.test_data_dir = os.path.join(
            FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data"
        )
        self.mock_addon = MockAddon()
        self.signals_caught = []
        self.test_object = AddonUninstaller(self.mock_addon)

        self.test_object.finished.connect(
            functools.partial(self.catch_signal, "finished")
        )
        self.test_object.success.connect(
            functools.partial(self.catch_signal, "success")
        )
        self.test_object.failure.connect(
            functools.partial(self.catch_signal, "failure")
        )

    def tearDown(self):
        """Finalize the test."""

    def catch_signal(self, signal_name, *_):
        """Internal use: used to catch and log any emitted signals. Not called directly."""
        self.signals_caught.append(signal_name)

    def setup_dummy_installation(self, temp_dir) -> str:
        """Set up a dummy Addon in temp_dir"""
        toplevel_path = os.path.join(temp_dir, self.mock_addon.name)
        os.makedirs(toplevel_path)
        with open(os.path.join(toplevel_path, "README.md"), "w") as f:
            f.write("## Mock Addon ##\n\nFile created by the unit test code.")
        self.test_object.installation_path = temp_dir
        return toplevel_path

    def create_fake_macro(self, macro_directory, fake_macro_name, digest):
        """Create an FCMacro file and matching digest entry for later removal"""
        os.makedirs(macro_directory, exist_ok=True)
        fake_file_installed = os.path.join(macro_directory, fake_macro_name)
        with open(digest, "a", encoding="utf-8") as f:
            f.write("# The following files were created outside this installation:\n")
            f.write(fake_file_installed + "\n")
        with open(fake_file_installed, "w", encoding="utf-8") as f:
            f.write("# Fake macro data for unit testing")

    def test_uninstall_normal(self):
        """Test the integrated uninstall function under normal circumstances"""

        with tempfile.TemporaryDirectory() as temp_dir:
            toplevel_path = self.setup_dummy_installation(temp_dir)
            self.test_object.run()
            self.assertTrue(os.path.exists(temp_dir))
            self.assertFalse(os.path.exists(toplevel_path))
            self.assertNotIn("failure", self.signals_caught)
            self.assertIn("success", self.signals_caught)
            self.assertIn("finished", self.signals_caught)

    def test_uninstall_no_name(self):
        """Test the integrated uninstall function for an addon without a name"""

        with tempfile.TemporaryDirectory() as temp_dir:
            toplevel_path = self.setup_dummy_installation(temp_dir)
            self.mock_addon.name = None
            result = self.test_object.run()
            self.assertTrue(os.path.exists(temp_dir))
            self.assertIn("failure", self.signals_caught)
            self.assertNotIn("success", self.signals_caught)
            self.assertIn("finished", self.signals_caught)

    def test_uninstall_dangerous_name(self):
        """Test the integrated uninstall function for an addon with a dangerous name"""

        with tempfile.TemporaryDirectory() as temp_dir:
            toplevel_path = self.setup_dummy_installation(temp_dir)
            self.mock_addon.name = "./"
            result = self.test_object.run()
            self.assertTrue(os.path.exists(temp_dir))
            self.assertIn("failure", self.signals_caught)
            self.assertNotIn("success", self.signals_caught)
            self.assertIn("finished", self.signals_caught)

    def test_uninstall_unmatching_name(self):
        """Test the integrated uninstall function for an addon with a name that isn't installed"""

        with tempfile.TemporaryDirectory() as temp_dir:
            toplevel_path = self.setup_dummy_installation(temp_dir)
            self.mock_addon.name += "Nonexistent"
            result = self.test_object.run()
            self.assertTrue(os.path.exists(temp_dir))
            self.assertIn("failure", self.signals_caught)
            self.assertNotIn("success", self.signals_caught)
            self.assertIn("finished", self.signals_caught)

    def test_uninstall_addon_with_macros(self):
        """Tests that the uninstaller removes the macro files"""
        with tempfile.TemporaryDirectory() as temp_dir:
            toplevel_path = self.setup_dummy_installation(temp_dir)
            macro_directory = os.path.join(temp_dir, "Macros")
            self.create_fake_macro(
                macro_directory,
                "FakeMacro.FCMacro",
                os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
            )
            result = self.test_object.run()
            self.assertNotIn("failure", self.signals_caught)
            self.assertIn("success", self.signals_caught)
            self.assertIn("finished", self.signals_caught)
            self.assertFalse(
                os.path.exists(os.path.join(macro_directory, "FakeMacro.FCMacro"))
            )
            self.assertTrue(os.path.exists(macro_directory))

    def test_uninstall_calls_script(self):
        """Tests that the main uninstaller run function calls the uninstall.py script"""

        class Interceptor:
            def __init__(self):
                self.called = False
                self.args = []

            def func(self, *args):
                self.called = True
                self.args = args

        interceptor = Interceptor()
        with tempfile.TemporaryDirectory() as temp_dir:
            toplevel_path = self.setup_dummy_installation(temp_dir)
            self.test_object.run_uninstall_script = interceptor.func
            result = self.test_object.run()
            self.assertTrue(interceptor.called, "Failed to call uninstall script")

    def test_remove_extra_files_no_digest(self):
        """Tests that a lack of digest file is not an error, and nothing gets removed"""
        with tempfile.TemporaryDirectory() as temp_dir:
            self.test_object.remove_extra_files(temp_dir)  # Shouldn't throw
            self.assertTrue(os.path.exists(temp_dir))

    def test_remove_extra_files_empty_digest(self):
        """Test that an empty digest file is not an error, and nothing gets removed"""
        with tempfile.TemporaryDirectory() as temp_dir:
            with open("AM_INSTALLATION_DIGEST.txt", "w", encoding="utf-8") as f:
                f.write("")
            self.test_object.remove_extra_files(temp_dir)  # Shouldn't throw
            self.assertTrue(os.path.exists(temp_dir))

    def test_remove_extra_files_comment_only_digest(self):
        """Test that a digest file that contains only comment lines is not an error, and nothing
        gets removed"""
        with tempfile.TemporaryDirectory() as temp_dir:
            with open("AM_INSTALLATION_DIGEST.txt", "w", encoding="utf-8") as f:
                f.write("# Fake digest file for unit testing")
            self.test_object.remove_extra_files(temp_dir)  # Shouldn't throw
            self.assertTrue(os.path.exists(temp_dir))

    def test_remove_extra_files_repeated_files(self):
        """Test that a digest with the same file repeated removes it once, but doesn't error on
        later requests to remove it."""
        with tempfile.TemporaryDirectory() as temp_dir:
            toplevel_path = self.setup_dummy_installation(temp_dir)
            macro_directory = os.path.join(temp_dir, "Macros")
            self.create_fake_macro(
                macro_directory,
                "FakeMacro.FCMacro",
                os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
            )
            self.create_fake_macro(
                macro_directory,
                "FakeMacro.FCMacro",
                os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
            )
            self.create_fake_macro(
                macro_directory,
                "FakeMacro.FCMacro",
                os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
            )
            self.test_object.remove_extra_files(toplevel_path)  # Shouldn't throw
            self.assertFalse(
                os.path.exists(os.path.join(macro_directory, "FakeMacro.FCMacro"))
            )

    def test_remove_extra_files_normal_case(self):
        """Test that a digest that is a "normal" case removes the requested files"""
        with tempfile.TemporaryDirectory() as temp_dir:
            toplevel_path = self.setup_dummy_installation(temp_dir)
            macro_directory = os.path.join(temp_dir, "Macros")
            self.create_fake_macro(
                macro_directory,
                "FakeMacro1.FCMacro",
                os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
            )
            self.create_fake_macro(
                macro_directory,
                "FakeMacro2.FCMacro",
                os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
            )
            self.create_fake_macro(
                macro_directory,
                "FakeMacro3.FCMacro",
                os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
            )

            # Make sure the setup worked as expected, otherwise the test is meaningless
            self.assertTrue(
                os.path.exists(os.path.join(macro_directory, "FakeMacro1.FCMacro"))
            )
            self.assertTrue(
                os.path.exists(os.path.join(macro_directory, "FakeMacro2.FCMacro"))
            )
            self.assertTrue(
                os.path.exists(os.path.join(macro_directory, "FakeMacro3.FCMacro"))
            )

            self.test_object.remove_extra_files(toplevel_path)  # Shouldn't throw

            self.assertFalse(
                os.path.exists(os.path.join(macro_directory, "FakeMacro1.FCMacro"))
            )
            self.assertFalse(
                os.path.exists(os.path.join(macro_directory, "FakeMacro2.FCMacro"))
            )
            self.assertFalse(
                os.path.exists(os.path.join(macro_directory, "FakeMacro3.FCMacro"))
            )

    def test_runs_uninstaller_script_successful(self):
        """Tests that the uninstall.py script is called"""
        with tempfile.TemporaryDirectory() as temp_dir:
            toplevel_path = self.setup_dummy_installation(temp_dir)
            with open(
                os.path.join(toplevel_path, "uninstall.py"), "w", encoding="utf-8"
            ) as f:
                double_escaped = temp_dir.replace("\\", "\\\\")
                f.write(
                    f"""# Mock uninstaller script
import os
path = '{double_escaped}'
with open(os.path.join(path,"RAN_UNINSTALLER.txt"),"w",encoding="utf-8") as f:
    f.write("File created by uninstall.py from unit tests")
"""
                )
            self.test_object.run_uninstall_script(
                toplevel_path
            )  # The exception does not leak out
            self.assertTrue(
                os.path.exists(os.path.join(temp_dir, "RAN_UNINSTALLER.txt"))
            )

    def test_runs_uninstaller_script_failure(self):
        """Tests that exceptions in the uninstall.py script do not leak out"""
        with tempfile.TemporaryDirectory() as temp_dir:
            toplevel_path = self.setup_dummy_installation(temp_dir)
            with open(
                os.path.join(toplevel_path, "uninstall.py"), "w", encoding="utf-8"
            ) as f:
                f.write(
                    f"""# Mock uninstaller script
raise RuntimeError("Fake exception for unit testing")
"""
                )
            self.test_object.run_uninstall_script(
                toplevel_path
            )  # The exception does not leak out


class TestMacroUninstaller(unittest.TestCase):
    """Test class for addonmanager_uninstaller.py non-GUI functionality"""

    MODULE = "test_uninstaller"  # file name without extension

    def setUp(self):
        self.mock_addon = MockAddon()
        self.mock_addon.macro = MockMacro()
        self.test_object = MacroUninstaller(self.mock_addon)
        self.signals_caught = []

        self.test_object.finished.connect(
            functools.partial(self.catch_signal, "finished")
        )
        self.test_object.success.connect(
            functools.partial(self.catch_signal, "success")
        )
        self.test_object.failure.connect(
            functools.partial(self.catch_signal, "failure")
        )

    def tearDown(self):
        pass

    def catch_signal(self, signal_name, *_):
        """Internal use: used to catch and log any emitted signals. Not called directly."""
        self.signals_caught.append(signal_name)

    def test_remove_simple_macro(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            self.test_object.installation_location = temp_dir
            self.mock_addon.macro.install(temp_dir)
            # Make sure the setup worked, otherwise the test is meaningless
            self.assertTrue(
                os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename))
            )
            self.test_object.run()
            self.assertFalse(
                os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename))
            )
            self.assertNotIn("failure", self.signals_caught)
            self.assertIn("success", self.signals_caught)
            self.assertIn("finished", self.signals_caught)

    def test_remove_macro_with_icon(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            self.test_object.installation_location = temp_dir
            self.mock_addon.macro.icon = "mock_icon_test.svg"
            self.mock_addon.macro.install(temp_dir)
            # Make sure the setup worked, otherwise the test is meaningless
            self.assertTrue(
                os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename))
            )
            self.assertTrue(
                os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.icon))
            )
            self.test_object.run()
            self.assertFalse(
                os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename))
            )
            self.assertFalse(
                os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.icon))
            )
            self.assertNotIn("failure", self.signals_caught)
            self.assertIn("success", self.signals_caught)
            self.assertIn("finished", self.signals_caught)

    def test_remove_macro_with_xpm_data(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            self.test_object.installation_location = temp_dir
            self.mock_addon.macro.xpm = "/*Fake XPM data*/"
            self.mock_addon.macro.install(temp_dir)
            # Make sure the setup worked, otherwise the test is meaningless
            self.assertTrue(
                os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename))
            )
            self.assertTrue(
                os.path.exists(os.path.join(temp_dir, "MockMacro_icon.xpm"))
            )
            self.test_object.run()
            self.assertFalse(
                os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename))
            )
            self.assertFalse(
                os.path.exists(os.path.join(temp_dir, "MockMacro_icon.xpm"))
            )
            self.assertNotIn("failure", self.signals_caught)
            self.assertIn("success", self.signals_caught)
            self.assertIn("finished", self.signals_caught)

    def test_remove_macro_with_files(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            self.test_object.installation_location = temp_dir
            self.mock_addon.macro.other_files = [
                "test_file_1.txt",
                "test_file_2.FCMacro",
                "subdir/test_file_3.txt",
            ]
            self.mock_addon.macro.install(temp_dir)
            # Make sure the setup worked, otherwise the test is meaningless
            for f in self.mock_addon.macro.other_files:
                self.assertTrue(
                    os.path.exists(os.path.join(temp_dir, f)),
                    f"Expected {f} to exist, and it does not",
                )
            self.test_object.run()
            for f in self.mock_addon.macro.other_files:
                self.assertFalse(
                    os.path.exists(os.path.join(temp_dir, f)),
                    f"Expected {f} to be removed, and it was not",
                )
            self.assertFalse(
                os.path.exists(os.path.join(temp_dir, "subdir")),
                "Failed to remove empty subdirectory",
            )
            self.assertNotIn("failure", self.signals_caught)
            self.assertIn("success", self.signals_caught)
            self.assertIn("finished", self.signals_caught)

    def test_remove_nonexistent_macro(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            self.test_object.installation_location = temp_dir
            # Don't run the installer:

            self.assertFalse(
                os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename))
            )
            self.test_object.run()  # Should not raise an exception
            self.assertFalse(
                os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename))
            )
            self.assertNotIn("failure", self.signals_caught)
            self.assertIn("success", self.signals_caught)
            self.assertIn("finished", self.signals_caught)

    def test_remove_write_protected_macro(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            self.test_object.installation_location = temp_dir
            self.mock_addon.macro.install(temp_dir)
            # Make sure the setup worked, otherwise the test is meaningless
            f = os.path.join(temp_dir, self.mock_addon.macro.filename)
            self.assertTrue(os.path.exists(f))
            os.chmod(f, S_IREAD | S_IRGRP | S_IROTH)
            self.test_object.run()

            if os.path.exists(f):
                os.chmod(f, S_IWUSR | S_IREAD)
                self.assertNotIn("success", self.signals_caught)
                self.assertIn("failure", self.signals_caught)
            else:
                # In some cases we managed to delete it anyway:
                self.assertIn("success", self.signals_caught)
                self.assertNotIn("failure", self.signals_caught)
            self.assertIn("finished", self.signals_caught)

    def test_cleanup_directories_multiple_empty(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            empty_directories = set(["empty1", "empty2", "empty3"])
            full_paths = set()
            for directory in empty_directories:
                full_path = os.path.join(temp_dir, directory)
                os.mkdir(full_path)
                full_paths.add(full_path)

            for directory in full_paths:
                self.assertTrue(directory, "Test code failed to create {directory}")
            self.test_object._cleanup_directories(full_paths)
            for directory in full_paths:
                self.assertFalse(os.path.exists(directory))

    def test_cleanup_directories_none(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            full_paths = set()
            self.test_object._cleanup_directories(full_paths)  # Shouldn't throw

    def test_cleanup_directories_not_empty(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            empty_directories = set(["empty1", "empty2", "empty3"])
            full_paths = set()
            for directory in empty_directories:
                full_path = os.path.join(temp_dir, directory)
                os.mkdir(full_path)
                full_paths.add(full_path)
                with open(
                    os.path.join(full_path, "test.txt"), "w", encoding="utf-8"
                ) as f:
                    f.write("Unit test dummy data\n")

            for directory in full_paths:
                self.assertTrue(directory, "Test code failed to create {directory}")
            self.test_object._cleanup_directories(full_paths)
            for directory in full_paths:
                self.assertTrue(os.path.exists(directory))
