# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (c) 2008-2026 Andrew Ziem.
#
# This work is licensed under the terms of the GNU GPL, version 3 or
# later.  See the COPYING file in the top-level directory.


"""
Test case for module GUI
"""

import os
import sys
import unittest
import time
import types
import warnings
from unittest import mock

import bleachbit
from bleachbit.Cleaner import backends
from bleachbit.GtkShim import HAVE_GTK, Gdk, Gio, GLib, GObject, Gtk
from bleachbit.Language import get_text as _
from bleachbit.Options import options
from tests import common

if HAVE_GTK:
    from bleachbit.GuiUtil import (clear_clipboard, get_font_size_from_name,
                                   get_window_info)
    from bleachbit.GuiTreeModels import TreeDisplayModel

bleachbit.online_update_notification_enabled = False


@unittest.skipUnless(HAVE_GTK, 'requires GTK+ module and a display environment')
class GUITestCase(common.BleachbitTestCase):
    app = None
    options_get_tree = options.get_tree
    _NEW_CLEANER_ID, _NEW_OPTION_ID = 'test_run_operations', 'test1'

    """Test case for module GUI"""
    @classmethod
    def setUpClass(cls):
        from bleachbit.GuiApplication import Bleachbit
        cls.app = Bleachbit(auto_exit=False, uac=False)
        cls._lang_env = common.set_temporary_env('LANGUAGE', 'en')
        cls._lang_env.__enter__()
        # reminder: the set up in the parent class creates a clean
        # configuration file.
        super(GUITestCase, GUITestCase).setUpClass()
        options.get_tree = types.MethodType(
            lambda self, parent, child: False, options)
        options.set('font_check_completed', True)
        try:
            cls.app.register()
        except GLib.GError as e:
            if "already exported" in str(e):
                cls.app = Gio.Application.get_default()
            else:
                raise
        with common.capture_glib_exceptions() as glib_errors:
            cls.app.activate()
            cls.refresh_gui()
        if glib_errors:
            _exc_type, exc_value, exc_tb = glib_errors[0]
            raise exc_value.with_traceback(exc_tb)

    @classmethod
    def tearDownClass(cls):
        super(GUITestCase, GUITestCase).tearDownClass()
        options.get_tree = cls.options_get_tree
        cls._lang_env.__exit__(None, None, None)
        window = cls.get_window()
        if window:
            window.destroy()
            cls.clear_window()

    @classmethod
    def get_window(cls):
        return getattr(cls.app, '_window', None)

    @classmethod
    def clear_window(cls):
        if cls.app:
            setattr(cls.app, '_window', None)

    @classmethod
    def refresh_gui(cls, delay=0):
        while Gtk.events_pending():
            if sys.version_info >= (3, 14):
                with warnings.catch_warnings():
                    warnings.simplefilter("error")
                    warnings.filterwarnings(
                        "ignore",
                        message=".*asyncio.AbstractEventLoopPolicy.*",
                        category=DeprecationWarning
                    )
                    warnings.filterwarnings(
                        "ignore",
                        message=".*asyncio.get_event_loop_policy.*",
                        category=DeprecationWarning
                    )
                    Gtk.main_iteration_do(blocking=False)
            else:
                Gtk.main_iteration_do(blocking=False)
        time.sleep(delay)

    @classmethod
    def print_widget(cls, widget, indent=0):
        print('{}{}'.format(' ' * indent, widget))
        if isinstance(widget, Gtk.Container):
            for c in widget.get_children():
                cls.print_widget(c, indent + 2)

    @classmethod
    def find_widget(cls, widget, widget_class, widget_label=None):
        if isinstance(widget, widget_class):
            if widget_label is None or widget.get_label() == widget_label:
                return widget
        if isinstance(widget, Gtk.Container):
            for c in widget.get_children():
                b = cls.find_widget(c, widget_class, widget_label)
                if b is not None:
                    return b
        return None

    @classmethod
    def get_iter(cls, model, cleaner):
        it = model.get_iter(Gtk.TreePath(0))
        while it:
            if model[it][2] == cleaner:
                return model.iter_children(it)
            it = model.iter_next(it)
        return None

    @classmethod
    def find_option(cls, model, cleaner, option):
        it = cls.get_iter(model, cleaner)
        # cls.assertIsNotNone(cls, it)
        while it:
            if model[it][2] == option:
                return it
            it = model.iter_next(it)
        return None

    def _put_checkmark_on_cleaner(self, gui, cleaner_id, option_id):
        model = gui.view.get_model()
        tree = self.find_widget(gui, Gtk.TreeView)
        self.assertIsNotNone(tree)
        it = self.find_option(model, cleaner_id, option_id)
        self.assertIsNotNone(it)
        tree.scroll_to_cell(model.get_path(it), None, False, 0, 0)
        model[model.iter_parent(it)][1] = True
        model[it][1] = True
        self.refresh_gui()

    def click_button(self, dialog, label):
        b = self.find_widget(dialog, Gtk.Button, label)
        self.assertIsNotNone(b)
        b.clicked()
        self.refresh_gui()

    def wait_until(self, condition, timeout=5):
        """
        Wait until a condition is met or timeout is reached.

        Args:
            condition: A callable that returns a boolean
            timeout: Maximum time to wait in seconds

        Returns:
            True if condition is met, False if timeout is reached
        """
        deadline = time.time() + timeout
        while time.time() < deadline:
            self.refresh_gui(0.05)
            if condition():
                return True
        self.refresh_gui()
        return condition()

    def test_append_text(self):
        """Test append_text handles special strings"""
        gui = self.get_window()
        for test_str in common.SPECIAL_TEST_STRINGS:
            gui.append_text(test_str + "\n")
            gui.append_text(f"prefix{test_str}suffix\n")
            gui.append_text(test_str + "\n", tag='error')
            gui.append_text(f"Deleting file: /path/to/file_{test_str}.txt\n")
        self.refresh_gui()

    def test_get_window_info(self):
        """Test get_window_info"""
        gui = self.get_window()
        geo = get_window_info(gui)
        self.assertGreaterEqual(geo.x, 0)
        self.assertGreaterEqual(geo.y, 0)
        self.assertGreaterEqual(geo.width, 0)
        self.assertGreaterEqual(geo.height, 0)
        self.assertIsInstance(geo.monitor_model, str)
        self.assertGreater(len(geo.monitor_model), 0)
        self.assertNotEqual(geo.monitor_model, "(error)")

    def test_GUI(self):
        """Unit test for class GUI"""
        # there should be no crashes
        # app.do_startup()
        # pp.do_activate()                            Build a unit test that that does this
        gui = self.get_window()
        gui.update_progress_bar(0.0)
        gui.update_progress_bar(1.0)
        gui.update_progress_bar("status")

    def test_get_font_size_from_name(self):
        """Test get_font_size_from_name()"""
        tests = (("Sans 12", 12),
                 ("Sans 10.5", 10),
                 ("Sans 0", None),
                 ("Sans -1", None),
                 ("Sans", None),
                 ("", None),
                 (None, None))
        for font_name, expected in tests:
            self.assertEqual(get_font_size_from_name(
                font_name), expected, f"Font name '{font_name}' should return {expected}")

    def test_preferences(self):
        """Opens the preferences dialog and closes it"""

        # show preferences dialog
        pref = self.app.get_preferences_dialog()
        pref.dialog.show_all()
        self.refresh_gui()

        # click close button
        self.click_button(pref.dialog, Gtk.STOCK_CLOSE)

        # destroy
        pref.dialog.destroy()

    def test_preferences_cookies_page(self):
        """Opens the preferences dialog and navigates to cookies page"""
        pref = self.app.get_preferences_dialog()
        pref.dialog.show_all()
        self.refresh_gui()

        # Navigate to cookies page - this will trigger cookie manager loading
        pref.select_page('cookies')
        self.refresh_gui()

        # click close button
        self.click_button(pref.dialog, Gtk.STOCK_CLOSE)
        pref.dialog.destroy()

    def test_system_information(self):
        """Opens the system information dialog and closes it"""
        dialog, _txt, _txtbuffer = self.app.get_system_information_dialog()
        dialog.show_all()
        self.refresh_gui()

        # click close button
        self.click_button(dialog, Gtk.STOCK_CLOSE)

        # destroy
        dialog.destroy()

    def test_about(self):
        """Opens the about dialog and closes it"""
        about = self.app.get_about_dialog()
        about.show_all()
        self.refresh_gui()

        # destroy
        about.destroy()

    def test_chaff(self):
        """Minimal test of the chaff dialog"""
        from bleachbit import Chaff, GuiChaff
        # common.py patches the download directory, so have_models() will return False.
        if not Chaff.download_models():
            self.skipTest('Unable to download chaff models for GUI test')
        gui = self.get_window()
        cd = GuiChaff.ChaffDialog(gui)
        cd.show_all()
        # Trigger missing-folder branch (no destination chosen)
        cd.choose_folder_button.unselect_all()
        self.refresh_gui()
        cd.on_make_files(None)
        self.refresh_gui()
        self.assertTrue(cd.infobar.get_visible())
        chaff_dst_dir = os.path.join(self.tempdir, 'chaff_dst')
        os.mkdir(chaff_dst_dir)
        cd.choose_folder_button.set_filename(chaff_dst_dir)
        cd.when_finished_combo.set_active(1)  # do not delete after generation
        cd.stop_value_spin.set_value(10)
        self.refresh_gui()
        self.click_button(cd, _("Make files"))
        self.assertIsNotNone(
            cd.thread, 'Chaff generation thread did not start')
        cd.thread.join()
        self.refresh_gui()
        self.assertEqual(len(os.listdir(chaff_dst_dir)), 10)

    def test_cookie_manager_bulk_actions_wait_for_loading(self):
        """Bulk cookie actions should not save until loading finishes"""
        from bleachbit import GuiCookie

        class FakeThread:
            def __init__(self, target=None, daemon=None):
                self.target = target
                self.daemon = daemon

            def start(self):
                return None

        with mock.patch('bleachbit.GuiCookie.threading.Thread', FakeThread):
            pane = GuiCookie.CookieManagerPane()

        self.assertTrue(pane._is_loading)
        self.assertFalse(pane.select_all_btn.get_sensitive())
        self.assertFalse(pane.deselect_all_btn.get_sensitive())

        with mock.patch.object(pane, 'save_changes') as save_changes:
            pane.on_select_all_clicked(None)
            pane.on_deselect_all_clicked(None)
            save_changes.assert_not_called()

        pane._finish_populate(['example.com'])
        self.refresh_gui()

        self.assertFalse(pane._is_loading)
        self.assertTrue(pane.select_all_btn.get_sensitive())
        self.assertTrue(pane.deselect_all_btn.get_sensitive())

        with mock.patch.object(pane, 'save_changes') as save_changes:
            pane.on_select_all_clicked(None)
            save_changes.assert_called_once_with()
        self.assertTrue(all(row[0] for row in pane.cookie_store))
        pane.destroy()

    def test_preview(self):
        """Select cleaner option and clicks preview button"""
        gui = self.get_window()
        self.refresh_gui()
        self._put_checkmark_on_cleaner(gui, 'system', 'tmp')
        self.refresh_gui()
        self.click_button(gui, _("Preview"))
        self.refresh_gui()

    def test_notify(self):
        """Test a pop-up notification"""
        from bleachbit.GuiUtil import notify
        notify('This is a test notification')
        time.sleep(1)  # Pause for 1 second

    @mock.patch('bleachbit.GuiBasic.delete_confirmation_dialog')
    def test_confirm_delete(self, mock_delete_confirmation_dialog):
        gui = self.get_window()
        options.set('expert_mode', True)
        for new_delete_confirmation in [True, False]:
            options.set('delete_confirmation',
                        new_delete_confirmation)
            gui._confirm_delete(False, False)

        # We should have a single call to delete_confirmation_dialog
        # only when delete_confirmation option is True.
        mock_delete_confirmation_dialog.assert_called_once()

    def test_shred_paths(self):
        dirname = self.mkdtemp(prefix='bleachbit-test-shred_paths')
        test_files_dirs = [
            self.mkstemp(prefix="somefile", dir=dirname),
            dirname,
            self.mkstemp(prefix="somefile")
        ]

        for obj in test_files_dirs:
            self.assertExists(obj)

        gui = self.get_window()
        self.refresh_gui()

        with mock.patch('bleachbit.GuiWindow.GUI._confirm_delete', return_value=True):
            self.assertTrue(gui._confirm_delete(False, False))
            gui.shred_paths(test_files_dirs)

        self.refresh_gui()

        for obj in test_files_dirs:
            self.assertNotExists(obj)

    def test_shred_paths_cancel_cleans_temporary_backend(self):
        """Test that shred_paths with cancel cleans up the temporary backend"""
        test_file = self.write_file('shred-cancel')
        gui = self.get_window()
        options.set('delete_confirmation', True)
        self.refresh_gui()

        try:
            with mock.patch.object(gui, '_confirm_delete', return_value=False):
                gui.shred_paths([test_file])

            self.assertTrue(self.wait_until(lambda: '_gui' not in backends))
            self.assertExists(test_file)
        finally:
            options.set('delete_confirmation', False)
            backends.pop('_gui', None)

    def test_shred_paths_cancel_after_preview_cleans_temporary_backend(self):
        """Test cancel cleans up _gui even when the preview finishes during the dialog.

        In real usage the confirmation dialog runs a nested GTK main loop, so
        the preview worker can finish and call worker_done() before the user
        clicks Cancel.  This test simulates that by processing GUI events
        inside the mocked _confirm_delete before returning False.
        """
        test_file = self.write_file('shred-cancel-after-preview')
        gui = self.get_window()
        options.set('delete_confirmation', True)
        self.refresh_gui()

        def fake_confirm_delete(*_args, **_kwargs):
            # Simulate the dialog processing idle events, allowing the
            # preview worker to finish before the user cancels.
            self.wait_until(lambda: '_gui' not in backends
                            or gui._gui_cleaner_cleanup_pending is None)
            self.refresh_gui()
            return False

        try:
            with mock.patch.object(gui, '_confirm_delete',
                                   side_effect=fake_confirm_delete):
                gui.shred_paths([test_file])

            # _gui must be gone even though the preview finished during the
            # dialog (before _confirm_delete returned).
            self.assertNotIn('_gui', backends)
            self.assertExists(test_file)
        finally:
            options.set('delete_confirmation', False)
            backends.pop('_gui', None)

    def test_shred_paths_clears_clipboard_mock(self):
        """Test that shred_paths with should_clear_clipboard=True clears the clipboard"""
        test_file = self.write_file('shred-me-via-clipboard')
        gui = self.get_window()
        self.refresh_gui()

        with mock.patch('bleachbit.GuiWindow.clear_clipboard') as mock_clear_clipboard:
            with mock.patch.object(gui, '_confirm_delete', return_value=True):
                gui.shred_paths([test_file], should_clear_clipboard=True)

        self.refresh_gui()

        mock_clear_clipboard.assert_called_once()
        self.assertNotExists(test_file)

    def test_shred_paths_from_clipboard_menu_integration(self):
        """Shred a path copied to the real clipboard"""
        test_file = self.write_file('shred-me-via-real-clipboard')
        self.refresh_gui()

        options.set('expert_mode', True)
        options.set('delete_confirmation', False)

        clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
        clipboard.set_text(test_file, -1)
        self.refresh_gui()
        self.assertTrue(self.wait_until(
            lambda: test_file == clipboard.wait_for_text()))

        targets = [Gdk.atom_intern_static_string('text/plain')]
        self.app.cb_clipboard_uri_received(clipboard, targets, None)
        self.assertTrue(self.wait_until(lambda: not os.path.exists(test_file)))
        self.assertNotExists(test_file)

    def test_shred_clipboard_empty_no_glib_warnings(self):
        """Empty clipboard must not trigger GLib g_array warnings."""
        clear_clipboard()
        self.refresh_gui()

        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter('always')
            self.app.cb_shred_clipboard(None, None)
            self.refresh_gui()
            glib_warnings = [
                warning for warning in caught
                if 'g_array' in str(warning.message)
            ]

        self.assertEqual([], glib_warnings)

    def _setup_new_cleaner(self, gui):
        def _create_cleaner_file_in_directory(dirname):
            cleaner_content = ('<?xml version="1.0" encoding="UTF-8"?>'
                               '<cleaner id="{}">'
                               '<label>Test run_operations</label>'
                               '<option id="{}">'
                               '<label>Test1</label>'
                               '<description>Delete files in a test directory</description>'
                               '<action command="delete" search="walk.all" path="{}"/>'
                               '</option>'
                               '</cleaner>'.format(self._NEW_CLEANER_ID, self._NEW_OPTION_ID, dirname))
            cleaner_filename = os.path.join(
                dirname, 'test_run_operations_cleaner.xml')
            self.write_file(cleaner_filename, cleaner_content, 'w')
            return cleaner_filename

        def _load_new_cleaner_in_gui(gui):
            # to load the new test cleaner with id 'test_run_operations'
            gui.cb_refresh_operations()
            self.refresh_gui()

        dirname = self.mkdtemp(prefix='bleachbit-test-run_operations')
        cleaner_filename = _create_cleaner_file_in_directory(dirname)
        self.assertExists(cleaner_filename)
        with mock.patch('bleachbit.CleanerML.list_cleanerml_files') as mock_list_cleanerml_files:
            with mock.patch('bleachbit.RecognizeCleanerML.cleaner_change_dialog') as mock_cleaner_change_dialog:
                mock_list_cleanerml_files.return_value = [cleaner_filename]
                mock_cleaner_change_dialog.return_value = None
                _load_new_cleaner_in_gui(gui)
        file_to_clean = self.mkstemp(prefix="somefile", dir=dirname)
        self.assertExists(file_to_clean)
        return file_to_clean

    def test_run_operations(self):
        gui = self.get_window()
        file_to_clean = self._setup_new_cleaner(gui)
        self._put_checkmark_on_cleaner(
            gui, self._NEW_CLEANER_ID, self._NEW_OPTION_ID)

        with mock.patch('bleachbit.GuiWindow.GUI._confirm_delete', return_value=True):
            self.assertTrue(gui._confirm_delete(False, False))
            # same as b = self.click_button(gui, _("Clean"))
            gui.run_operations(None)

        self.refresh_gui()
        self.assertNotExists(file_to_clean)

    def test_cb_run_option(self):
        gui = self.get_window()
        file_to_clean = self._setup_new_cleaner(gui)

        for really_delete, assert_method in [(False, self.assertExists), (True, self.assertNotExists)]:
            with mock.patch('bleachbit.GuiWindow.GUI._confirm_delete', return_value=True):
                self.assertTrue(gui._confirm_delete(False, False))
                gui.cb_run_option(
                    None, really_delete, self._NEW_CLEANER_ID, self._NEW_OPTION_ID
                )  # activated from context menu

            self.refresh_gui()
            assert_method(file_to_clean)

    def test_context_menu_cookie_manager(self):
        """Test cookie manager helper returns correct value for options"""
        gui = self.get_window()

        class _DummyAction:
            def __init__(self, action_key):
                self.action_key = action_key

        cookie_cleaner_id = '_unit_test_cookie_cleaner'
        non_cookie_cleaner_id = '_unit_test_non_cookie_cleaner'
        saved_cookie_cleaner = backends.get(cookie_cleaner_id)
        saved_non_cookie_cleaner = backends.get(non_cookie_cleaner_id)

        try:
            cookie_cleaner = type('DummyCleaner', (), {})()
            cookie_cleaner.actions = [('cookies', _DummyAction('cookie'))]
            non_cookie_cleaner = type('DummyCleaner', (), {})()
            non_cookie_cleaner.actions = [('logs', _DummyAction('delete'))]

            backends[cookie_cleaner_id] = cookie_cleaner
            backends[non_cookie_cleaner_id] = non_cookie_cleaner

            self.assertTrue(
                gui._option_has_cookie_command(cookie_cleaner_id, 'cookies'),
                'Expected test cookie cleaner option to be detected as cookie action')
            self.assertFalse(
                gui._option_has_cookie_command(non_cookie_cleaner_id, 'logs'),
                'Expected test non-cookie cleaner option to not be detected as cookie action')
        finally:
            if saved_cookie_cleaner is None:
                backends.pop(cookie_cleaner_id, None)
            else:
                backends[cookie_cleaner_id] = saved_cookie_cleaner
            if saved_non_cookie_cleaner is None:
                backends.pop(non_cookie_cleaner_id, None)
            else:
                backends[non_cookie_cleaner_id] = saved_non_cookie_cleaner

    def test_parent_unchecked_when_all_children_blocked_by_expert_mode(self):
        """When all children are blocked by expert mode, parent must stay unchecked."""

        model = Gtk.TreeStore(
            GObject.TYPE_STRING,   # 0: name
            GObject.TYPE_BOOLEAN,  # 1: active
            GObject.TYPE_PYOBJECT,  # 2: id
            GObject.TYPE_STRING,   # 3: size
            GObject.TYPE_STRING,   # 4: icon
        )
        parent_iter = model.append(
            None, ("TestCleaner", False, "test_cleaner", "", ""))
        model.append(parent_iter, ("Option1", False, "option1", "", ""))
        model.append(parent_iter, ("Option2", False, "option2", "", ""))

        tdm = TreeDisplayModel()

        def blocked_warning(_option_id):
            return "This option requires expert mode."

        with mock.patch('bleachbit.GuiTreeModels.backends',
                        {"test_cleaner": mock.Mock(get_warning=blocked_warning)}), \
                mock.patch('bleachbit.GuiTreeModels.options') as mock_options:
            mock_options.get.return_value = False  # expert_mode off
            mock_options.get_warning_preference.return_value = False

            parent_path = model.get_path(parent_iter)
            tdm.col1_toggled_cb(None, str(parent_path), model, None)

        self.assertFalse(model[parent_iter][1],
                         "Parent should remain unchecked when all children are blocked by expert mode")
