"""The config functions."""

# Authors: The MNE-Python contributors.
# License: BSD-3-Clause
# Copyright the MNE-Python contributors.

import atexit
import contextlib
import json
import multiprocessing
import os
import os.path as op
import platform
import shutil
import subprocess
import sys
import tempfile
from functools import lru_cache, partial
from importlib import import_module
from pathlib import Path
from urllib.error import URLError
from urllib.request import urlopen

from packaging.version import parse

from ._logging import logger, warn
from .check import (
    _check_fname,
    _check_option,
    _check_qt_version,
    _soft_import,
    _validate_type,
)
from .docs import fill_doc
from .misc import _pl

_temp_home_dir = None


class UnknownPlatformError(Exception):
    """Exception raised for unknown platforms."""


def set_cache_dir(cache_dir):
    """Set the directory to be used for temporary file storage.

    This directory is used by joblib to store memmapped arrays,
    which reduces memory requirements and speeds up parallel
    computation.

    Parameters
    ----------
    cache_dir : str or None
        Directory to use for temporary file storage. None disables
        temporary file storage.
    """
    if cache_dir is not None and not op.exists(cache_dir):
        raise OSError(f"Directory {cache_dir} does not exist")

    set_config("MNE_CACHE_DIR", cache_dir, set_env=False)


def set_memmap_min_size(memmap_min_size):
    """Set the minimum size for memmaping of arrays for parallel processing.

    Parameters
    ----------
    memmap_min_size : str or None
        Threshold on the minimum size of arrays that triggers automated memory
        mapping for parallel processing, e.g., '1M' for 1 megabyte.
        Use None to disable memmaping of large arrays.
    """
    _validate_type(memmap_min_size, (str, None), "memmap_min_size")
    if memmap_min_size is not None:
        if memmap_min_size[-1] not in ["K", "M", "G"]:
            raise ValueError(
                "The size has to be given in kilo-, mega-, or "
                f"gigabytes, e.g., 100K, 500M, 1G, got {repr(memmap_min_size)}"
            )

    set_config("MNE_MEMMAP_MIN_SIZE", memmap_min_size, set_env=False)


