# -*- coding: future_fstrings -*-
"""Handle searching for torrents on torrent trackers."""
import asyncio
import importlib
import inspect
import itertools
import logging
import pathlib
from typing import List, Iterator, Optional, Set, Tuple, Union

import cachalot

try:
    import uvloop

    asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
except ImportError:
    pass

from mariner import exceptions, torrent

Name = str
Page = str
Path = Union[str, pathlib.Path]
Url = str


class SearchEngine:
    """Search on for a torrent using Tracker plugins."""

    log = logging.getLogger(__name__)
    plugin_directory = pathlib.Path(__file__).parent / "trackers"

    def __init__(self, timeout: int = 10) -> None:
        self.timeout = timeout
        self.plugins = {}
        self.results = cachalot.Cache(path="~/.local/share/mariner/results.json", size=1000)
        self.initialize_plugins()

    @property
    def filters(self) -> Set[str]:
        "Available filters."
        filters = set()
        for plugin in self.plugins.values():
            filters = filters.union(plugin.filters)
        return filters

    @property
    def timeout(self) -> int:
        "Timeout for connections."
        return self._timeout

    @timeout.setter
    def timeout(self, timeout: int) -> None:
        if timeout <= 0:
            raise exceptions.ConfigurationError("Timeout cannot be negative.")
        self._timeout = timeout  # pylint: disable=attribute-defined-outside-init

    @staticmethod
    def _flatten(nested_list: List[List]) -> List:
        """Flatten a list."""
        return list(itertools.chain(*nested_list))

    def _load_modules(self) -> Iterator:
        """Find and import tracker plugins."""
        for module in self.plugin_directory.glob("*.py"):  # pylint: disable=no-member
            # Cast to str as a workaround for Python 3.5
            name = str(module.stem)
            module = str(module)

            self.log.debug("Loading module=%s", module)
            spec = importlib.util.spec_from_file_location(name, module)
            loaded_module = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(loaded_module)
            yield loaded_module

    def initialize_plugins(self) -> None:
        """Find engines and register them."""
        self.log.debug("Initializing plugins")
        for module in self._load_modules():
            classes = inspect.getmembers(module, inspect.isclass)
            for name, plugin in classes:
                # We don't care about classes defined in other modules
                if not plugin.__module__ == module.__name__:
                    continue
                self.log.debug("Adding plugin=%s", plugin)
                self.plugins[name.lower()] = plugin
                for alias in plugin.aliases:
                    self.plugins[alias] = plugin

    def result(self, tid: str) -> torrent.Torrent:
        """Get torrent of given id.

        Args:
            tid: ID of the torrent to get.

        Returns:
            Torrent with given ID.
        """
        self.log.debug("Fetching torrent with tid=%s", tid)
        torrent_ = self.results.get(tid)
        if torrent_:
            return torrent_
        raise exceptions.NoResultException(f"No torrent with ID {tid}")

    @cachalot.Cache(path="~/.cache/mariner/cache.json", timeout=14400, size=100)
    def _cached_search(self, title: str, trackers: List[str]) -> List[torrent.Torrent]:
        """Search for torrents on given site and cache to results. This
        method is an implementation detail. As coroutines are not easily
        serializable, we cannot simply cache TrackerPlugun.results() method.

        Args:
            title: String to search for.

        Returns:
            List of Torrents returned by the search.
        """
        self.log.debug("Fetching search results")

        # Get unique trackers
        try:
            plugins = set(self.plugins[t] for t in trackers)
        except KeyError:
            raise exceptions.ConfigurationError("Illegal value for default_tracker")
        else:
            tasks = asyncio.gather(
                *(p(timeout=self.timeout).results(title.lower()) for p in plugins)
            )
            loop = asyncio.get_event_loop()
            torrents = loop.run_until_complete(tasks)
            torrents = self._flatten(torrents)
        return torrents

    def search(
        self,  # pylint: disable=too-many-arguments
        title: str,
        trackers: List[str],
        limit: Optional[int] = 10,
        sort_by_newest: bool = False,
    ) -> List[Tuple[int, torrent.Torrent]]:
        """Search for torrents on given site.

        Args:
            title: String to search for.
            limit: Defaults to 10. Limits the number of results, that should be shown.

        Returns:
            List of Torrents returned by the search, up to the limit.
        """
        if not title:
            raise exceptions.InputError("No string to search for.")
        if not trackers:
            raise exceptions.InputError("No torrent trackers to search on")
        if limit <= 0:
            raise exceptions.InputError("Limit has to be higher than zero.")

        torrents = self._cached_search(title, trackers)
        sorted_torrents = self._sort_results(torrents, sort_by_newest)

        # Show only results up to to the limit
        results = list((i, t) for i, t in enumerate(sorted_torrents[:limit]))
        if results:
            self.save_results(results)
            return results
        raise exceptions.NoResultException(f"No results for {title}")

    def save_results(self, torrents: List[Tuple[int, torrent.Torrent]]) -> None:
        """Save results in a database.

        Args:
            torrents: List of ID, Torrent tuples.
        """
        self.results.clear()
        for tid, torrent_ in torrents:
            self.results.insert(tid, torrent_)

    def _sort_results(
        self, torrents: List[torrent.Torrent], sort_by_newest: bool
    ) -> List[torrent.Torrent]:
        """Sort torrent results.

        Args:
            torrents: List of torrents to sort.
            sort_by_newest: True if torrent should be sorted by recendivity.

        Returns:
            Sorted list of torrents.
        """
        self.log.debug("Sorting results")
        if sort_by_newest:
            sorted_torrents = list(reversed(sorted(torrents, key=lambda x: x.date)))
        else:
            sorted_torrents = list(reversed(sorted(torrents)))
        return sorted_torrents
