# 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 cases for module Winapp
"""

import os
import shutil
import stat
import string
import struct
import tempfile
import time
from unittest import mock

from tests import common
from bleachbit.Winapp import Winapp, detectos, detect_file, section2option
from bleachbit.Windows import detect_registry_key, parse_windows_build
from bleachbit import IS_POSIX, IS_WINDOWS, logger

if IS_WINDOWS:
    import winreg

def _create_registry_keys(*key_paths):
    """Create registry keys, ignoring errors if they already exist"""
    if not IS_WINDOWS:
        return
    for key_path in key_paths:
        try:
            hkey = winreg.CreateKey(winreg.HKEY_CURRENT_USER, key_path)
            hkey.Close()
        except OSError:
            pass

def _delete_registry_keys(*key_paths):
    """Delete registry keys, ignoring errors if they don't exist"""
    if not IS_WINDOWS:
        return
    for key_path in key_paths:
        try:
            winreg.DeleteKey(winreg.HKEY_CURRENT_USER, key_path)
        except OSError:
            pass

KEYFULL = 'HKCU\\Software\\BleachBit\\DeleteThisKey'


def get_winapp2():
    """Download and cache winapp2.ini.  Return local filename."""
    url = ("https://raw.githubusercontent.com/bleachbit/winapp2.ini"
            "/refs/heads/master/Winapp2-BleachBit.ini")
    tmpdir = None
    if IS_POSIX:
        tmpdir = '/tmp'
    if IS_WINDOWS:
        tmpdir = os.getenv('TMP')
    if not tmpdir:
        tmpdir = tempfile.gettempdir()
    fname = os.path.join(tmpdir, 'bleachbit_test_winapp2.ini')
    if os.path.exists(fname):
        age_seconds = time.time() - os.stat(fname)[stat.ST_MTIME]
        if age_seconds > (24 * 36 * 36):
            logger.info('deleting stale file %s ', fname)
            os.remove(fname)
    if not os.path.exists(fname):
        from bleachbit.Network import download_url_to_fn
        assert download_url_to_fn(url, fname)
    assert os.path.exists(fname)
    return fname


