from __future__ import annotations

import logging
from collections.abc import Mapping
from typing import TYPE_CHECKING, NamedTuple

import pkg_resources

from mopidy import config as config_lib
from mopidy import exceptions
from mopidy.internal import path

if TYPE_CHECKING:
    from pathlib import Path
    from typing import Any, Dict, Iterator, List, Optional, Type

    from mopidy.commands import Command
    from mopidy.config import ConfigSchema

    Config = Dict[str, Dict[str, Any]]


logger = logging.getLogger(__name__)


class ExtensionData(NamedTuple):
    extension: "Extension"
    entry_point: Any
    config_schema: ConfigSchema
    config_defaults: Any
    command: Optional[Command]


class Extension:

    """Base class for Mopidy extensions"""

    dist_name: str
    """The extension's distribution name, as registered on PyPI

    Example: ``Mopidy-Soundspot``
    """

    ext_name: str
    """The extension's short name, as used in setup.py and as config section
    name

    Example: ``soundspot``
    """

    version: str
    """The extension's version

    Should match the :attr:`__version__` attribute on the extension's main
    Python module and the version registered on PyPI.
    """

    def get_default_config(self) -> str:
        """The extension's default config as a text string.

        :returns: str
        """
        raise NotImplementedError(
            'Add at least a config section with "enabled = true"'
        )

    def get_config_schema(self) -> ConfigSchema:
        """The extension's config validation schema

        :returns: :class:`~mopidy.config.schemas.ConfigSchema`
        """
        schema = config_lib.ConfigSchema(self.ext_name)
        schema["enabled"] = config_lib.Boolean()
        return schema

    @classmethod
    def get_cache_dir(cls, config: Config) -> Path:
        """Get or create cache directory for the extension.

        Use this directory to cache data that can safely be thrown away.

        :param config: the Mopidy config object
        :return: pathlib.Path
        """
        if cls.ext_name is None:
            raise AssertionError
        cache_dir_path = (
            path.expand_path(config["core"]["cache_dir"]) / cls.ext_name
        )
        path.get_or_create_dir(cache_dir_path)
        return cache_dir_path

    @classmethod
    def get_config_dir(cls, config: Config) -> Path:
        """Get or create configuration directory for the extension.

        :param config: the Mopidy config object
        :return: pathlib.Path
        """
        if cls.ext_name is None:
            raise AssertionError
        config_dir_path = (
            path.expand_path(config["core"]["config_dir"]) / cls.ext_name
        )
        path.get_or_create_dir(config_dir_path)
        return config_dir_path

    @classmethod
    def get_data_dir(cls, config: Config) -> Path:
        """Get or create data directory for the extension.

        Use this directory to store data that should be persistent.

        :param config: the Mopidy config object
        :returns: pathlib.Path
        """
        if cls.ext_name is None:
            raise AssertionError
        data_dir_path = (
            path.expand_path(config["core"]["data_dir"]) / cls.ext_name
        )
        path.get_or_create_dir(data_dir_path)
        return data_dir_path

    def get_command(self) -> Optional[Command]:
        """Command to expose to command line users running ``mopidy``.

        :returns:
          Instance of a :class:`~mopidy.commands.Command` class.
        """
        pass

    def validate_environment(self) -> None:
        """Checks if the extension can run in the current environment.

        Dependencies described by :file:`setup.py` are checked by Mopidy, so
        you should not check their presence here.

        If a problem is found, raise :exc:`~mopidy.exceptions.ExtensionError`
        with a message explaining the issue.

        :raises: :exc:`~mopidy.exceptions.ExtensionError`
        :returns: :class:`None`
        """
        pass

    def setup(self, registry: "Registry") -> None:
        """
        Register the extension's components in the extension :class:`Registry`.

        For example, to register a backend::

            def setup(self, registry):
                from .backend import SoundspotBackend
                registry.add('backend', SoundspotBackend)

        See :class:`Registry` for a list of registry keys with a special
        meaning. Mopidy will instantiate and start any classes registered under
        the ``frontend`` and ``backend`` registry keys.

        This method can also be used for other setup tasks not involving the
        extension registry.

        :param registry: the extension registry
        :type registry: :class:`Registry`
        """
        raise NotImplementedError