# List the known configuration values
_known_config_types = {
    "MNE_3D_OPTION_ANTIALIAS": (
        "bool, whether to use full-screen antialiasing in 3D plots"
    ),
    "MNE_3D_OPTION_DEPTH_PEELING": "bool, whether to use depth peeling in 3D plots",
    "MNE_3D_OPTION_MULTI_SAMPLES": (
        "int, number of samples to use for full-screen antialiasing"
    ),
    "MNE_3D_OPTION_SMOOTH_SHADING": ("bool, whether to use smooth shading in 3D plots"),
    "MNE_3D_OPTION_THEME": ("str, the color theme (light or dark) to use for 3D plots"),
    "MNE_BROWSE_RAW_SIZE": (
        "tuple, width and height of the raw browser window (in inches)"
    ),
    "MNE_BROWSER_BACKEND": (
        "str, the backend to use for the MNE Browse Raw window (qt or matplotlib)"
    ),
    "MNE_BROWSER_OVERVIEW_MODE": (
        "str, the overview mode to use in the MNE Browse Raw window )"
        "(see mne.viz.plot_raw for valid options)"
    ),
    "MNE_BROWSER_PRECOMPUTE": (
        "bool, whether to precompute raw data in the MNE Browse Raw window"
    ),
    "MNE_BROWSER_THEME": "str, the color theme (light or dark) to use for the browser",
    "MNE_BROWSER_USE_OPENGL": (
        "bool, whether to use OpenGL for rendering in the MNE Browse Raw window"
    ),
    "MNE_CACHE_DIR": "str, path to the cache directory for parallel execution",
    "MNE_COREG_ADVANCED_RENDERING": (
        "bool, whether to use advanced OpenGL rendering in mne coreg"
    ),
    "MNE_COREG_COPY_ANNOT": (
        "bool, whether to copy the annotation files during warping"
    ),
    "MNE_COREG_FULLSCREEN": "bool, whether to use full-screen mode in mne coreg",
    "MNE_COREG_GUESS_MRI_SUBJECT": (
        "bool, whether to guess the MRI subject in mne coreg"
    ),
    "MNE_COREG_HEAD_HIGH_RES": (
        "bool, whether to use high-res head surface in mne coreg"
    ),
    "MNE_COREG_HEAD_OPACITY": ("bool, the head surface opacity to use in mne coreg"),
    "MNE_COREG_HEAD_INSIDE": (
        "bool, whether to add an opaque inner scalp head surface to help "
        "occlude points behind the head in mne coreg"
    ),
    "MNE_COREG_INTERACTION": (
        "str, interaction style in mne coreg (trackball or terrain)"
    ),
    "MNE_COREG_MARK_INSIDE": (
        "bool, whether to mark points inside the head surface in mne coreg"
    ),
    "MNE_COREG_PREPARE_BEM": (
        "bool, whether to prepare the BEM solution after warping in mne coreg"
    ),
    "MNE_COREG_ORIENT_TO_SURFACE": (
        "bool, whether to orient the digitization markers to the head surface "
        "in mne coreg"
    ),
    "MNE_COREG_SCALE_LABELS": (
        "bool, whether to scale the MRI labels during warping in mne coreg"
    ),
    "MNE_COREG_SCALE_BY_DISTANCE": (
        "bool, whether to scale the digitization markers by their distance from "
        "the scalp in mne coreg"
    ),
    "MNE_COREG_SCENE_SCALE": (
        "float, the scale factor of the 3D scene in mne coreg (default 0.16)"
    ),
    "MNE_COREG_WINDOW_HEIGHT": "int, window height for mne coreg",
    "MNE_COREG_WINDOW_WIDTH": "int, window width for mne coreg",
    "MNE_COREG_SUBJECTS_DIR": "str, path to the subjects directory for mne coreg",
    "MNE_CUDA_DEVICE": "int, CUDA device to use for GPU processing",
    "MNE_DATA": "str, default data directory",
    "MNE_DATASETS_BRAINSTORM_PATH": "str, path for brainstorm data",
    "MNE_DATASETS_EEGBCI_PATH": "str, path for EEGBCI data",
    "MNE_DATASETS_EPILEPSY_ECOG_PATH": "str, path for epilepsy_ecog data",
    "MNE_DATASETS_HF_SEF_PATH": "str, path for HF_SEF data",
    "MNE_DATASETS_MEGSIM_PATH": "str, path for MEGSIM data",
    "MNE_DATASETS_MISC_PATH": "str, path for misc data",
    "MNE_DATASETS_MTRF_PATH": "str, path for MTRF data",
    "MNE_DATASETS_SAMPLE_PATH": "str, path for sample data",
    "MNE_DATASETS_SOMATO_PATH": "str, path for somato data",
    "MNE_DATASETS_MULTIMODAL_PATH": "str, path for multimodal data",
    "MNE_DATASETS_FNIRS_MOTOR_PATH": "str, path for fnirs_motor data",
    "MNE_DATASETS_OPM_PATH": "str, path for OPM data",
    "MNE_DATASETS_SPM_FACE_DATASETS_TESTS": "str, path for spm_face data",
    "MNE_DATASETS_SPM_FACE_PATH": "str, path for spm_face data",
    "MNE_DATASETS_TESTING_PATH": "str, path for testing data",
    "MNE_DATASETS_VISUAL_92_CATEGORIES_PATH": "str, path for visual_92_categories data",
    "MNE_DATASETS_KILOWORD_PATH": "str, path for kiloword data",
    "MNE_DATASETS_FIELDTRIP_CMC_PATH": "str, path for fieldtrip_cmc data",
    "MNE_DATASETS_PHANTOM_KIT_PATH": "str, path for phantom_kit data",
    "MNE_DATASETS_PHANTOM_4DBTI_PATH": "str, path for phantom_4dbti data",
    "MNE_DATASETS_PHANTOM_KERNEL_PATH": "str, path for phantom_kernel data",
    "MNE_DATASETS_LIMO_PATH": "str, path for limo data",
    "MNE_DATASETS_REFMEG_NOISE_PATH": "str, path for refmeg_noise data",
    "MNE_DATASETS_SSVEP_PATH": "str, path for ssvep data",
    "MNE_DATASETS_ERP_CORE_PATH": "str, path for erp_core data",
    "MNE_FORCE_SERIAL": "bool, force serial rather than parallel execution",
    "MNE_LOGGING_LEVEL": (
        "str or int, controls the level of verbosity of any function "
        "decorated with @verbose. See "
        "https://mne.tools/stable/auto_tutorials/intro/50_configure_mne.html#logging"
    ),
    "MNE_MEMMAP_MIN_SIZE": (
        "str, threshold on the minimum size of arrays passed to the workers that "
        "triggers automated memory mapping, e.g., 1M or 0.5G"
    ),
    "MNE_REPR_HTML": (
        "bool, represent some of our objects with rich HTML in a notebook environment"
    ),
    "MNE_SKIP_NETWORK_TESTS": (
        "bool, used in a test decorator (@requires_good_network) to skip "
        "tests that include large downloads"
    ),
    "MNE_SKIP_TESTING_DATASET_TESTS": (
        "bool, used in test decorators (@requires_spm_data, "
        "@requires_bstraw_data) to skip tests that require specific datasets"
    ),
    "MNE_STIM_CHANNEL": "string, the default channel name for mne.find_events",
    "MNE_TQDM": (
        'str, either "tqdm", "tqdm.auto", or "off". Controls presence/absence '
        "of progress bars"
    ),
    "MNE_USE_CUDA": "bool, use GPU for filtering/resampling",
    "MNE_USE_NUMBA": (
        "bool, use Numba just-in-time compiler for some of our intensive computations"
    ),
    "SUBJECTS_DIR": "path-like, directory of freesurfer MRI files for each subject",
}

