# -*- coding: utf-8 -*-
#
# Copyright © Spyder Project Contributors
# Licensed under the terms of the MIT License
# (see spyder/__init__.py for details)

"""
Spyder base configuration management

This file only deals with non-GUI configuration features
(in other words, we won't import any PyQt object here, avoiding any
sip API incompatibility issue in spyder's non-gui modules)
"""

from __future__ import annotations

from collections.abc import Callable
from glob import glob
import locale
import os
import os.path as osp
import re
import shutil
import sys
import tempfile
import uuid
import warnings

# Local imports
from spyder import __version__
from spyder.utils import encoding

#==============================================================================
# Only for development
#==============================================================================
# To activate/deactivate certain things for development
# SPYDER_DEV is (and *only* has to be) set in bootstrap.py
DEV = os.environ.get('SPYDER_DEV')

# Manually override whether the dev configuration directory is used.
USE_DEV_CONFIG_DIR = os.environ.get('SPYDER_USE_DEV_CONFIG_DIR')

# Get a random id for the safe-mode config dir
CLEAN_DIR_ID = str(uuid.uuid4()).split('-')[-1]


def get_safe_mode():
    """
    Make Spyder use a temp clean configuration directory for testing
    purposes SPYDER_SAFE_MODE can be set using the --safe-mode option.
    """
    return bool(os.environ.get('SPYDER_SAFE_MODE'))


def running_under_pytest():
    """
    Return True if currently running under pytest.

    This function is used to do some adjustment for testing. The environment
    variable SPYDER_PYTEST is defined in conftest.py.
    """
    return bool(os.environ.get('SPYDER_PYTEST'))


def running_remoteclient_tests():
    """
    Return True if currently running the remoteclient tests.

    This function is used to do some adjustment for testing. The environment
    variable SPYDER_TEST_REMOTE_CLIENT is 'true' in conftest.py.
    """
    return bool(os.environ.get("SPYDER_TEST_REMOTE_CLIENT") == "true")


def running_in_ci():
    """Return True if currently running under CI."""
    return bool(os.environ.get('CI'))


def running_in_ci_with_conda():
    """Return True if currently running under CI with conda packages."""
    return running_in_ci() and os.environ.get('USE_CONDA', None) == 'true'


def running_in_binder():
    """Return True if currently running in Binder."""
    return (
        bool(os.environ.get("BINDER_REPO_URL"))
        and "spyder-ide/binder-environments" in os.environ["BINDER_REPO_URL"]
    )


def building_autodoc():
    """Return True if executing under Sphinx Autodoc, False otherwise."""
    return bool(os.environ.get('SPHINX_AUTODOC'))


def is_stable_version(version):
    """
    Return true if version is stable, i.e. with letters in the final component.

    Stable version examples: ``1.2``, ``1.3.4``, ``1.0.5``.
    Non-stable version examples: ``1.3.4beta``, ``0.1.0rc1``, ``3.0.0dev0``.
    """
    if not isinstance(version, tuple):
        version = version.split('.')
    last_part = version[-1]

    if not re.search(r'[a-zA-Z]', last_part):
        return True
    else:
        return False


def use_dev_config_dir(use_dev_config_dir=USE_DEV_CONFIG_DIR):
    """Return whether the dev configuration directory should used."""
    if use_dev_config_dir is not None:
        if use_dev_config_dir.lower() in {'false', '0'}:
            use_dev_config_dir = False
    else:
        use_dev_config_dir = DEV or not is_stable_version(__version__)

    return use_dev_config_dir


#==============================================================================
# Debug helpers
#==============================================================================
# This is needed after restarting and using debug_print
STDOUT = sys.stdout
STDERR = sys.stderr


def get_debug_level():
    debug_env = os.environ.get('SPYDER_DEBUG', '')
    if not debug_env.isdigit():
        debug_env = bool(debug_env)
    return int(debug_env)


def debug_print(*message):
    """Output debug messages to stdout"""
    warnings.warn("debug_print is deprecated; use the logging module instead.")
    if get_debug_level():
        ss = STDOUT
        # This is needed after restarting and using debug_print
        for m in message:
            ss.buffer.write(str(m).encode('utf-8'))
        print('', file=ss)


