# vim: ts=4:sw=4:expandtab

# BleachBit
# Copyright (C) 2008-2025 Andrew Ziem
# https://www.bleachbit.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.


"""
Store and retrieve user preferences
"""

# standard library imports
import atexit
import errno
import logging
import os
import re
import threading

# third-party imports
if 'nt' == os.name:
    from win32file import GetLongPathName

# local application imports
import bleachbit
from bleachbit import General
from bleachbit.Language import get_text as _

logger = logging.getLogger(__name__)

FLUSH_DELAY_SECS = 15.0  # decimal seconds

OPTION_DEFAULTS = {
    'auto_hide': {'value': True},
    'auto_detect_lang': {'value': True},
    'check_beta': {'value': False},
    'check_online_updates': {'value': True},
    'dark_mode': {'value': True},
    'debug': {'value': False},
    'delete_confirmation': {'value': True},
    'expert_mode': {'value': False},
    'exit_done': {'value': False},
    'first_start': {'value': False},
    'kde_shred_menu_option': {'value': False},
    'load_cleaners': {'value': True},
    'remember_geometry': {'value': True},
    'shred': {'value': False},
    'units_iec': {'value': False},
    'window_maximized': {'value': False},
    'window_fullscreen': {'value': False},
    'font_check_completed': {'value': False, 'platforms': ('nt',)},
    'update_winapp2': {'value': False, 'platforms': ('nt',)},
    'use_fontconfig_backend': {'value': False, 'platforms': ('nt',)},
    'win10_theme': {'value': False, 'platforms': ('nt',)},
}


def _platform_allows(meta):
    platforms = meta.get('platforms')
    return not platforms or os.name in platforms


def _get_default_value(option):
    meta = OPTION_DEFAULTS.get(option)
    if not meta or not _platform_allows(meta):
        return None
    return meta['value']


boolean_keys = [
    key for key, meta in OPTION_DEFAULTS.items()
    if _platform_allows(meta)
]
int_keys = ['window_x', 'window_y', 'window_width', 'window_height', ]


def _option_index(option_name):
    """Extract the numeric index from an option name.

    Handles both plain integer keys (e.g. '0', '1') used by get_list
    and compound keys (e.g. '1_type', '2_path') used by get_paths.
    """
    return int(option_name.split('_')[0])


def path_to_option(pathname):
    """Change a pathname to a .ini option name (a key)"""
    # On Windows change to lowercase and use backwards slashes.
    pathname = os.path.normcase(pathname)
    # On Windows expand DOS-8.3-style pathnames.
    if 'nt' == os.name and os.path.exists(pathname):
        pathname = GetLongPathName(pathname)
    if len(pathname) > 1 and ':' == pathname[1]:
        # ConfigParser treats colons in a special way
        pathname = pathname[0] + pathname[2:]
    return pathname


def init_configuration(*, log=True):
    """Initialize an empty configuration, if necessary"""
    if not os.path.exists(bleachbit.options_dir):
        General.makedirs(bleachbit.options_dir)
    if os.path.lexists(bleachbit.options_file):
        if log:
            logger.debug('Deleting configuration: %s', bleachbit.options_file)
        os.remove(bleachbit.options_file)
    with open(bleachbit.options_file, 'w', encoding='utf-8-sig', errors='surrogateescape') as f_ini:
        f_ini.write('[bleachbit]\n')
        if os.name == 'nt' and bleachbit.portable_mode:
            f_ini.write('[Portable]\n')
    for section in options.config.sections():
        options.config.remove_section(section)
    options.restore()