# These allow for partial matches, e.g. 'MNE_STIM_CHANNEL_1' is okay key
_known_config_wildcards = (
    "MNE_STIM_CHANNEL",  # can have multiple stim channels
    "MNE_DATASETS_FNIRS",  # mne-nirs
    "MNE_NIRS",  # mne-nirs
    "MNE_KIT2FIFF",  # mne-kit-gui
    "MNE_ICALABEL",  # mne-icalabel
    "MNE_LSL",  # mne-lsl
)


@contextlib.contextmanager
def _open_lock(path, *args, **kwargs):
    """
    Context manager that opens a file with an optional file lock.

    If the `filelock` package is available, a lock is acquired on a lock file
    based on the given path (by appending '.lock').

    Otherwise, a null context is used. The path is then opened in the
    specified mode.

    Parameters
    ----------
    path : str
        The path to the file to be opened.
    *args, **kwargs : optional
        Additional arguments and keyword arguments to be passed to the
        `open` function.

    """
    filelock = _soft_import(
        "filelock", purpose="parallel config set and get", strict=False
    )

    lock_context = contextlib.nullcontext()  # default to no lock

    if filelock:
        lock_path = f"{path}.lock"
        try:
            lock_context = filelock.FileLock(lock_path, timeout=5)
            lock_context.acquire()
        except TimeoutError:
            warn(
                "Could not acquire lock file after 5 seconds, consider deleting it "
                f"if you know the corresponding file is usable:\n{lock_path}"
            )
            lock_context = contextlib.nullcontext()

    with lock_context, open(path, *args, **kwargs) as fid:
        yield fid


def _load_config(config_path, raise_error=False):
    """Safely load a config file."""
    with _open_lock(config_path, "r+") as fid:
        try:
            config = json.load(fid)
        except ValueError:
            # No JSON object could be decoded --> corrupt file?
            msg = (
                f"The MNE-Python config file ({config_path}) is not a valid JSON "
                "file and might be corrupted"
            )
            if raise_error:
                raise RuntimeError(msg)
            warn(msg)
            config = dict()
    return config


def get_config_path(home_dir=None):
    r"""Get path to standard mne-python config file.

    Parameters
    ----------
    home_dir : str | None
        The folder that contains the .mne config folder.
        If None, it is found automatically.

    Returns
    -------
    config_path : str
        The path to the mne-python configuration file. On windows, this
        will be '%USERPROFILE%\.mne\mne-python.json'. On every other
        system, this will be ~/.mne/mne-python.json.
    """
    val = op.join(_get_extra_data_path(home_dir=home_dir), "mne-python.json")
    return val