#==============================================================================
# Configuration paths
#==============================================================================
def get_conf_subfolder():
    """Return the configuration subfolder for different ooperating systems."""
    # Spyder settings dir
    # NOTE: During the 2.x.x series this dir was named .spyder2, but
    # since 3.0+ we've reverted back to use .spyder to simplify major
    # updates in version (required when we change APIs by Linux
    # packagers)
    if sys.platform.startswith('linux'):
        SUBFOLDER = 'spyder'
    else:
        SUBFOLDER = '.spyder'

    # We can't have PY2 and PY3 settings in the same dir because:
    # 1. This leads to ugly crashes and freezes (e.g. by trying to
    #    embed a PY2 interpreter in PY3)
    # 2. We need to save the list of installed modules (for code
    #    completion) separately for each version
    SUBFOLDER = SUBFOLDER + '-py3'

    # If running a development/beta version, save config in a separate
    # directory to avoid wiping or contaiminating the user's saved stable
    # configuration.
    if use_dev_config_dir():
        SUBFOLDER = SUBFOLDER + '-dev'

    return SUBFOLDER


def get_project_config_folder():
    """Return the default project configuration folder."""
    return '.spyproject'


def get_home_dir():
    """Return user home directory."""
    try:
        # expanduser() returns a raw byte string which needs to be
        # decoded with the codec that the OS is using to represent
        # file paths.
        path = encoding.to_unicode_from_fs(osp.expanduser('~'))
    except Exception:
        path = ''

    if osp.isdir(path):
        return path
    else:
        # Get home from alternative locations
        for env_var in ('HOME', 'USERPROFILE', 'TMP'):
            # os.environ.get() returns a raw byte string which needs to be
            # decoded with the codec that the OS is using to represent
            # environment variables.
            path = encoding.to_unicode_from_fs(os.environ.get(env_var, ''))
            if osp.isdir(path):
                return path
            else:
                path = ''

        if not path:
            raise RuntimeError('Please set the environment variable HOME to '
                               'your user/home directory path so Spyder can '
                               'start properly.')


def get_clean_conf_dir():
    """
    Return the path to a temp clean configuration dir, for tests and safe mode.
    """
    conf_dir = osp.join(
        tempfile.gettempdir(),
        'spyder-clean-conf-dirs',
        CLEAN_DIR_ID,
    )
    return conf_dir


def get_custom_conf_dir():
    """
    Use a custom configuration directory, passed through our command
    line options or by setting the env var below.
    """
    custom_dir = os.environ.get('SPYDER_CONFDIR')
    if custom_dir:
        custom_dir = osp.abspath(custom_dir)

        # Set env var to not lose its value in future calls when the cwd
        # is changed by Spyder.
        os.environ['SPYDER_CONFDIR'] = custom_dir
        return custom_dir


def get_conf_path(filename=None):
    """Return absolute path to the config file with the specified filename."""
    # Define conf_dir
    if running_under_pytest() or get_safe_mode():
        # Use clean config dir if running tests or the user requests it.
        conf_dir = get_clean_conf_dir()
    elif get_custom_conf_dir():
        # Use a custom directory if the user decided to do it through
        # our command line options.
        conf_dir = get_custom_conf_dir()
    elif sys.platform.startswith('linux'):
        # This makes us follow the XDG standard to save our settings
        # on Linux, as it was requested on spyder-ide/spyder#2629.
        xdg_config_home = os.environ.get('XDG_CONFIG_HOME', '')
        if not xdg_config_home:
            xdg_config_home = osp.join(get_home_dir(), '.config')

        if not osp.isdir(xdg_config_home):
            os.makedirs(xdg_config_home)

        conf_dir = osp.join(xdg_config_home, get_conf_subfolder())
    else:
        conf_dir = osp.join(get_home_dir(), get_conf_subfolder())

    # Create conf_dir
    if not osp.isdir(conf_dir):
        if running_under_pytest() or get_safe_mode() or get_custom_conf_dir():
            os.makedirs(conf_dir)
        else:
            os.mkdir(conf_dir)

    if filename is None:
        return conf_dir
    else:
        return osp.join(conf_dir, filename)