class WinappTestCase(common.BleachbitTestCase):
    """Test cases for Winapp"""

    ini_fn = None

    def run_all(self, cleaner, really_delete):
        """Test all the cleaner options"""
        for (option_id, __name) in cleaner.get_options():
            for cmd in cleaner.get_commands(option_id):
                for result in cmd.execute(really_delete):
                    common.validate_result(self, result, really_delete)

    @common.skipUnlessWindows
    def test_remote(self):
        """Test with downloaded file"""
        winapps = Winapp(get_winapp2())
        for cleaner in winapps.get_cleaners():
            self.run_all(cleaner, False)

    def test_detectos(self):
        """Test detectos function"""
        # Tests are in the format (required_ver, mock, expected_return)
        tests = (('5.1', '5.1', True),
                 ('5.1', '6.0', False),
                 ('6.0', '5.1', False),
                 # 5.1 is the maximum version
                 ('|5.1', '5.1', True),
                 ('|5.1', '6.0', False),
                 ('|5.1', '10.0', False),
                 # 10.0 is the maximum version
                 ('|10.0', '5.1', True),
                 ('|10.0', '10.0', True),
                 ('|10.0', '10.1', False),
                 # 6.1 is the minimum version
                 ('6.1|', '5.1', False),
                 ('6.1|', '6.0', False),
                 ('6.1|', '6.1', True),
                 ('6.1|', '6.2', True),
                 ('6.1|', '10.0', True),
                 ('6.2|', '5.1', False),
                 ('6.2|', '6.0', False),
                 ('6.2|', '6.1', False),
                 ('6.2|', '6.2', True),
                 # must be 6.2 or 6.3
                 ('6.2|6.3', '6.0', False),
                 ('6.2|6.3', '6.1', False),
                 ('6.2|6.3', '6.2', True),
                 ('6.2|6.3', '6.3', True),
                 ('6.2|6.3', '10.0', False),
                 # 10.0 is the minimum
                 ('10.0|', '5.1', False),
                 ('10.0|', '10.0', True))
        for (req, mock_ver, expected_return) in tests:
            mock_ver = parse_windows_build(mock_ver)
            actual_return = detectos(req, mock_ver)
            msg = (f'detectos({req}, {mock_ver})=={actual_return}'
                   f' instead of {expected_return}')
            self.assertEqual(expected_return, actual_return, msg)

    @common.skipUnlessWindows
    def test_detect_file(self):
        """Test detect_file function"""
        tests = [('%windir%\\system32\\kernel32.dll', True),
                 ('%windir%\\system32', True),
                 ('%ProgramFiles%\\Internet Explorer', True),
                 ('%ProgramFiles%\\Internet Explorer\\', True),
                 ('%windir%\\doesnotexist', False),
                 ('%windir%\\system*', True),
                 ('%windir%\\*ystem32', True),
                 ('%windir%\\*ystem3*', True)]
        # On 64-bit Windows, Winapp2.ini expands the %ProgramFiles% environment
        # variable to also %ProgramW6432%, so test unique entries in
        # %ProgramW6432%.
        if os.getenv('ProgramW6432'):
            dir_64 = os.listdir(os.getenv('ProgramFiles'))
            dir_32 = os.listdir(os.getenv('ProgramW6432'))
            dir_32_unique = set(dir_32) - set(dir_64)
            if dir_32 and not dir_32_unique and 8 * struct.calcsize('P') == 32:
                raise RuntimeError(
                    'Test expects objects in %ProgramW6432% not in %ProgramFiles%')
            for pathname in dir_32_unique:
                tests.append((f'%ProgramFiles%\\{pathname}', True))
        else:
            logger.info(
                'skipping %ProgramW6432% tests because WoW64 not detected')
        for pathname, expected_return in tests:
            actual_return = detect_file(pathname)
            msg = f'detect_file({pathname}) returned {actual_return}'
            self.assertEqual(expected_return, actual_return, msg)

    def setup_fake(self, f1_filename=None):
        """Setup the test environment"""
        subkey = 'Software\\BleachBit\\DeleteThisKey\\AndThisKey'

        # put ampersand in directory name to test
        # https://github.com/bleachbit/bleachbit/issues/308
        dirname = self.mkdtemp(prefix='bleachbit-test-winapp&')
        fname1 = self.write_file(os.path.join(dirname, f1_filename or 'deleteme.log'), b'', 'wb')

        dirname2 = self.mkdir(os.path.join(dirname, 'sub'))
        fname2 = self.write_file(os.path.join(dirname2, 'deleteme.log'), b'', 'wb')

        fbak = self.write_file(os.path.join(dirname, 'deleteme.bak'), b'', 'wb')

        self.assertExists(fname1)
        self.assertExists(fname2)
        self.assertExists(fbak)

        _create_registry_keys(subkey)

        self.assertTrue(detect_registry_key(KEYFULL))
        self.assertTrue(detect_registry_key(f'HKCU\\{subkey}'))

        return dirname, fname1, fname2, fbak

    def ini2cleaner(self, body, do_next=True):
        """Write a minimal Winapp2.ini"""
        with open(self.ini_fn, 'w', encoding='utf-8') as ini:
            ini.write('[someapp]\nLangSecRef=3021\n' + body + '\n')
        self.assertExists(self.ini_fn)
        if do_next:
            return next(Winapp(self.ini_fn).get_cleaners())
        return Winapp(self.ini_fn).get_cleaners()

    @common.skipUnlessWindows
    def test_fake(self):
        """Test with fake file"""

        # reuse this path to store a winapp2.ini file in
        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2')

        # a set of tests
        # this map explains what each position in the test tuple means
        # 0=line to write directly to winapp2.ini
        # 1=filename1 to place in fake environment (default=deleteme.log)
        # 2=auto-hide before cleaning
        # 3=dirname exists after cleaning
        # 4=filename1 (.\deleteme.log) exists after cleaning
        # 5=sub\deleteme.log exists after cleaning
        # 6=.\deleteme.bak exists after cleaning
        tests = [
            # single file
            ('FileKey1=%s|deleteme.log', None,
                False, True, False, True, True),
            # single file, case matching should be insensitive
            ('FileKey1=%s|dEleteme.LOG', None,
                False, True, False, True, True),
            # special characters for XML
            ('FileKey1=%s|special_chars_&-\'.txt', 'special_chars_&-\'.txt',
             False, True, False, True, True),
            # *.log
            ('FileKey1=%s|*.LOG', None, False, True, False, True, True),
            # semicolon separates different file types
            ('FileKey1=%s|*.log;*.bak', None,
             False, True, False, True, False),
            # *.*
            ('FileKey1=%s|*.*', None, False, True, False, True, False),
            # recurse *.*
            ('FileKey1=%s|*.*|RECURSE', None, False,
             True, False, False, False),
            # recurse *.log
            ('FileKey1=%s|*.log|RECURSE', None, False,
             True, False, False, True),
            # remove self *.*, this removes the directory
            ('FileKey1=%s|*.*|REMOVESELF', None,
             False, False, False, False, False),
        ]

        # Add positive detection, where the detection believes the application is present,
        # to all the tests, which are also positive.
        new_tests = []
        for test in tests:
            for detect in (
                "\nDetectFile=%%APPDATA%%\\Microsoft",
                "\nSpecialDetect=DET_WINDOWS",
                "\nDetectFile1=%%APPDATA%%\\Microsoft\nDetectFile2=%%APPDATA%%\\does_not_exist",
                "\nDetectFile1=%%APPDATA%%\\does_not_exist\nDetectFile2=%%APPDATA%%\\Microsoft",
                "\nDetect=HKCU\\Software\\Microsoft",
                # Below checks that a space is OK in the registry key
                "\nDetect=HKCU\\Software\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon",
                # Below checks Detect# where one of two keys exist.
                "\nDetect1=HKCU\\Software\\Microsoft\nDetect2=HKCU\\Software\\does_not_exist",
                "\nDetect1=HKCU\\Software\\does_not_exist\nDetect2=HKCU\\Software\\Microsoft",
                # Below checks Detect with DetectFile where one exists
                "\nDetect=HKCU\\Software\\Microsoft\nDetectFile=%%APPDATA%%\\does_not_exist",
                    "\nDetect=HKCU\\Software\\does_not_exist\nDetectFile=%%APPDATA%%\\Microsoft"):
                new_ini = test[0] + detect
                new_test = [new_ini, ] + list(test[1:])
                new_tests.append(new_test)
        positive_tests = tests + new_tests

        # execute positive tests
        for test in positive_tests:
            print('positive test: ', test)
            self.assertEqual(len(test), 7)
            (dirname, fname1, fname2, fbak) = self.setup_fake(test[1])
            cleaner = self.ini2cleaner(test[0] % dirname)
            self.assertEqual(test[2], cleaner.auto_hide())
            self.run_all(cleaner, False)
            self.run_all(cleaner, True)
            self.assertCondExists(test[3], dirname)
            self.assertCondExists(test[4], fname1)
            self.assertCondExists(test[5], fname2)
            self.assertCondExists(test[6], fbak)
            shutil.rmtree(dirname, True)

        # negative tests where the application detect believes the application
        # is absent
        for test in tests:
            for detect in (
                "\nDetectFile=c:\\does_not_exist",
                "\nSpecialDetect=DET_SPACE_QUEST",
                # special characters for XML
                "\nDetectFile=c:\\does_not_exist_special_chars_&'",
                "\nDetectFile1=c:\\does_not_exist1\nDetectFile2=c:\\does_not_exist2",
                "\nDetect=HKCU\\Software\\does_not_exist",
                "\nDetect=HKCU\\Software\\does_not_exist_&'",
                    "\nDetect1=HKCU\\Software\\does_not_exist1"
                    "\nDetect2=HKCU\\Software\\does_not_exist1"):
                new_ini = test[0] + detect
                test_full = [new_ini, ] + list(test[1:])
                print('negative test', test_full)
                # execute the test
                (dirname, fname1, fname2, fbak) = self.setup_fake()
                cleaner = self.ini2cleaner(test_full[0] % dirname, False)
                self.assertRaises(StopIteration, cleaner.__next__)
                shutil.rmtree(dirname, True)

        # registry key, basic
        (dirname, fname1, fname2, fbak) = self.setup_fake()
        cleaner = self.ini2cleaner(f'RegKey1={KEYFULL}')
        self.run_all(cleaner, False)
        self.assertTrue(detect_registry_key(KEYFULL))
        self.run_all(cleaner, True)
        self.assertFalse(detect_registry_key(KEYFULL))
        shutil.rmtree(dirname, True)

        # check for parse error with ampersand
        (dirname, fname1, fname2, fbak) = self.setup_fake()
        cleaner = self.ini2cleaner(
            'RegKey1=HKCU\\Software\\PeanutButter&Jelly')
        self.run_all(cleaner, False)
        self.run_all(cleaner, True)
        shutil.rmtree(dirname, True)

    @common.skipUnlessWindows
    def test_excludekey(self):
        """Test for ExcludeKey"""

        # reuse this path to store a winapp2.ini file in
        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2')

        # Each tuple contains:
        #   [0] = Body of winapp2.ini configuration
        #   [1] = Whether .\deleteme.log should exist after cleaning (True=exists)
        #   [2] = Whether .\deleteme.bak should exist after cleaning (True=exists)
        #   [3] = Whether sub\deleteme.log should exist after cleaning (True=exists)
        #
        # Path notation:
        #   %(d)s     - Literal test directory path without a trailing backslash
        #   %(d)s\\   - Literal directory with a trailing backslash
        #   %%bbtestdir%% - Literal environment variable that resolves to same test directory

        tests = (
            # delete everything in single directory (no children) without
            # exclusions
            ('FileKey1=%(d)s|deleteme.*', False, False, True),
            # delete everything in single directory using environment variable
            ('FileKey1=%%bbtestdir%%|deleteme.*', False, False, True),
            # delete everything in parent and child directories without
            # exclusions
            ('FileKey1=%(d)s|deleteme.*|RECURSE', False, False, False),
            # don't delete files containing the described file name
            ('FileKey1=%(d)s|eteme.*|RECURSE', True, True, True),
            # delete a pattern sub folder included
            ('FileKey1=%(d)s|*eteme.*|RECURSE', False, False, False),
            # delete a pattern sub folder not included
            ('FileKey1=%(d)s|*eteme.*', False, False, True),
            # exclude log delimited by pipe
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=FILE|%(d)s|deleteme.log',
             True, False, True),
            # trailing backslash should work the same as without
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=FILE|%(d)s\\|deleteme.log',
             True, False, True),
            # delete / exclude patterns with different complexity
            ('FileKey1=%(d)s|*eteme.*\nExcludeKey1=FILE|%(d)s|deleteme.log',
             True, False, True),
            ('FileKey1=%(d)s|*eteme.*\nExcludeKey1=FILE|%(d)s\\|deleteme.log',
             True, False, True),
            ('FileKey1=%(d)s|*ete*.*\nExcludeKey1=PATH|%(d)s|*ete*.lo?',
             True, False, True),
            ('FileKey1=%(d)s|*eteme*\nExcludeKey1=PATH|%(d)s|*notexist.*',
             False, False, True),
            ('FileKey1=%(d)s|*eteme.log\nExcludeKey1=PATH|%(d)s|do*.log',
             False, True, True),
            # semicolon separates different file types
            ('FileKey1=%(d)s|*.log;*.bak|RECURSE\nExcludeKey1=PATH|%(d)s|*.log',
             True, False, True),
            # exclude log without pipe delimiter
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=FILE|%(d)s\\deleteme.log',
             True, False, True),
            # exclude everything in folder
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=PATH|%(d)s|*.*',
             True, True, True),
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=PATH|%(d)s\\|*.*',
             True, True, True),
            ('FileKey1=%(d)s|deleteme.*|RECURSE\nExcludeKey1=PATH|%(d)s|*.*',
             True, True, True),
            ('FileKey1=%(d)s|*.*|RECURSE\nExcludeKey1=PATH|%(d)s|*.*',
             True, True, True),
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=PATH|%(d)s',
             True, True, True),
            # exclude everything in folder using environment variable
            # use double %% to escape
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=PATH|%%bbtestdir%%|*.*',
             True, True, True),
            # exclude sub-folder
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=PATH|%(d)s\\sub',
             False, False, True),
            # exclude multiple file types that do not exist
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=PATH|%(d)s|*.exe;*.dll',
             False, False, True),
            # exclude multiple file types that do exist, so dlete nothing
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=PATH|%(d)s|*.bak;*.log',
             True, True, True),
            ('FileKey1=%(d)s|deleteme.*|RECURSE\nExcludeKey1=PATH|%(d)s|*.bak;*.log',
             True, True, True),
            # multiple ExcludeKey, neither of which should do anything
            ('FileKey1=%(d)s|deleteme.*|RECURSE'
             '\nExcludeKey1=PATH|c:\\doesnotexist|*.*'
             '\nExcludeKey2=PATH|c:\\alsodoesnotexist\\|*.*',
             False, False, False),
            # multiple ExcludeKey, the first should work
            ('FileKey1=%(d)s|deleteme.*|RECURSE'
             '\nExcludeKey1=PATH|%(d)s|*.log'
             '\nExcludeKey2=PATH|c:\\alsodoesnotexist\\|*.*',
             True, False, True),
            # multiple ExcludeKey, both should work
            ('FileKey1=%(d)s|deleteme.*|RECURSE'
             '\nExcludeKey1=PATH|%(d)s|*.log'
             '\nExcludeKey2=PATH|%(d)s|*.bak',
             True, True, True),
            # glob should exclude the directory called 'sub'
            ('FileKey1=%(d)s|*.*\nExcludeKey1=PATH|%(d)s\\s*',
             False, False, True),
        )

        for test in tests:
            msg = f'\nTest:\n{test[0]}'
            # setup
            (dirname, _fname1, _fname2, _fbak) = self.setup_fake()
            self.assertExists(rf'{dirname}\deleteme.log', msg)
            self.assertExists(rf'{dirname}\deleteme.bak', msg)
            self.assertExists(rf'{dirname}\sub\deleteme.log', msg)
            # set environment variable for testing
            with common.set_temporary_env('bbtestdir', dirname):
                self.assertExists(r'$bbtestdir\deleteme.log', msg)
                # delete files
                cleaner = self.ini2cleaner(test[0] % {'d': dirname})
                self.run_all(cleaner, True)
                # test
                self.assertCondExists(test[1], rf'{dirname}\deleteme.log', msg)
                self.assertCondExists(test[2], rf'{dirname}\deleteme.bak', msg)
                self.assertCondExists(
                    test[3], rf'{dirname}\sub\deleteme.log', msg)

            # cleanup
            shutil.rmtree(dirname, True)

    def _verify_keys_state(self, expected_state):
        """Verify registry keys match expected state (dict of key_path -> exists)"""
        for key_path, should_exist in expected_state.items():
            exists = detect_registry_key('HKCU\\' + key_path)
            if should_exist:
                self.assertTrue(exists, f'Key {key_path} should exist')
            else:
                self.assertFalse(exists, f'Key {key_path} should not exist')

    @common.skipUnlessWindows
    def test_excludekey_reg(self):
        """Test for ExcludeKey with REG type to prevent registry key deletion"""

        # reuse this path to store a winapp2.ini file in
        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2')

        test_key_parent = r'Software\BleachBitTest\ExcludeKey'
        test_key_child0 = rf'{test_key_parent}\Child0'
        test_key_child1 = rf'{test_key_parent}\Child1'
        test_key_not_exc = f'{test_key_parent}_NotExcluded'

        tests = (
            # (exclude_key, parent_should_remain, child0_should_remain, child1_should_remain)
            # Excluding a child key also implicitly protects the parent,
            # since the parent must exist for the excluded child to persist.
            (test_key_parent, True, True, True),
            (test_key_child0, True, True, False),
            (test_key_child1, True, False, True),
        )

        for exclude_key, parent_should_remain, child0_should_remain, child1_should_remain in tests:
            with self.subTest(exclude_key=exclude_key):
                try:
                    _create_registry_keys(
                        test_key_parent, test_key_child0,
                        test_key_child1, test_key_not_exc)
                    cleaner_ini = f'''RegKey1=HKCU\\{test_key_parent}
RegKey2=HKCU\\{test_key_child0}
RegKey3=HKCU\\{test_key_child1}
RegKey4=HKCU\\{test_key_not_exc}
ExcludeKey1=REG|HKCU\\{exclude_key}'''
                    cleaner = self.ini2cleaner(cleaner_ini)
                    self.run_all(cleaner, False)
                    self._verify_keys_state({
                        test_key_parent: True,
                        test_key_child0: True,
                        test_key_child1: True,
                        test_key_not_exc: True,
                    })
                    self.run_all(cleaner, True)
                    self._verify_keys_state({
                        test_key_parent: parent_should_remain,
                        test_key_child0: child0_should_remain,
                        test_key_child1: child1_should_remain,
                        test_key_not_exc: False,
                    })
                finally:
                    _delete_registry_keys(
                        test_key_parent, test_key_child0,
                        test_key_child1, test_key_not_exc)

    @common.skipUnlessWindows
    def test_filekey_with_path_including_systemdrive(self):
        """Test FileKey with path including SystemDrive"""
        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2')
        # lexists(r'C:filename') returns False
        # FindFilesW(r'C:filename') throws an exception
        # So assert that we use r'C:\filename'.
        # We need this test because winapp2.ini supports such paths and
        # giving r'C:filename' as argument to lexists or FindFilesW
        # causes a hard to detect issue, which has been detected because
        # lexists('C:filename') returns True when called through PyCharm.
        # Also test_remote is supposed to run cleaners that contain
        # such paths but it doesn't assert if those cleaners actually
        # clean their targets.

        filename = os.path.join('C:\\', 'deleteme.txt')
        try:
            common.touch_file(filename)
        except PermissionError:
            self.skipTest('Permission denied: run as administrator')

        with mock.patch('os.path.lexists') as mock_lexists:
            cleaner = self.ini2cleaner('FileKey1=%SystemDrive%|deleteme.txt')
            self.run_all(cleaner, True)
            mock_lexists.assert_any_call(r'C:\deleteme.txt')

        self.assertNotExists(filename)

    @common.skipUnlessWindows
    def test_removeself(self):
        """Test for the removeself option"""

        # indexes for test
        # position 0: FileKey statement
        # position 1: whether the file `dir_c\subdir\foo.log` should exist
        #              after operation is complete
        # position 2: path of top-folder which should have been deleted
        d = self.tempdir
        tests = (
            # Refer to directory directly (i.e., without a glob).
            (rf'FileKey1={d}\dir_c|*.*|REMOVESELF',
             False, rf'{d}\dir_c'),
            # Refer to file that exists. This is invalid, so nothing happens.
            (rf'FileKey1={d}\dir_c\submarine_sandwich.log|*.*|REMOVESELF',
             True, ''),
            (rf'FileKey1={d}\dir_c\submarine*|*.*|REMOVESELF',
             True, ''),
            # Refer to path that does not exist, so nothing happens.
            (rf'FileKey1={d}\dir_c\doesnotexist.log|*.*|REMOVESELF',
             True, ''),
            # Refer by glob to both a file and directory (which both start with `sub`).
            # This should affect only the directory.
            (rf'FileKey1={d}\dir_a\sub*|*.*|REMOVESELF',
             True, rf'{d}\dir_a\subdir'),
            # glob in middle of directory path with whole directory entry
            (rf'FileKey1={d}\*c\subdir|*.*|REMOVESELF',
             False, rf'{d}\dir_c\subdir'),
            (rf'FileKey1={d}\*doesnotexist\subdir|*.*|REMOVESELF',
             True, ''),
            # glob at end of path
            (rf'FileKey1={d}\dir_c\sub*|*.*|REMOVESELF',
             False, rf'{d}\dir_c\subdir'),
            (rf'FileKey1={d}\dir_c\doesnotexist*|*.*|REMOVESELF',
             True, '')
        )

        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2')

        for filekey, c_log_expected, top_log_expected in tests:
            for letter in ('a', 'b', 'c'):
                # Make three directories, each with a `foo.log` file.
                fn = os.path.join(self.tempdir, 'dir_' +
                                  letter, 'subdir', 'foo.log')
                common.touch_file(fn)

            # In dir_a, place a file one level up from `foo.log`.
            # Notice the glob `dir_a\sub*` will match both a directory and file.
            fn2 = os.path.join(self.tempdir, 'dir_a', 'submarine_sandwich.log')
            common.touch_file(fn2)

            cleaner = self.ini2cleaner(filekey)
            self.assertExists(fn, filekey)
            self.assertExists(fn2, filekey)
            if top_log_expected:
                self.assertExists(top_log_expected, filekey)
            self.run_all(cleaner, True)
            if c_log_expected:
                self.assertExists(fn, filekey)
            else:
                self.assertNotExists(fn, filekey)
            if top_log_expected != '':
                self.assertNotExists(top_log_expected, filekey)
            self.assertExists(fn2, filekey)
            if top_log_expected:
                self.assertNotExists(top_log_expected, filekey)

    def test_section2option(self):
        """Test for section2option()"""
        tests = (('  FOO2  ', 'foo2'),
                 ('A - B (C)', 'a_b_c'))
        for test in tests:
            self.assertEqual(section2option(test[0]), test[1])

    def test_section_not_found(self):
        """Test a section that is found"""
        tmp_ini_fn = self.write_file(
            'section_not_found.ini',
            r"""[Initech TPS Reports *]
LangSecRef=3023
DetectFile=%CommonAppData%\Initech\TPSReports
FileKey1=%CommonAppData%\Initech\TPSReports\Logs|*.*""",
            mode='w')

        with mock.patch.object(Winapp, 'detect', return_value=True) as mock_detect:
            winapps = Winapp(tmp_ini_fn)
            cleaner_list = list(winapps.get_cleaners())
            self.assertEqual(len(cleaner_list), 1)
            self.assertEqual(cleaner_list[0].get_name(), 'Multimedia')
            this_option = list(cleaner_list[0].get_options())[0]
            self.assertEqual(this_option[0], 'initech_tps_reports')
            self.assertEqual(this_option[1], 'Initech TPS Reports')
            mock_detect.assert_called()

    def test_many_patterns(self):
        """Test a cleaner like Steam Installers and related performance improvement

        https://github.com/bleachbit/bleachbit/issues/325
        """

        # set up environment
        file_count = 1000
        dir_count = 10
        print(f'Making {file_count} files in each of {dir_count} directories.')
        for i_d in range(1, dir_count + 1):
            sub_dir = self.mkdir(os.path.join(self.tempdir, f'dir{i_d}'))
            for i_f in range(1, file_count + 1):
                self.write_file(os.path.join(sub_dir, f'file{i_f}'))

        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2')

        searches = ';'.join(
            [f'*.{letter}' for letter in string.ascii_letters[0:26]])
        cleaner = self.ini2cleaner(
            f'FileKey1={self.tempdir}|{searches}|RECURSE')

        # preview
        t0 = time.time()
        self.run_all(cleaner, False)
        t1 = time.time()
        print(f'Elapsed time in preview: {t1 - t0:.4f} seconds ')

        # delete
        self.run_all(cleaner, True)