# -*- coding: utf-8 -*-
# Copyright 2018, CS GROUP - France, https://www.csgroup.eu/
#
# This file is part of EODAG project
#     https://www.github.com/CS-SI/EODAG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations

import logging
import re
from operator import attrgetter
from typing import TYPE_CHECKING, Any, Iterator, Optional, Union, cast

import importlib_metadata

from eodag.api.provider import ProvidersDict
from eodag.config import AUTH_TOPIC_KEYS, PLUGINS_TOPICS_KEYS, load_config
from eodag.plugins.apis.base import Api
from eodag.plugins.authentication.base import Authentication
from eodag.plugins.base import EODAGPluginMount
from eodag.plugins.crunch.base import Crunch
from eodag.plugins.download.base import Download
from eodag.plugins.search.base import Search
from eodag.utils import GENERIC_COLLECTION, deepcopy, dict_md5sum
from eodag.utils.exceptions import (
    AuthenticationError,
    MisconfiguredError,
    UnsupportedProvider,
)

if TYPE_CHECKING:
    from mypy_boto3_s3 import S3ServiceResource
    from requests.auth import AuthBase

    from eodag.api.product._product import EOProduct
    from eodag.api.provider import ProviderConfig
    from eodag.config import PluginConfig
    from eodag.plugins.base import PluginTopic


logger = logging.getLogger("eodag.plugins.manager")