class Options:

    """Store and retrieve user preferences"""

    def __init__(self):
        self.purged = False
        self.config = bleachbit.RawConfigParser(delimiters='=')
        self.config.optionxform = str  # make keys case sensitive for hashpath purging
        self.config.BOOLEAN_STATES['t'] = True
        self.config.BOOLEAN_STATES['f'] = False
        self.overrides = {}
        self.old_version = None  # Store previous version in memory
        self._dirty = False
        self._closed = False
        self._flush_generation = 0
        self._flush_lock = threading.RLock()
        self._flush_timer = None
        self._atexit_close = self.close
        atexit.register(self._atexit_close)
        self.restore()

        old_option = 'system.free_disk_space'
        try:
            if self.config.has_section("tree") and self.config.has_option('tree', old_option):
                logger.debug(
                    "Migrating legacy option '%s' to 'system.empty_space'", old_option)
                self.config.set('tree', 'system.empty_space', 'true')
                self.config.remove_option('tree', old_option)
                self.__schedule_flush()
        except Exception:
            logger.exception("Error migrating legacy option '%s'", old_option)

    def __cancel_flush_timer(self):
        """Invalidate and stop any pending delayed flush timer."""
        self._flush_generation += 1
        timer = self._flush_timer
        self._flush_timer = None
        if timer is not None:
            timer.cancel()

    def __flush_after_delay(self, generation):
        """Timer callback that flushes if the timer is still current."""
        with self._flush_lock:
            if generation != self._flush_generation:
                return
            self._flush_timer = None
            self.__flush_locked()

    def __schedule_flush(self):
        """Mark configuration dirty and schedule a delayed flush."""
        with self._flush_lock:
            self._dirty = True
            self._flush_generation += 1
            generation = self._flush_generation
            if self._flush_timer is not None:
                self._flush_timer.cancel()
            self._flush_timer = threading.Timer(
                FLUSH_DELAY_SECS, self.__flush_after_delay, args=(generation,))
            self._flush_timer.daemon = True
            self._flush_timer.start()

    def __flush(self, force=False):
        """Acquire the lock and flush to disk."""
        with self._flush_lock:
            self.__flush_locked(force=force)

    def __flush_locked(self, force=False):
        """Write configuration to disk while holding the flush lock."""
        if not force and not self._dirty:
            return
        if not self.purged:
            self.__purge()

        try:
            if not os.path.exists(bleachbit.options_dir):
                General.makedirs(bleachbit.options_dir)
            mkfile = not os.path.exists(bleachbit.options_file)
            with open(bleachbit.options_file, 'w', encoding='utf-8-sig', errors='surrogateescape') as _file:
                self.config.write(_file)
            if mkfile and General.sudo_mode():
                General.chownself(bleachbit.options_file)
        except (OSError, IOError, PermissionError) as e:
            if e.errno == errno.ENOSPC:
                logger.error(
                    _("Disk was full when writing configuration to file: %s"), bleachbit.options_file)
            elif e.errno == errno.EACCES:
                logger.error(
                    _("Permission denied when writing configuration to file: %s"), bleachbit.options_file)
            else:
                raise
        else:
            self._dirty = False

    def __purge(self):
        """Clear out obsolete data"""
        self.purged = True
        if not self.config.has_section('hashpath'):
            return
        for option in self.config.options('hashpath'):
            pathname = option
            if 'nt' == os.name and re.search(r'^[a-z]\\', option):
                # restore colon lost because ConfigParser treats colon special
                # in keys
                pathname = pathname[0] + ':' + pathname[1:]
            exists = False
            try:
                exists = os.path.lexists(pathname)
            except:
                # this deals with corrupt keys
                # https://www.bleachbit.org/forum/bleachbit-wont-launch-error-startup
                logger.error(
                    _("Error checking whether path exists: %s"), pathname)
            if not exists:
                # the file does not on exist, so forget it
                self.config.remove_option('hashpath', option)

    def __migrate_warning_preferences(self):
        """Recover warning preferences corrupted by legacy colon parsing.

        This fixes an issue introduced in version 6.0.0 and fixed for 6.0.2.
        See https://github.com/bleachbit/bleachbit/issues/2110
        """
        section = 'warnings'
        if not self.config.has_section(section):
            return
        migrated = False
        for option in tuple(self.config.options(section)):
            if option not in ('cleaner', 'protected_path'):
                continue
            value = self.config.get(section, option)
            if '=' not in value:
                continue
            suffix, warning_value = value.rsplit('=', 1)
            suffix = suffix.strip()
            warning_value = warning_value.strip()
            if not suffix or \
                    warning_value.lower() not in self.config.BOOLEAN_STATES:
                continue
            migrated_option = f'{option}:{suffix}'
            if not self.config.has_option(section, migrated_option):
                self.config.set(section, migrated_option, warning_value)
            self.config.remove_option(section, option)
            migrated = True
        if migrated:
            self.__schedule_flush()

    def __auto_preserve_languages(self):
        """Automatically preserve the active language"""
        active_lang = bleachbit.Language.get_active_language_code()
        for lang_id in set([active_lang.split('_')[0], 'en']):
            logger.info(_("Automatically preserving language %s."), lang_id)
            self.set_language(lang_id, True)

    def has_option(self, option, section='bleachbit'):
        """Check if option is set"""
        return self.config.has_option(section, option)

    def has_override(self, option, section='bleachbit'):
        """Check if option is overridden"""
        return (section, option) in self.overrides

    def get(self, option, section='bleachbit'):
        """Retrieve a general option"""
        if not 'nt' == os.name and 'update_winapp2' == option:
            return False
        if section == 'bleachbit' and option == 'debug':
            from bleachbit.Log import is_debugging_enabled_via_cli
            if is_debugging_enabled_via_cli():
                # command line overrides stored configuration
                return True
        override_key = (section, option)
        if override_key in self.overrides:
            return self.overrides[override_key]
        if section == 'hashpath' and len(option) > 1 and option[1] == ':':
            option = option[0] + option[2:]
        if self.config.has_option(section, option):
            if option in boolean_keys:
                return self.config.getboolean(section, option)
            if option in int_keys:
                return self.config.getint(section, option)
            return self.config.get(section, option)

        if section == 'bleachbit':
            default = _get_default_value(option)
            if default is not None:
                return default

        return None

    def get_hashpath(self, pathname):
        """Recall the hash for a file"""
        return self.get(path_to_option(pathname), 'hashpath')

    def get_language(self, langid):
        """Retrieve value for whether to preserve the language"""
        if not self.config.has_section('preserve_languages'):
            self.__auto_preserve_languages()
        if not self.config.has_option('preserve_languages', langid):
            return False
        return self.config.getboolean('preserve_languages', langid)

    def get_languages(self):
        """Return a list of all selected languages"""
        if not self.config.has_section('preserve_languages'):
            self.__auto_preserve_languages()
        return self.config.options('preserve_languages')

    def get_list(self, option):
        """Return an option which is a list data type"""
        section = f"list/{option}"
        if not self.config.has_section(section):
            return None
        values = [
            self.config.get(section, opt)
            for opt in sorted(self.config.options(section), key=_option_index)
        ]
        return values

    def get_paths(self, section):
        """Abstracts get_whitelist_paths and get_custom_paths"""
        if not self.config.has_section(section):
            return []
        myoptions = []
        for opt in sorted(self.config.options(section), key=_option_index):
            pos = opt.find('_')
            if -1 == pos:
                continue
            myoptions.append(opt[0:pos])
        values = []
        for opt in sorted(set(myoptions), key=_option_index):
            p_type = self.config.get(section, opt + '_type')
            p_path = self.config.get(section, opt + '_path')
            values.append((p_type, p_path))
        return values

    def get_whitelist_paths(self):
        """Return the keep list (formerly whitelist) of paths

        Returns a list of tuples (type, path) where type is 'file' or 'folder'
        """
        return self.get_paths("whitelist/paths")

    def get_custom_paths(self):
        """Return list of custom paths

        Returns a list of tuples (type, path) where type is 'file' or 'folder'
        """
        return self.get_paths("custom/paths")

    def get_warning_preference(self, key):
        """Return whether a specific warning is already confirmed."""
        section = "warnings"
        if not self.config.has_section(section):
            return False
        if not self.config.has_option(section, key):
            return False
        try:
            return self.config.getboolean(section, key)
        except ValueError:
            logger.exception("Error reading warning preference for %s", key)
            return False

    def remember_warning_preference(self, key):
        """Persist that a specific warning was confirmed once."""
        section = "warnings"
        if not self.config.has_section(section):
            self.config.add_section(section)
        self.config.set(section, key, 'True')
        self.__schedule_flush()

    def clear_warning_preferences(self):
        """Clear all saved warning confirmations."""
        section = "warnings"
        if self.config.has_section(section):
            self.config.remove_section(section)
            self.__schedule_flush()

    def get_tree(self, parent, child):
        """Retrieve an option for the tree view.

        Args:
            parent: The cleaner ID (e.g., 'system', 'firefox')
            child: The option ID within the cleaner (e.g., 'cache'), or None for the cleaner itself

        Returns:
            bool: True if the option is enabled, False otherwise
        """
        option = parent
        if child is not None:
            option += "." + child

        if not self.config.has_option('tree', option):
            return False
        try:
            return self.config.getboolean('tree', option)
        except:
            # in case of corrupt configuration (Launchpad #799130)
            logger.exception('Error in get_tree()')
            return False

    def is_corrupt(self):
        """Perform a self-check for corruption of the configuration"""
        # no boolean key must raise an exception
        for boolean_key in boolean_keys:
            try:
                if self.config.has_option('bleachbit', boolean_key):
                    self.config.getboolean('bleachbit', boolean_key)
            except ValueError:
                return True
        # no int key must raise an exception
        for int_key in int_keys:
            try:
                if self.config.has_option('bleachbit', int_key):
                    self.config.getint('bleachbit', int_key)
            except ValueError:
                return True
        return False

    def reset_overrides(self):
        """Clear all overrides"""
        self.overrides.clear()

    def restore(self):
        """Restore saved options from disk

        Abandons any changes not written to disk.
        """
        with self._flush_lock:
            self.__cancel_flush_timer()
            self._dirty = False
            # Reading configuration merges with existing data,
            # so clear it first.
            for section in self.config.sections():
                self.config.remove_section(section)
        try:
            with open(bleachbit.options_file, 'r', encoding='utf-8-sig', errors='surrogateescape') as _file:
                self.config.read_file(_file, bleachbit.options_file)
        except FileNotFoundError:
            if not bleachbit.options_file.startswith('/tmp'):
                logger.debug("Configuration file does not exist yet: %s",
                             bleachbit.options_file)
        except Exception:
            logger.exception("Error reading application's configuration")
        if not self.config.has_section("bleachbit"):
            self.config.add_section("bleachbit")
        if not self.config.has_section("hashpath"):
            self.config.add_section("hashpath")
        self.__migrate_warning_preferences()
        if not self.config.has_section("list/shred_drives"):
            from bleachbit.FileUtilities import guess_overwrite_paths
            try:
                self.set_list('shred_drives', guess_overwrite_paths())
            except:
                logger.exception(
                    _("Error when setting the default drives to shred."))
        # BleachBit upgrade or first start ever
        if not self.config.has_option('bleachbit', 'version') or \
                self.get('version') != bleachbit.APP_VERSION:
            if self.config.has_option('bleachbit', 'version'):
                self.old_version = self.get('version')
            self.set('first_start', True)

        # set version
        self.set("version", bleachbit.APP_VERSION)

    def set(self, key, value, section='bleachbit'):
        """Set a general option"""
        assert isinstance(key, str), f"key must be a string: {key}"
        assert isinstance(section, str), f"section must be a string: {section}"
        override_key = (section, key)
        if override_key not in self.overrides:
            value = str(value)
            if self.config.has_option(section, key) and self.config.get(section, key) == value:
                return
            self.config.set(section, key, value)
            self.__schedule_flush()

    def commit(self):
        """Cancel times and write changes"""
        with self._flush_lock:
            self.__cancel_flush_timer()
            self.__flush(force=True)

    def cancel_pending_flush(self):
        """Cancel any pending delayed flush and clear the dirty flag.

        Unlike close(), this does not mark the options object as closed,
        so it can be called repeatedly (e.g. between test classes that
        share a single Options singleton).
        """
        with self._flush_lock:
            self.__cancel_flush_timer()
            self._dirty = False

    def close(self):
        """Cancel times and write changes"""
        with self._flush_lock:
            if self._closed:
                return
            self._closed = True
            atexit.unregister(self._atexit_close)
            self.__cancel_flush_timer()
            self.__flush(force=False)

    def set_hashpath(self, pathname, hashvalue):
        """Remember the hash of a path"""
        self.set(path_to_option(pathname), hashvalue, 'hashpath')

    def set_list(self, key, values):
        """Set a value which is a list data type"""
        assert isinstance(key, str), f"key must be a string: {key}"
        section = f"list/{key}"
        # Remove existing section first to clear old values
        # before writing new ones.
        if self.config.has_section(section):
            self.config.remove_section(section)
        self.config.add_section(section)
        for counter, value in enumerate(values):
            self.config.set(section, str(counter), value)
        self.__schedule_flush()

    def set_whitelist_paths(self, values):
        """Save the keep list (formerly whitelist)"""
        section = "whitelist/paths"
        # Remove existing section first to clear old values
        # before writing new ones.
        if self.config.has_section(section):
            self.config.remove_section(section)
        self.config.add_section(section)
        for counter, value in enumerate(values):
            self.config.set(section, str(counter) + '_type', value[0])
            self.config.set(section, str(counter) + '_path', value[1])
        self.__schedule_flush()

    def set_custom_paths(self, values):
        """Save the custom paths

        @param values: list of tuples containing (path_type, path)
            where path_type is either 'file' or 'folder'
        """
        section = "custom/paths"
        # Remove existing section first to clear old values
        # before writing new ones.
        if self.config.has_section(section):
            self.config.remove_section(section)
        self.config.add_section(section)
        for counter, value in enumerate(values):
            path_type, path = value
            assert path_type in ('file', 'folder')
            self.config.set(section, str(counter) + '_type', path_type)
            self.config.set(section, str(counter) + '_path', path)
        self.__schedule_flush()

    def set_language(self, langid, value):
        """Set the value for a locale (whether to preserve it)"""
        if not self.config.has_section('preserve_languages'):
            self.config.add_section('preserve_languages')
        if self.config.has_option('preserve_languages', langid) and not value:
            self.config.remove_option('preserve_languages', langid)
        else:
            self.config.set('preserve_languages', langid, str(value))
        self.__schedule_flush()

    def set_tree(self, parent, child, value):
        """Set an option for the tree view.  The child may be None."""
        if not self.config.has_section("tree"):
            self.config.add_section("tree")
        option = parent
        if child is not None:
            option = option + "." + child
        if self.config.has_option('tree', option) and not value:
            self.config.remove_option('tree', option)
        else:
            self.config.set('tree', option, str(value))
        self.__schedule_flush()

    def toggle(self, key):
        """Toggle a boolean key"""
        self.set(key, not self.get(key))

    def set_override(self, key, value, section='bleachbit'):
        """Set a CLI override that will never be written to disk"""
        override_key = (section, key)
        self.overrides[override_key] = value


options = Options()