def get_config(key=None, default=None, raise_error=False, home_dir=None, use_env=True):
    """Read MNE-Python preferences from environment or config file.

    Parameters
    ----------
    key : None | str
        The preference key to look for. The os environment is searched first,
        then the mne-python config file is parsed.
        If None, all the config parameters present in environment variables or
        the path are returned. If key is an empty string, a list of all valid
        keys (but not values) is returned.
    default : str | None
        Value to return if the key is not found.
    raise_error : bool
        If True, raise an error if the key is not found (instead of returning
        default).
    home_dir : str | None
        The folder that contains the .mne config folder.
        If None, it is found automatically.
    use_env : bool
        If True, consider env vars, if available.
        If False, only use MNE-Python configuration file values.

        .. versionadded:: 0.18

    Returns
    -------
    value : dict | str | None
        The preference key value.

    See Also
    --------
    set_config
    """
    _validate_type(key, (str, type(None)), "key", "string or None")

    if key == "":
        # These are str->str (immutable) so we should just copy the dict
        # itself, no need for deepcopy
        return _known_config_types.copy()

    # first, check to see if key is in env
    if use_env and key is not None and key in os.environ:
        return os.environ[key]

    # second, look for it in mne-python config file
    config_path = get_config_path(home_dir=home_dir)
    if not op.isfile(config_path):
        config = {}
    else:
        config = _load_config(config_path)

    if key is None:
        # update config with environment variables
        if use_env:
            env_keys = set(config).union(_known_config_types).intersection(os.environ)
            config.update({key: os.environ[key] for key in env_keys})
        return config
    elif raise_error is True and key not in config:
        loc_env = "the environment or in the " if use_env else ""
        meth_env = (
            (f'either os.environ["{key}"] = VALUE for a temporary solution, or ')
            if use_env
            else ""
        )
        extra_env = (
            " You can also set the environment variable before running python."
            if use_env
            else ""
        )
        meth_file = (
            f'mne.utils.set_config("{key}", VALUE, set_env=True) for a permanent one'
        )
        raise KeyError(
            f'Key "{key}" not found in {loc_env}'
            f"the mne-python config file ({config_path}). "
            f"Try {meth_env}{meth_file}.{extra_env}"
        )
    else:
        return config.get(key, default)


def set_config(key, value, home_dir=None, set_env=True):
    """Set a MNE-Python preference key in the config file and environment.

    Parameters
    ----------
    key : str
        The preference key to set.
    value : str |  None
        The value to assign to the preference key. If None, the key is
        deleted.
    home_dir : str | None
        The folder that contains the .mne config folder.
        If None, it is found automatically.
    set_env : bool
        If True (default), update :data:`os.environ` in addition to
        updating the MNE-Python config file.

    See Also
    --------
    get_config
    """
    _validate_type(key, "str", "key")
    # While JSON allow non-string types, we allow users to override config
    # settings using env, which are strings, so we enforce that here
    _validate_type(value, (str, "path-like", type(None)), "value")
    if value is not None:
        value = str(value)

    if key not in _known_config_types and not any(
        key.startswith(k) for k in _known_config_wildcards
    ):
        warn(f'Setting non-standard config type: "{key}"')

    # Read all previous values
    config_path = get_config_path(home_dir=home_dir)
    if op.isfile(config_path):
        config = _load_config(config_path, raise_error=True)
    else:
        config = dict()
        logger.info(
            f"Attempting to create new mne-python configuration file:\n{config_path}"
        )
    if value is None:
        config.pop(key, None)
        if set_env and key in os.environ:
            del os.environ[key]
    else:
        config[key] = value
        if set_env:
            os.environ[key] = value
        if key == "MNE_BROWSER_BACKEND":
            from ..viz._figure import set_browser_backend

            set_browser_backend(value)

    # Write all values. This may fail if the default directory is not
    # writeable.
    directory = op.dirname(config_path)
    if not op.isdir(directory):
        os.mkdir(directory)

    # Adapting the mode depend if you are create the file
    # or no.
    mode = "r+" if op.isfile(config_path) else "w+"

    with _open_lock(config_path, mode) as fid:
        try:
            data = json.load(fid)
        except (ValueError, json.JSONDecodeError) as exc:
            logger.info(
                f"Could not read the {config_path} json file during the writing."
                f" Assuming it is empty. Got: {exc}"
            )
            data = {}

        if value is None:
            data.pop(key, None)
        else:
            data[key] = value

        fid.seek(0)
        fid.truncate()
        json.dump(data, fid, sort_keys=True, indent=0)