class PluginManager:
    """A manager for the plugins.

    The role of instances of this class (normally only one instance exists,
    created during instantiation of :class:`~eodag.api.core.EODataAccessGateway`.
    But nothing is done to enforce this) is to instantiate the plugins
    according to the providers configuration, keep track of them in memory, and
    manage a cache of plugins already constructed. The providers configuration contains
    information such as the name of the provider, the internet endpoint for accessing
    it, and the plugins to use to perform defined actions (search, download,
    authenticate, crunch).

    :param providers: The ProvidersDict instance with all information about the providers
                      supported by ``eodag``
    """

    supported_topics = set(PLUGINS_TOPICS_KEYS)

    collection_to_provider_config_map: dict[str, list[ProviderConfig]]

    skipped_plugins: list[str]

    def __init__(self, providers: ProvidersDict) -> None:
        self.skipped_plugins = []
        self.providers = providers
        # Load all the plugins. This will make all plugin classes of a particular
        # type to be available in the base plugin class's 'plugins' attribute.
        # For example, by importing module 'eodag.plugins.search.resto', the plugin
        # 'RestoSearch' will be available in self.supported_topics['search'].plugins
        for topic in self.supported_topics:
            # This way of discovering plugins means that anyone can create eodag
            # plugins as a separate python package (though it must require eodag), and
            # have it discovered as long as they declare an entry point of the type
            # 'eodag.plugins.search' for example in its setup script. See the setup
            # script of eodag for an example of how to do this.
            for entry_point in importlib_metadata.entry_points(
                group="eodag.plugins.{}".format(topic)
            ):
                try:
                    entry_point.load()
                except ModuleNotFoundError:
                    logger.debug(
                        "%s plugin skipped, eodag[%s] or eodag[all] needed",
                        entry_point.name,
                        ",".join(entry_point.extras),
                    )
                    self.skipped_plugins.append(entry_point.name)
                except ImportError:
                    import traceback as tb

                    logger.warning("Unable to load plugin: %s.", entry_point.name)
                    logger.warning("Reason:\n%s", tb.format_exc())
                    logger.warning(
                        "Check that the plugin module (%s) is importable",
                        entry_point.name,
                    )
                if entry_point.dist and entry_point.dist.name != "eodag":
                    # use plugin providers if any
                    name = entry_point.dist.name
                    dist = entry_point.dist
                    plugin_providers_config_path = [
                        str(x) for x in dist.locate_file(name).rglob("providers.yml")
                    ]
                    if plugin_providers_config_path:
                        plugin_configs = load_config(plugin_providers_config_path[0])
                        self.providers.update_from_configs(plugin_configs)
        self.rebuild()

    def rebuild(self, providers: Optional[ProvidersDict] = None) -> None:
        """(Re)Build plugin manager mapping and cache"""
        if providers is not None:
            self.providers = providers

        self.build_collection_to_provider_config_map()
        self._built_plugins_cache: dict[tuple[str, str, str], Any] = {}

    def build_collection_to_provider_config_map(self) -> None:
        """Build mapping conf between collections and providers"""
        self.collection_to_provider_config_map = {}
        for provider in list(self.providers.values()):
            if not provider.collections_config:
                logger.info(
                    "%s: provider has no product configured and will be skipped",
                    provider,
                )
                del self.providers[provider.name]
                continue

            for collection in provider.collections_config:
                collection_providers = (
                    self.collection_to_provider_config_map.setdefault(  # noqa
                        collection, []
                    )
                )
                collection_providers.append(provider.config)
                collection_providers.sort(key=attrgetter("priority"), reverse=True)

    def get_search_plugins(
        self, collection: Optional[str] = None, provider: Optional[str] = None
    ) -> Iterator[Union[Search, Api]]:
        """Build and return all the search plugins supporting the given collection,
        ordered by highest priority, or the search plugin of the given provider

        :param collection: (optional) The collection that the constructed plugins
                             must support
        :param provider: (optional) The provider or the provider group on which to get
            the search plugins
        :returns: All the plugins supporting the collection, one by one (a generator
                  object)
            or :class:`~eodag.plugins.download.Api`)
        :raises: :class:`~eodag.utils.exceptions.UnsupportedProvider`
        """

        def get_plugin() -> Union[Search, Api]:
            plugin: Union[Search, Api]
            if search := getattr(config, "search", None):
                config.search.products = config.products
                config.search.priority = config.priority
                plugin = cast(Search, self._build_plugin(config.name, search, Search))
            elif api := getattr(config, "api", None):
                config.api.products = config.products
                config.api.priority = config.priority
                plugin = cast(Api, self._build_plugin(config.name, api, Api))
            else:
                raise MisconfiguredError(
                    f"No search plugin configured for {config.name}."
                )
            return plugin

        configs: Optional[list[ProviderConfig]]
        if collection:
            configs = self.collection_to_provider_config_map.get(collection)
            if not configs:
                logger.info(
                    "UnsupportedCollection: %s, using generic settings", collection
                )
                configs = self.collection_to_provider_config_map[GENERIC_COLLECTION]
        else:
            configs = list(p.config for p in self.providers.values())

        if provider:
            configs = [
                c for c in configs if provider in [getattr(c, "group", None), c.name]
            ]

        if not configs and collection:
            raise UnsupportedProvider(
                f"{provider} is not (yet) supported for {collection}"
            )
        if not configs:
            raise UnsupportedProvider(f"{provider} is not (yet) supported")

        for config in sorted(configs, key=attrgetter("priority"), reverse=True):
            yield get_plugin()

    def get_download_plugin(self, product: EOProduct) -> Union[Download, Api]:
        """Build and return the download plugin capable of downloading the given
        product.

        :param product: The product to get a download plugin for
        :returns: The download plugin capable of downloading the product
        """
        plugin_conf = self.providers.get_config(product.provider)
        if plugin_conf is None:
            msg = f"Provider {product.provider} not found"
            raise UnsupportedProvider(msg)
        if download := getattr(plugin_conf, "download", None):
            plugin_conf.download.priority = plugin_conf.priority
            plugin = cast(
                Download,
                self._build_plugin(product.provider, download, Download),
            )
        elif api := getattr(plugin_conf, "api", None):
            plugin_conf.api.products = plugin_conf.products
            plugin_conf.api.priority = plugin_conf.priority
            plugin = cast(Api, self._build_plugin(product.provider, api, Api))
        else:
            raise MisconfiguredError(
                f"No download plugin configured for provider {plugin_conf.name}."
            )
        return plugin

    def get_auth_plugin(
        self, associated_plugin: PluginTopic, product: Optional[EOProduct] = None
    ) -> Optional[Authentication]:
        """Build and return the authentication plugin associated to the given
        search/download plugin

        .. versionchanged:: v3.0.0
            ``get_auth_plugin()`` now needs ``associated_plugin`` instead of ``provider``
            as argument.

        :param associated_plugin: The search/download plugin to which the authentication
                                  plugin is linked
        :param product: The product to download. ``None`` for search authentication
        :returns: The Authentication plugin
        """
        # matching url from product to download
        if product is not None and len(product.assets) > 0:
            matching_url = next(iter(product.assets.values()))["href"]
        elif product is not None:
            matching_url = product.properties.get(
                "eodag:download_link"
            ) or product.properties.get("eodag:order_link")
        else:
            # search auth
            matching_url = getattr(associated_plugin.config, "api_endpoint", None)

        try:
            auth_plugin = next(
                self.get_auth_plugins(
                    associated_plugin.provider,
                    matching_url=matching_url,
                    matching_conf=associated_plugin.config,
                )
            )
        except StopIteration:
            auth_plugin = None
        return auth_plugin

    def get_auth_plugins(
        self,
        provider: str,
        matching_url: Optional[str] = None,
        matching_conf: Optional[PluginConfig] = None,
    ) -> Iterator[Authentication]:
        """Build and return the authentication plugin for the given collection and
        provider

        :param provider: The provider for which to get the authentication plugin
        :param matching_url: url to compare with plugin matching_url pattern
        :param matching_conf: configuration to compare with plugin matching_conf
        :returns: All the Authentication plugins for the given criteria
        """
        auth_conf: Optional[PluginConfig] = None

        def _is_auth_plugin_matching(
            auth_conf: PluginConfig,
            matching_url: Optional[str],
            matching_conf: Optional[PluginConfig],
        ) -> bool:
            plugin_matching_conf = getattr(auth_conf, "matching_conf", {})
            if matching_conf:
                if (
                    plugin_matching_conf
                    and matching_conf.__dict__.items() >= plugin_matching_conf.items()
                ):
                    # conf matches
                    return True
            plugin_matching_url = getattr(auth_conf, "matching_url", None)
            if matching_url:
                if plugin_matching_url and re.match(
                    rf"{plugin_matching_url}", matching_url
                ):
                    # url matches
                    return True
            # no match
            return False

        # providers configs with given provider at first
        sorted_providers_config = deepcopy(self.providers.configs)
        sorted_providers_config = {
            provider: sorted_providers_config.pop(provider),
            **sorted_providers_config,
        }

        for plugin_provider, provider_conf in sorted_providers_config.items():
            for key in AUTH_TOPIC_KEYS:
                auth_conf = getattr(provider_conf, key, None)
                if auth_conf is None:
                    continue
                # plugin without configured match criteria: only works for given provider
                unconfigured_match = (
                    True
                    if (
                        not getattr(auth_conf, "matching_conf", {})
                        and not getattr(auth_conf, "matching_url", None)
                        and provider == plugin_provider
                    )
                    else False
                )

                if unconfigured_match or _is_auth_plugin_matching(
                    auth_conf, matching_url, matching_conf
                ):
                    auth_conf.priority = provider_conf.priority
                    plugin = cast(
                        Authentication,
                        self._build_plugin(plugin_provider, auth_conf, Authentication),
                    )
                    yield plugin
                else:
                    continue

    def get_auth(
        self,
        provider: str,
        matching_url: Optional[str] = None,
        matching_conf: Optional[PluginConfig] = None,
    ) -> Optional[Union[AuthBase, S3ServiceResource]]:
        """Authenticate and return the authenticated object for the first matching
        authentication plugin

        :param provider: The provider for which to get the authentication plugin
        :param matching_url: url to compare with plugin matching_url pattern
        :param matching_conf: configuration to compare with plugin matching_conf
        :returns: All the Authentication plugins for the given criteria
        """
        for auth_plugin in self.get_auth_plugins(provider, matching_url, matching_conf):
            if auth_plugin and callable(getattr(auth_plugin, "authenticate", None)):
                try:
                    auth = auth_plugin.authenticate()
                    return auth
                except (AuthenticationError, MisconfiguredError) as e:
                    logger.debug(f"Could not authenticate on {provider}: {str(e)}")
                    continue
            else:
                logger.debug(
                    f"Could not authenticate on {provider} using {auth_plugin} plugin"
                )
                continue
        return None

    @staticmethod
    def get_crunch_plugin(name: str, **options: Any) -> Crunch:
        """Instantiate a eodag Crunch plugin whom class name is `name`, and configure
        it with the `options`

        :param name: The name of the Crunch plugin to instantiate
        :param options: The configuration parameters of the cruncher
        :returns: The cruncher named `name`
        """
        klass = Crunch.get_plugin_by_class_name(name)
        return klass(options)

    def sort_providers(self) -> None:
        """Sort providers taking into account current priority order"""
        for provider_configs in self.collection_to_provider_config_map.values():
            provider_configs.sort(key=attrgetter("priority"), reverse=True)

    def set_priority(self, provider: str, priority: int) -> None:
        """Set the priority of the given provider

        :param provider: The provider which is assigned the priority
        :param priority: The priority to assign to the provider
        """
        # Update the priority in the configurations so that it is taken into account
        # when a plugin of this provider is latterly built
        for (
            _,
            provider_configs,
        ) in self.collection_to_provider_config_map.items():
            for config in provider_configs:
                if config.name == provider:
                    config.priority = priority
            # Sort the provider configs, taking into account the new priority order
            provider_configs.sort(key=attrgetter("priority"), reverse=True)
        # Update the priority of already built plugins of the given provider
        for provider_name, topic_class, auth_match_md5 in self._built_plugins_cache:
            if provider_name == provider:
                self._built_plugins_cache[
                    (provider, topic_class, auth_match_md5)
                ].priority = priority

    def _build_plugin(
        self,
        provider: str,
        plugin_conf: PluginConfig,
        topic_class: type[PluginTopic],
    ) -> Union[Api, Search, Download, Authentication, Crunch]:
        """Build the plugin of the given topic with the given plugin configuration and
        registered as the given provider

        :param provider: The provider for which to build the plugin
        :param plugin_conf: The configuration of the plugin to be built
        :param topic_class: The type of plugin to build
        :returns: The built plugin
                :class:`~eodag.plugin.download.Download` or
                :class:`~eodag.plugin.authentication.Authentication` or
                :class:`~eodag.plugin.crunch.Crunch`
        """
        # md5 hash to helps identifying an auth plugin within several for a given provider
        # (each has distinct matching settings)
        auth_match_md5 = dict_md5sum(
            {
                "matching_url": getattr(plugin_conf, "matching_url", None),
                "matching_conf": getattr(plugin_conf, "matching_conf", None),
            }
        )
        cached_instance = self._built_plugins_cache.setdefault(
            (provider, topic_class.__name__, auth_match_md5), None
        )
        if cached_instance is not None:
            return cached_instance
        plugin_class = EODAGPluginMount.get_plugin_by_class_name(
            topic_class, getattr(plugin_conf, "type")
        )
        plugin: Union[Api, Search, Download, Authentication, Crunch] = plugin_class(
            provider, plugin_conf
        )
        self._built_plugins_cache[
            (provider, topic_class.__name__, auth_match_md5)
        ] = plugin
        return plugin
