#-----------------------------------------------------------------------------
# Copyright (c) 2013-2023, PyInstaller Development Team.
#
# Distributed under the terms of the GNU General Public License (version 2
# or later) with exception for distributing the bootloader.
#
# The full license is in the file COPYING.txt, distributed with this software.
#
# SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
#-----------------------------------------------------------------------------

from PyInstaller.compat import is_darwin
from PyInstaller.utils.hooks import logger, get_hook_config
from PyInstaller import isolated


@isolated.decorate
def _get_configured_default_backend():
    """
    Return the configured default matplotlib backend name, if available as matplotlib.rcParams['backend'] (or overridden
    by MPLBACKEND environment variable. If the value of matplotlib.rcParams['backend'] corresponds to the auto-sentinel
    object, returns None
    """
    import matplotlib
    # matplotlib.rcParams overrides the __getitem__ implementation and attempts to determine and load the default
    # backend using pyplot.switch_backend(). Therefore, use dict.__getitem__().
    val = dict.__getitem__(matplotlib.rcParams, 'backend')
    if isinstance(val, str):
        return val
    return None


@isolated.decorate
def _list_available_mpl_backends():
    """
    Returns the names of all available matplotlib backends.
    """
    import matplotlib
    return matplotlib.rcsetup.all_backends


@isolated.decorate
def _check_mpl_backend_importable(module_name):
    """
    Attempts to import the given module name (matplotlib backend module).

    Exceptions are propagated to caller.
    """
    __import__(module_name)


# Bytecode scanning
def _recursive_scan_code_objects_for_mpl_use(co):
    """
    Recursively scan the bytecode for occurrences of matplotlib.use() or mpl.use() calls with const arguments, and
    collect those arguments into list of used matplotlib backend names.
    """

    from PyInstaller.depend.bytecode import any_alias, recursive_function_calls

    mpl_use_names = {
        *any_alias("matplotlib.use"),
        *any_alias("mpl.use"),  # matplotlib is commonly aliased as mpl
    }

    backends = []
    for calls in recursive_function_calls(co).values():
        for name, args in calls:
            # matplotlib.use(backend) or matplotlib.use(backend, force)
            # We support only literal arguments. Similarly, kwargs are
            # not supported.
            if len(args) not in {1, 2} or not isinstance(args[0], str):
                continue
            if name in mpl_use_names:
                backends.append(args[0])

    return backends


def _backend_module_name(name):
    """
    Converts matplotlib backend name to its corresponding module name.

    Equivalent to matplotlib.cbook._backend_module_name().
    """
    if name.startswith("module://"):
        return name[9:]
    return f"matplotlib.backends.backend_{name.lower()}"


def _autodetect_used_backends(hook_api):
    """
    Returns a list of automatically-discovered matplotlib backends in use, or the name of the default matplotlib
    backend. Implements the 'auto' backend selection method.
    """
    # Scan the code for matplotlib.use()
    modulegraph = hook_api.analysis.graph
    mpl_code_objs = modulegraph.get_code_using("matplotlib")
    used_backends = []
    for name, co in mpl_code_objs.items():
        co_backends = _recursive_scan_code_objects_for_mpl_use(co)
        if co_backends:
            logger.info(
                "Discovered Matplotlib backend(s) via `matplotlib.use()` call in module %r: %r", name, co_backends
            )
            used_backends += co_backends

    # Deduplicate and sort the list of used backends before displaying it.
    used_backends = sorted(set(used_backends))

    if used_backends:
        HOOK_CONFIG_DOCS = 'https://pyinstaller.org/en/stable/hooks-config.html#matplotlib-hooks'
        logger.info(
            "The following Matplotlib backends were discovered by scanning for `matplotlib.use()` calls: %r. If your "
            "backend of choice is not in this list, either add a `matplotlib.use()` call to your code, or configure "
            "the backend collection via hook options (see: %s).", used_backends, HOOK_CONFIG_DOCS
        )
        return used_backends

    # Determine the default matplotlib backend.
    #
    # Ideally, this would be done by calling ``matplotlib.get_backend()``. However, that function tries to switch to the
    # default backend (calling ``matplotlib.pyplot.switch_backend()``), which seems to occasionally fail on our linux CI
    # with an error and, on other occasions, returns the headless Agg backend instead of the GUI one (even with display
    # server running). Furthermore, using ``matplotlib.get_backend()`` returns headless 'Agg' when display server is
    # unavailable, which is not ideal for automated builds.
    #
    # Therefore, we try to emulate ``matplotlib.get_backend()`` ourselves. First, we try to obtain the configured
    # default backend from settings (rcparams and/or MPLBACKEND environment variable). If that is unavailable, we try to
    # find the first importable GUI-based backend, using the same list as matplotlib.pyplot.switch_backend() uses for
    # automatic backend selection. The difference is that we only test whether the backend module is importable, without
    # trying to switch to it.
    default_backend = _get_configured_default_backend()  # isolated sub-process
    if default_backend:
        logger.info("Found configured default matplotlib backend: %s", default_backend)
        return [default_backend]

    # `QtAgg` supersedes `Qt5Agg`; however, we keep `Qt5Agg` in the candidate list to support older versions of
    # matplotlib that do not have `QtAgg`.
    candidates = ["QtAgg", "Qt5Agg", "Gtk4Agg", "Gtk3Agg", "TkAgg", "WxAgg"]
    if is_darwin:
        candidates = ["MacOSX"] + candidates
    logger.info("Trying determine the default backend as first importable candidate from the list: %r", candidates)

    for candidate in candidates:
        try:
            module_name = _backend_module_name(candidate)
            _check_mpl_backend_importable(module_name)  # NOTE: uses an isolated sub-process.
        except Exception:
            continue
        return [candidate]

    # Fall back to headless Agg backend
    logger.info("None of the backend candidates could be imported; falling back to headless Agg!")
    return ['Agg']