def _get_extra_data_path(home_dir=None):
    """Get path to extra data (config, tables, etc.)."""
    global _temp_home_dir
    if home_dir is None:
        home_dir = os.environ.get("_MNE_FAKE_HOME_DIR")
    if home_dir is None:
        # this has been checked on OSX64, Linux64, and Win32
        if "nt" == os.name.lower():
            APPDATA_DIR = os.getenv("APPDATA")
            USERPROFILE_DIR = os.getenv("USERPROFILE")
            if APPDATA_DIR is not None and op.isdir(
                op.join(APPDATA_DIR, ".mne")
            ):  # backward-compat
                home_dir = APPDATA_DIR
            elif USERPROFILE_DIR is not None:
                home_dir = USERPROFILE_DIR
            else:
                raise FileNotFoundError(
                    "The USERPROFILE environment variable is not set, cannot "
                    "determine the location of the MNE-Python configuration "
                    "folder"
                )
            del APPDATA_DIR, USERPROFILE_DIR
        else:
            # This is a more robust way of getting the user's home folder on
            # Linux platforms (not sure about OSX, Unix or BSD) than checking
            # the HOME environment variable. If the user is running some sort
            # of script that isn't launched via the command line (e.g. a script
            # launched via Upstart) then the HOME environment variable will
            # not be set.
            if os.getenv("MNE_DONTWRITE_HOME", "") == "true":
                if _temp_home_dir is None:
                    _temp_home_dir = tempfile.mkdtemp()
                    atexit.register(
                        partial(shutil.rmtree, _temp_home_dir, ignore_errors=True)
                    )
                home_dir = _temp_home_dir
            else:
                home_dir = os.path.expanduser("~")

        if home_dir is None:
            raise ValueError(
                "mne-python config file path could "
                "not be determined, please report this "
                "error to mne-python developers"
            )

    return op.join(home_dir, ".mne")


def get_subjects_dir(subjects_dir=None, raise_error=False):
    """Safely use subjects_dir input to return SUBJECTS_DIR.

    Parameters
    ----------
    subjects_dir : path-like | None
        If a value is provided, return subjects_dir. Otherwise, look for
        SUBJECTS_DIR config and return the result.
    raise_error : bool
        If True, raise a KeyError if no value for SUBJECTS_DIR can be found
        (instead of returning None).

    Returns
    -------
    value : Path | None
        The SUBJECTS_DIR value.
    """
    from_config = False
    if subjects_dir is None:
        subjects_dir = get_config("SUBJECTS_DIR", raise_error=raise_error)
        from_config = True
        if subjects_dir is not None:
            subjects_dir = Path(subjects_dir)
    if subjects_dir is not None:
        # Emit a nice error or warning if their config is bad
        try:
            subjects_dir = _check_fname(
                fname=subjects_dir,
                overwrite="read",
                must_exist=True,
                need_dir=True,
                name="subjects_dir",
            )
        except FileNotFoundError:
            if from_config:
                msg = (
                    "SUBJECTS_DIR in your MNE-Python configuration or environment "
                    "does not exist, consider using mne.set_config to fix it: "
                    f"{subjects_dir}"
                )
                if raise_error:
                    raise FileNotFoundError(msg) from None
                else:
                    warn(msg)
            elif raise_error:
                raise

    return subjects_dir