class Registry(Mapping):

    """Registry of components provided by Mopidy extensions.

    Passed to the :meth:`~Extension.setup` method of all extensions. The
    registry can be used like a dict of string keys and lists.

    Some keys have a special meaning, including, but not limited to:

    - ``backend`` is used for Mopidy backend classes.
    - ``frontend`` is used for Mopidy frontend classes.

    Extensions can use the registry for allow other to extend the extension
    itself. For example the ``Mopidy-Local`` historically used the
    ``local:library`` key to allow other extensions to register library
    providers for ``Mopidy-Local`` to use. Extensions should namespace
    custom keys with the extension's :attr:`~Extension.ext_name`,
    e.g. ``local:foo`` or ``http:bar``.
    """

    def __init__(self) -> None:
        self._registry: Dict[str, List[Type[Any]]] = {}

    def add(self, name: str, cls: Type[Any]) -> None:
        """Add a component to the registry.

        Multiple classes can be registered to the same name.
        """
        self._registry.setdefault(name, []).append(cls)

    def __getitem__(self, name: str) -> List[Type[Any]]:
        return self._registry.setdefault(name, [])

    def __iter__(self) -> Iterator[str]:
        return iter(self._registry)

    def __len__(self) -> int:
        return len(self._registry)


def load_extensions() -> List[ExtensionData]:
    """Find all installed extensions.

    :returns: list of installed extensions
    """

    installed_extensions = []

    for entry_point in pkg_resources.iter_entry_points("mopidy.ext"):
        logger.debug("Loading entry point: %s", entry_point)
        try:
            extension_class = entry_point.resolve()
        except Exception as e:
            logger.exception(
                f"Failed to load extension {entry_point.name}: {e}"
            )
            continue

        try:
            if not issubclass(extension_class, Extension):
                raise TypeError  # issubclass raises TypeError on non-class
        except TypeError:
            logger.error(
                "Entry point %s did not contain a valid extension" "class: %r",
                entry_point.name,
                extension_class,
            )
            continue

        try:
            extension = extension_class()
            # Ensure required extension attributes are present after try block
            _ = extension.dist_name
            _ = extension.ext_name
            _ = extension.version
            extension_data = ExtensionData(
                entry_point=entry_point,
                extension=extension,
                config_schema=extension.get_config_schema(),
                config_defaults=extension.get_default_config(),
                command=extension.get_command(),
            )
        except Exception:
            logger.exception(
                "Setup of extension from entry point %s failed, "
                "ignoring extension.",
                entry_point.name,
            )
            continue

        installed_extensions.append(extension_data)

        logger.debug(
            "Loaded extension: %s %s", extension.dist_name, extension.version
        )

    names = (ed.extension.ext_name for ed in installed_extensions)
    logger.debug("Discovered extensions: %s", ", ".join(names))
    return installed_extensions


def validate_extension_data(data: ExtensionData) -> bool:
    """Verify extension's dependencies and environment.

    :param extensions: an extension to check
    :returns: if extension should be run
    """

    logger.debug("Validating extension: %s", data.extension.ext_name)

    if data.extension.ext_name != data.entry_point.name:
        logger.warning(
            "Disabled extension %(ep)s: entry point name (%(ep)s) "
            "does not match extension name (%(ext)s)",
            {"ep": data.entry_point.name, "ext": data.extension.ext_name},
        )
        return False

    try:
        data.entry_point.require()
    except pkg_resources.DistributionNotFound as exc:
        logger.info(
            "Disabled extension %s: Dependency %s not found",
            data.extension.ext_name,
            exc,
        )
        return False
    except pkg_resources.VersionConflict as exc:
        if len(exc.args) == 2:
            found, required = exc.args
            logger.info(
                "Disabled extension %s: %s required, but found %s at %s",
                data.extension.ext_name,
                required,
                found,
                found.location,
            )
        else:
            logger.info(
                "Disabled extension %s: %s", data.extension.ext_name, exc
            )
        return False

    try:
        data.extension.validate_environment()
    except exceptions.ExtensionError as exc:
        logger.info("Disabled extension %s: %s", data.extension.ext_name, exc)
        return False
    except Exception:
        logger.exception(
            "Validating extension %s failed with an exception.",
            data.extension.ext_name,
        )
        return False

    if not data.config_schema:
        logger.error(
            "Extension %s does not have a config schema, disabling.",
            data.extension.ext_name,
        )
        return False
    elif not isinstance(data.config_schema.get("enabled"), config_lib.Boolean):
        logger.error(
            'Extension %s does not have the required "enabled" config'
            " option, disabling.",
            data.extension.ext_name,
        )
        return False

    for key, value in data.config_schema.items():
        if not isinstance(value, config_lib.ConfigValue):
            logger.error(
                "Extension %s config schema contains an invalid value"
                ' for the option "%s", disabling.',
                data.extension.ext_name,
                key,
            )
            return False

    if not data.config_defaults:
        logger.error(
            "Extension %s does not have a default config, disabling.",
            data.extension.ext_name,
        )
        return False

    return True