def get_conf_paths():
    """Return the files that can update system configuration defaults."""
    CONDA_PREFIX = os.environ.get('CONDA_PREFIX', None)

    if os.name == 'nt':
        SEARCH_PATH = (
            'C:/ProgramData/spyder',
        )
    else:
        SEARCH_PATH = (
            '/etc/spyder',
            '/usr/local/etc/spyder',
        )

    if CONDA_PREFIX is not None:
        CONDA_PREFIX = CONDA_PREFIX.replace('\\', '/')
        SEARCH_PATH += (
            '{}/etc/spyder'.format(CONDA_PREFIX),
        )

    SEARCH_PATH += (
        '{}/etc/spyder'.format(sys.prefix),
    )

    if running_under_pytest():
        search_paths = []
        tmpfolder = str(tempfile.gettempdir())
        for i in range(3):
            path = os.path.join(tmpfolder, 'site-config-' + str(i))
            if not os.path.isdir(path):
                os.makedirs(path)
            search_paths.append(path)
        SEARCH_PATH = tuple(search_paths)

    return SEARCH_PATH


def get_module_path(modname):
    """Return module *modname* base path"""
    return osp.abspath(osp.dirname(sys.modules[modname].__file__))


def get_module_data_path(modname, relpath=None, attr_name='DATAPATH'):
    """Return module *modname* data path
    Note: relpath is ignored if module has an attribute named *attr_name*

    Handles py2exe/cx_Freeze distributions"""
    datapath = getattr(sys.modules[modname], attr_name, '')
    if datapath:
        return datapath
    else:
        datapath = get_module_path(modname)
        parentdir = osp.join(datapath, osp.pardir)
        if osp.isfile(parentdir):
            # Parent directory is not a directory but the 'library.zip' file:
            # this is either a py2exe or a cx_Freeze distribution
            datapath = osp.abspath(osp.join(osp.join(parentdir, osp.pardir),
                                            modname))
        if relpath is not None:
            datapath = osp.abspath(osp.join(datapath, relpath))
        return datapath


def get_module_source_path(modname, basename=None):
    """Return module *modname* source path
    If *basename* is specified, return *modname.basename* path where
    *modname* is a package containing the module *basename*

    *basename* is a filename (not a module name), so it must include the
    file extension: .py or .pyw

    Handles py2exe/cx_Freeze distributions"""
    srcpath = get_module_path(modname)
    parentdir = osp.join(srcpath, osp.pardir)
    if osp.isfile(parentdir):
        # Parent directory is not a directory but the 'library.zip' file:
        # this is either a py2exe or a cx_Freeze distribution
        srcpath = osp.abspath(osp.join(osp.join(parentdir, osp.pardir),
                                       modname))
    if basename is not None:
        srcpath = osp.abspath(osp.join(srcpath, basename))
    return srcpath


def is_py2exe_or_cx_Freeze():
    """Return True if this is a py2exe/cx_Freeze distribution of Spyder"""
    return osp.isfile(osp.join(get_module_path('spyder'), osp.pardir))


#==============================================================================
# Translations
#==============================================================================
LANG_FILE = get_conf_path('langconfig')
DEFAULT_LANGUAGE = 'en'

# This needs to be updated every time a new language is added to spyder, and is
# used by the Preferences configuration to populate the Language QComboBox
LANGUAGE_CODES = {
    'en': 'English',
    'fr': 'Français',
    'es': 'Español',
    'hu': 'Magyar',
    'pt_BR': 'Português',
    'ru': 'Русский',
    'zh_CN': '简体中文',
    'ja': '日本語',
    'de': 'Deutsch',
    'pl': 'Polski',
    'fa': 'Persian',
    'hr': 'Croatian',
    'te': 'Telugu',
    'uk': 'Ukrainian',
}

# Disabled languages because their translations are outdated or incomplete
DISABLED_LANGUAGES = ['fa', 'hr', 'hu', 'pl', 'te', 'uk', 'ru']


def get_available_translations():
    """
    List available translations for spyder based on the folders found in the
    locale folder. This function checks if LANGUAGE_CODES contain the same
    information that is found in the 'locale' folder to ensure that when a new
    language is added, LANGUAGE_CODES is updated.
    """
    locale_path = get_module_data_path("spyder", relpath="locale",
                                       attr_name='LOCALEPATH')
    listdir = os.listdir(locale_path)
    langs = [d for d in listdir if osp.isdir(osp.join(locale_path, d))]
    langs = [DEFAULT_LANGUAGE] + langs

    # Remove disabled languages
    langs = list(set(langs) - set(DISABLED_LANGUAGES))

    # Check that there is a language code available in case a new translation
    # is added, to ensure LANGUAGE_CODES is updated.
    retlangs = []
    for lang in langs:
        if lang not in LANGUAGE_CODES:
            if DEV:
                error = ('Update LANGUAGE_CODES (inside config/base.py) if a '
                         'new translation has been added to Spyder. '
                         'Currently missing ' + lang)
                print(error)  # spyder: test-skip
                return ['en']
        else:
            retlangs.append(lang)

    return retlangs