@fill_doc
def _get_stim_channel(stim_channel, info, raise_error=True):
    """Determine the appropriate stim_channel.

    First, 'MNE_STIM_CHANNEL', 'MNE_STIM_CHANNEL_1', 'MNE_STIM_CHANNEL_2', etc.
    are read. If these are not found, it will fall back to 'STI 014' if
    present, then fall back to the first channel of type 'stim', if present.

    Parameters
    ----------
    stim_channel : str | list of str | None
        The stim channel selected by the user.
    %(info_not_none)s

    Returns
    -------
    stim_channel : list of str
        The name of the stim channel(s) to use
    """
    from .._fiff.pick import pick_types

    if stim_channel is not None:
        if not isinstance(stim_channel, list):
            _validate_type(stim_channel, "str", "Stim channel")
            stim_channel = [stim_channel]
        for channel in stim_channel:
            _validate_type(channel, "str", "Each provided stim channel")
        return stim_channel

    stim_channel = list()
    ch_count = 0
    ch = get_config("MNE_STIM_CHANNEL")
    while ch is not None and ch in info["ch_names"]:
        stim_channel.append(ch)
        ch_count += 1
        ch = get_config(f"MNE_STIM_CHANNEL_{ch_count}")
    if ch_count > 0:
        return stim_channel

    if "STI101" in info["ch_names"]:  # combination channel for newer systems
        return ["STI101"]
    if "STI 014" in info["ch_names"]:  # for older systems
        return ["STI 014"]

    stim_channel = pick_types(info, meg=False, ref_meg=False, stim=True)
    if len(stim_channel) == 0 and raise_error:
        raise ValueError(
            "No stim channels found. Consider specifying them "
            "manually using the 'stim_channel' parameter."
        )
    stim_channel = [info["ch_names"][ch_] for ch_ in stim_channel]
    return stim_channel


def _get_root_dir():
    """Get as close to the repo root as possible."""
    root_dir = Path(__file__).parents[1]
    up_dir = root_dir.parent
    if (up_dir / "setup.py").is_file() and all(
        (up_dir / x).is_dir() for x in ("mne", "examples", "doc")
    ):
        root_dir = up_dir
    return root_dir


def _get_numpy_libs():
    bad_lib = "unknown linalg bindings"
    try:
        from threadpoolctl import threadpool_info
    except Exception as exc:
        return bad_lib + f" (threadpoolctl module not found: {exc})"
    pools = threadpool_info()
    rename = dict(
        openblas="OpenBLAS",
        mkl="MKL",
    )
    for pool in pools:
        if pool["internal_api"] in ("openblas", "mkl"):
            return (
                f"{rename[pool['internal_api']]} "
                f"{pool['version']} with "
                f"{pool['num_threads']} thread{_pl(pool['num_threads'])}"
            )
    return bad_lib


_gpu_cmd = """\
from pyvista import GPUInfo; \
gi = GPUInfo(); \
print(gi.version); \
print(gi.renderer)"""


@lru_cache(maxsize=1)
def _get_gpu_info():
    # Once https://github.com/pyvista/pyvista/pull/2250 is merged and PyVista
    # does a release, we can triage based on version > 0.33.2
    proc = subprocess.run(
        [sys.executable, "-c", _gpu_cmd], check=False, capture_output=True
    )
    out = proc.stdout.decode().strip().replace("\r", "").split("\n")
    if proc.returncode or len(out) != 2:
        return None, None
    return out


def _get_total_memory():
    """Return the total memory of the system in bytes."""
    if platform.system() == "Windows":
        ps = shutil.which("pwsh") or shutil.which("powershell")
        o = subprocess.check_output(
            [ps, "-c", "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory"]
        ).decode()
        # Can get for example a "running scripts is disabled on this system"
        # error where "o" will be a long string rather than an int
        try:
            total_memory = int(o)
        except Exception:  # pragma: no cover
            total_memory = 0
    elif platform.system() == "Linux":
        o = subprocess.check_output(["free", "-b"]).decode()
        total_memory = int(o.splitlines()[1].split()[1])
    elif platform.system() == "Darwin":
        o = subprocess.check_output(["sysctl", "hw.memsize"]).decode()
        total_memory = int(o.split(":")[1].strip())
    else:
        raise UnknownPlatformError("Could not determine total memory")

    return total_memory


def _get_cpu_brand():
    """Return the CPU brand string."""
    if platform.system() == "Windows":
        ps = shutil.which("pwsh") or shutil.which("powershell")
        o = subprocess.check_output(
            [ps, "-c", "(Get-CimInstance Win32_Processor).Name"]
        ).decode()
        cpu_brand = o.strip().splitlines()[-1]
    elif platform.system() == "Linux":
        o = subprocess.check_output(["grep", "model name", "/proc/cpuinfo"]).decode()
        cpu_brand = o.splitlines()[0].split(": ")[1]
    elif platform.system() == "Darwin":
        o = subprocess.check_output(["sysctl", "machdep.cpu"]).decode()
        cpu_brand = o.split("brand_string: ")[1].strip()
    else:
        cpu_brand = "?"

    return cpu_brand