def _collect_all_importable_backends(hook_api):
    """
    Returns a list of all importable matplotlib backends. Implements the 'all' backend selection method.
    """
    # List of the human-readable names of all available backends.
    backend_names = _list_available_mpl_backends()  # NOTE: retrieved in an isolated sub-process.
    logger.info("All available matplotlib backends: %r", backend_names)

    # Try to import the module(s).
    importable_backends = []

    # List of backends to exclude; Qt4 is not supported by PyInstaller anymore.
    exclude_backends = {'Qt4Agg', 'Qt4Cairo'}

    # Ignore "CocoaAgg" on OSes other than macOS; attempting to import it on other OSes halts the current
    # (sub)process without printing output or raising exceptions, preventing reliable detection. Apply the
    # same logic for the (newer) "MacOSX" backend.
    if not is_darwin:
        exclude_backends |= {'CocoaAgg', 'MacOSX'}

    # For safety, attempt to import each backend in an isolated sub-process.
    for backend_name in backend_names:
        if backend_name in exclude_backends:
            logger.info('  Matplotlib backend %r: excluded', backend_name)
            continue

        try:
            module_name = _backend_module_name(backend_name)
            _check_mpl_backend_importable(module_name)  # NOTE: uses an isolated sub-process.
        except Exception:
            # Backend is not importable, for whatever reason.
            logger.info('  Matplotlib backend %r: ignored due to import error', backend_name)
            continue

        logger.info('  Matplotlib backend %r: added', backend_name)
        importable_backends.append(backend_name)

    return importable_backends


def hook(hook_api):
    # Backend collection setting
    backends_method = get_hook_config(hook_api, 'matplotlib', 'backends')
    if backends_method is None:
        backends_method = 'auto'  # default method

    # Select backend(s)
    if backends_method == 'auto':
        logger.info("Matplotlib backend selection method: automatic discovery of used backends")
        backend_names = _autodetect_used_backends(hook_api)
    elif backends_method == 'all':
        logger.info("Matplotlib backend selection method: collection of all importable backends")
        backend_names = _collect_all_importable_backends(hook_api)
    else:
        logger.info("Matplotlib backend selection method: user-provided name(s)")
        if isinstance(backends_method, str):
            backend_names = [backends_method]
        else:
            assert isinstance(backends_method, list), "User-provided backend name(s) must be either a string or a list!"
            backend_names = backends_method

    # Deduplicate and sort the list of selected backends before displaying it.
    backend_names = sorted(set(backend_names))

    logger.info("Selected matplotlib backends: %r", backend_names)

    # Set module names as hiddenimports
    module_names = [_backend_module_name(backend) for backend in backend_names]  # backend name -> module name
    hook_api.add_imports(*module_names)