def get_interface_language():
    """
    If Spyder has a translation available for the locale language, it will
    return the version provided by Spyder adjusted for language subdifferences,
    otherwise it will return DEFAULT_LANGUAGE.

    Example:
    1.) Spyder provides ('en', 'de', 'fr', 'es' 'hu' and 'pt_BR'), if the
    locale is either 'en_US' or 'en' or 'en_UK', this function will return 'en'

    2.) Spyder provides ('en', 'de', 'fr', 'es' 'hu' and 'pt_BR'), if the
    locale is either 'pt' or 'pt_BR', this function will return 'pt_BR'
    """

    if os.name == "nt":
        # Changing to locale.getlocale from locale.getdefaultlocale caused some
        # Windows machines to return non BCP47 locale codes. Instead use
        # win32 GetUserDefaultLocaleName which does seem to give BCP47 locale.
        # Fixes spyder-ide/spyder#23318.
        from ctypes import create_unicode_buffer, windll
        bufsize = 85  # LOCALE_NAME_MAX_LENGTH
        buf = create_unicode_buffer(bufsize)
        if windll.kernel32.GetUserDefaultLocaleName(buf, bufsize):
            locale_language = buf.value
        else:
            locale_language = DEFAULT_LANGUAGE
    else:
        # Fixes spyder-ide/spyder#3627.
        try:
            locale_language = locale.getlocale()[0]
        except ValueError:
            locale_language = DEFAULT_LANGUAGE

    # Tests expect English as the interface language
    if running_under_pytest():
        locale_language = DEFAULT_LANGUAGE

    language = DEFAULT_LANGUAGE

    if locale_language is not None:
        spyder_languages = get_available_translations()
        for lang in spyder_languages:
            if locale_language == lang:
                language = locale_language
                break
            elif (locale_language.startswith(lang) or
                    lang.startswith(locale_language)):
                language = lang
                break

    return language


def save_lang_conf(value):
    """Save language setting to language config file"""
    # Needed to avoid an error when trying to save LANG_FILE
    # but the operation fails for some reason.
    # See spyder-ide/spyder#8807.
    try:
        with open(LANG_FILE, 'w') as f:
            f.write(value)
    except EnvironmentError:
        pass


def load_lang_conf():
    """
    Load language setting from language config file if it exists, otherwise
    try to use the local settings if Spyder provides a translation, or
    return the default if no translation provided.
    """
    if osp.isfile(LANG_FILE):
        with open(LANG_FILE, 'r') as f:
            lang = f.read()
    else:
        lang = get_interface_language()
        save_lang_conf(lang)

    # Save language again if it's been disabled
    if lang.strip('\n') in DISABLED_LANGUAGES:
        lang = DEFAULT_LANGUAGE
        save_lang_conf(lang)

    return lang


def get_translation(
    modname: str, dirname: str | None = None
) -> Callable[[str], str]:
    """
    Return the translation callback for module ``modname``.

    Parameters
    ----------
    modname : str
        The module to get the translation callback for.
    dirname : str | None, optional
        The directory name of the module, same as the module name by default.

    Returns
    -------
    Callable[[str], str]
        The gettext translation callback for the given module.
    """
    if dirname is None:
        dirname = modname

    def translate_dumb(x: str) -> str:
        """Dumb function to not use translations."""
        return x

    if building_autodoc():

        def translate_gettext(x: str) -> str:
            """
            Translate a text string to the current language for a module.

            Parameters
            ----------
            x : str
                The string to translate.

            Returns
            -------
            str
                The translated string.
            """
            return x

        return translate_gettext

    locale_path = get_module_data_path(
        dirname, relpath="locale", attr_name="LOCALEPATH"
    )

    # If LANG is defined in Ubuntu, a warning message is displayed,
    # so in Unix systems we define the LANGUAGE variable.
    language = load_lang_conf()
    if os.name == "nt":
        # Trying to set LANG on Windows can fail when Spyder is
        # run with admin privileges.
        # Fixes spyder-ide/spyder#6886.
        try:
            os.environ["LANG"] = language  # Works on Windows
        except Exception:
            return translate_dumb
    else:
        os.environ["LANGUAGE"] = language  # Works on Linux

    if language == "en":
        return translate_dumb

    import gettext

    try:
        _trans = gettext.translation(modname, locale_path)

        def translate_gettext(x: str) -> str:
            """
            Translate a text string to the current language for a module.

            Parameters
            ----------
            x : str
                The string to translate.

            Returns
            -------
            str
                The translated string.
            """
            return _trans.gettext(x)

        return translate_gettext
    except Exception as exc:
        # logging module is not yet initialised at this point
        print(
            f"Could not load translations for {language} due to: "
            f"{exc.__class__.__name__} - {exc}",
            file=sys.stderr,
        )
        return translate_dumb