def sys_info(
    fid=None,
    show_paths=False,
    *,
    dependencies="user",
    unicode="auto",
    check_version=True,
):
    """Print system information.

    This function prints system information useful when triaging bugs.

    Parameters
    ----------
    fid : file-like | None
        The file to write to. Will be passed to :func:`print()`. Can be None to
        use :data:`sys.stdout`.
    show_paths : bool
        If True, print paths for each module.
    dependencies : 'user' | 'developer'
        Show dependencies relevant for users (default) or for developers
        (i.e., output includes additional dependencies).
    unicode : bool | "auto"
        Include Unicode symbols in output. If "auto", corresponds to True on Linux and
        macOS, and False on Windows.

        .. versionadded:: 0.24
    check_version : bool | float
        If True (default), attempt to check that the version of MNE-Python is up to date
        with the latest release on GitHub. Can be a float to give a different timeout
        (in sec) from the default (2 sec).

        .. versionadded:: 1.6
    """
    _validate_type(dependencies, str)
    _check_option("dependencies", dependencies, ("user", "developer"))
    _validate_type(check_version, (bool, "numeric"), "check_version")
    _validate_type(unicode, (bool, str), "unicode")
    _check_option("unicode", unicode, ("auto", True, False))
    if unicode == "auto":
        if platform.system() in ("Darwin", "Linux"):
            unicode = True
        else:  # Windows
            unicode = False
    ljust = 24 if dependencies == "developer" else 21
    platform_str = platform.platform()

    out = partial(print, end="", file=fid)
    out("Platform".ljust(ljust) + platform_str + "\n")
    out("Python".ljust(ljust) + str(sys.version).replace("\n", " ") + "\n")
    out("Executable".ljust(ljust) + sys.executable + "\n")
    try:
        cpu_brand = _get_cpu_brand()
    except Exception as exc:
        cpu_brand = f"? (could not determine: {exc})"
    out("CPU".ljust(ljust) + f"{cpu_brand} ")
    out(f"({multiprocessing.cpu_count()} cores)\n")
    out("Memory".ljust(ljust))
    try:
        total_memory = _get_total_memory()
    except Exception as exc:
        total_memory = f"? (could not determine: {exc})"
    else:
        total_memory = f"{total_memory / 1024**3:.1f}"  # convert to GiB
    out(f"{total_memory} GiB\n")
    out("\n")
    ljust -= 3  # account for +/- symbols
    libs = _get_numpy_libs()
    unavailable = []
    use_mod_names = (
        "# Core",
        "mne",
        "numpy",
        "scipy",
        "matplotlib",
        "",
        "# Numerical (optional)",
        "sklearn",
        "numba",
        "nibabel",
        "nilearn",
        "dipy",
        "openmeeg",
        "cupy",
        "pandas",
        "h5io",
        "h5py",
        "",
        "# Visualization (optional)",
        "pyvista",
        "pyvistaqt",
        "vtk",
        "qtpy",
        "ipympl",
        "pyqtgraph",
        "mne-qt-browser",
        "ipywidgets",
        # "trame",  # no version, see https://github.com/Kitware/trame/issues/183
        "trame_client",
        "trame_server",
        "trame_vtk",
        "trame_vuetify",
        "",
        "# Ecosystem (optional)",
        "mne-bids",
        "mne-nirs",
        "mne-features",
        "mne-connectivity",
        "mne-icalabel",
        "mne-bids-pipeline",
        "neo",
        "eeglabio",
        "edfio",
        "curryreader",
        "mffpy",
        "pybv",
        "pymef",
        "antio",
        "defusedxml",
        "",
    )
    if dependencies == "developer":
        use_mod_names += (
            "# Testing",
            "pytest",
            "hedtools",
            "statsmodels",
            "numpydoc",
            "jupyter_client",
            "nbclient",
            "nbformat",
            "nitime",
            "imageio",
            "imageio-ffmpeg",
            "snirf",
            "",
            "# Documentation",
            "sphinx",
            "sphinx-gallery",
            "pydata-sphinx-theme",
            "",
            "# Infrastructure",
            "decorator",
            "jinja2",
            # "lazy-loader",
            "packaging",
            "pooch",
            "tqdm",
            "",
        )
    try:
        unicode = unicode and (sys.stdout.encoding.lower().startswith("utf"))
    except Exception:  # in case someone overrides sys.stdout in an unsafe way
        unicode = False
    mne_version_good = True
    import_names = dict(hedtools="hed")
    for mi, mod_name in enumerate(use_mod_names):
        # upcoming break
        if mod_name == "":  # break
            if unavailable:
                out("└☐ " if unicode else " - ")
                out("unavailable".ljust(ljust))
                out(f"{', '.join(unavailable)}\n")
                unavailable = []
            if mi != len(use_mod_names) - 1:
                out("\n")
            continue
        elif mod_name.startswith("# "):  # header
            mod_name = mod_name.replace("# ", "")
            out(f"{mod_name}\n")
            continue
        pre = "├"
        last = use_mod_names[mi + 1] == "" and not unavailable
        if last:
            pre = "└"
        try:
            import_name = import_names.get(mod_name, mod_name.replace("-", "_"))
            mod = import_module(import_name)
        except Exception:
            unavailable.append(mod_name)
        else:
            mark = "☑" if unicode else "+"
            mne_extra = ""
            if mod_name == "mne" and check_version:
                timeout = 2.0 if check_version is True else float(check_version)
                mne_version_good, mne_extra = _check_mne_version(timeout)
                if mne_version_good is None:
                    mne_version_good = True
                elif not mne_version_good:
                    mark = "☒" if unicode else "X"
            out(f"{pre}{mark} " if unicode else f" {mark} ")
            out(f"{mod_name}".ljust(ljust))
            if mod_name == "vtk":
                vtk_version = mod.vtkVersion()
                # 9.0 dev has VersionFull but 9.0 doesn't
                for attr in ("GetVTKVersionFull", "GetVTKVersion"):
                    if hasattr(vtk_version, attr):
                        version = getattr(vtk_version, attr)()
                        if version != "":
                            out(version)
                            break
                else:
                    out("unknown")
            else:
                out(mod.__version__.lstrip("v"))
            if mod_name == "numpy":
                out(f" ({libs})")
            elif mod_name == "qtpy":
                version, api = _check_qt_version(return_api=True)
                out(f" ({api}={version})")
            elif mod_name == "matplotlib":
                out(f" (backend={mod.get_backend()})")
            elif mod_name == "pyvista":
                version, renderer = _get_gpu_info()
                if version is None:
                    out(" (OpenGL unavailable)")
                else:
                    out(f" (OpenGL {version} via {renderer})")
            elif mod_name == "mne":
                out(f" ({mne_extra})")
            # Now comes stuff after the version
            if show_paths:
                if last:
                    pre = "   "
                elif unicode:
                    pre = "│  "
                else:
                    pre = " | "
                out(f"\n{pre}{' ' * ljust}{op.dirname(mod.__file__)}")
            out("\n")

    if not mne_version_good:
        out(
            "\nTo update to the latest supported release version to get bugfixes and "
            "improvements, visit "
            "https://mne.tools/stable/install/updating.html\n"
        )


def _get_latest_version(timeout):
    # Bandit complains about urlopen, but we know the URL here
    url = "https://api.github.com/repos/mne-tools/mne-python/releases/latest"
    try:
        with urlopen(url, timeout=timeout) as f:  # nosec
            response = json.load(f)
    except (URLError, TimeoutError) as err:
        # Triage error type
        if "SSL" in str(err):
            return "SSL error"
        elif "timed out" in str(err):
            return f"timeout after {timeout} sec"
        else:
            return f"unknown error: {err}"
    else:
        return response["tag_name"].lstrip("v") or "version unknown"


def _check_mne_version(timeout):
    rel_ver = _get_latest_version(timeout)
    if not rel_ver[0].isnumeric():
        return None, (f"unable to check for latest version on GitHub, {rel_ver}")
    rel_ver = parse(rel_ver)
    this_ver = parse(import_module("mne").__version__)
    if this_ver > rel_ver:
        return True, f"development, latest release is {rel_ver}"
    if this_ver == rel_ver:
        return True, "latest release"
    else:
        return False, f"outdated, release {rel_ver} is available!"