# Translation callback
_ = get_translation("spyder")


#==============================================================================
# Namespace Browser (Variable Explorer) configuration management
#==============================================================================
# Variable explorer display / check all elements data types for sequences:
# (when saving the variable explorer contents, check_all is True,
CHECK_ALL = False  # XXX: If True, this should take too much to compute...

EXCLUDED_NAMES = ['nan', 'inf', 'infty', 'little_endian', 'colorbar_doc',
                  'typecodes', '__builtins__', '__main__', '__doc__', 'NaN',
                  'Inf', 'Infinity', 'sctypes', 'rcParams', 'rcParamsDefault',
                  'sctypeNA', 'typeNA', 'False_', 'True_']


#==============================================================================
# Conda-based installer application utilities
#==============================================================================
def is_conda_based_app(pyexec=sys.executable):
    """
    Check if Spyder is running from the conda-based installer by looking for
    the `conda_based_app` file.

    If a Python executable is provided, checks if it is in a conda-based
    installer environment or the root environment thereof.
    """
    real_pyexec = osp.realpath(pyexec)  # pyexec may be symlink

    # It seems in some cases the check below detects that a regular conda env
    # corresponds to our installer. This makes things a bit more strict by
    # also validating the env name where Spyder is installed.
    # Fixes spyder-ide/spyder#25549.
    if not "spyder-runtime" in real_pyexec:
        return False

    # Check the presence of conda-based-app file, which is added when creating
    # the installers.
    if os.name == 'nt':
        env_path = osp.dirname(real_pyexec)
    else:
        env_path = osp.dirname(osp.dirname(real_pyexec))

    menu_rel_path = '/Menu/conda-based-app'
    if (
        osp.exists(env_path + menu_rel_path)
        or glob(env_path + '/envs/*' + menu_rel_path)
    ):
        return True
    else:
        return False


def is_installed_all_users():
    """
    Check if conda-based installer is installed for all users.
    Only for conda-based installers.
    """
    real_pyexec = osp.realpath(sys.executable)  # may be symlink

    if not is_conda_based_app(real_pyexec):
        return False

    root = real_pyexec.split("envs")[0]
    return not osp.exists(root + ".nonadmin")


#==============================================================================
# Reset config files
#==============================================================================
SAVED_CONFIG_FILES = ('help', 'onlinehelp', 'path', 'pylint.results',
                      'spyder.ini', 'temp.py', 'temp.spydata', 'template.py',
                      'history.py', 'history_internal.py', 'workingdir',
                      '.projects', '.spyproject', '.ropeproject',
                      'monitor.log', 'monitor_debug.log', 'rope.log',
                      'langconfig', 'spyder.lock',
                      'config{}spyder.ini'.format(os.sep),
                      'config{}transient.ini'.format(os.sep),
                      'lsp_root_path', 'plugins')


def reset_config_files():
    """Remove all config files"""
    print("*** Reset Spyder settings to defaults ***", file=STDERR)
    for fname in SAVED_CONFIG_FILES:
        cfg_fname = get_conf_path(fname)
        if osp.isfile(cfg_fname) or osp.islink(cfg_fname):
            os.remove(cfg_fname)
        elif osp.isdir(cfg_fname):
            shutil.rmtree(cfg_fname)
        else:
            continue
        print("removing:", cfg_fname, file=STDERR)
