"""
Query online databases like IMDb
"""

import asyncio
import collections

from .. import errors
from ..utils import webdbs
from . import JobBase

import logging  # isort:skip
_log = logging.getLogger(__name__)


class WebDbSearchJob(JobBase):
    """
    Ask user to select a specific search result from an internet database

    This job adds the following signals to the :attr:`~.JobBase.signal`
    attribute:

        ``search_results``
            Emitted after new search results are available. Registered callbacks
            get a sequence of :class:`~.utils.webdbs.common.SearchResult`
            instances as a positional argument.

        ``searching_status``
            Emitted when a new search is started and when new results are
            available. Registered callbacks get `True` for "search started" or
            `False` for "search ended" as a positional argument.

        ``info_updating``
            Emitted when an attempt to fetch additional information is made.
            Registered callbacks get the name of an attribute of a
            :class:`~.utils.webdbs.common.SearchResult` object that returns a
            coroutine function as a positional argument.

        ``info_updated``
            Emitted when additional information is available. Registered
            callbacks are called for each piece of information and get ``key``
            and ``value`` as positional arguments. ``key`` is an attribute of a
            :class:`~.utils.webdbs.common.SearchResult` object that returns a
            coroutine function. ``value`` is the return value of that coroutine
            function.

        ``query_updated``
            Emitted after the :attr:`query` changed. Registered callbacks get
            the new query as a positional argument.
    """

    @property
    def name(self):
        return f'{self._db.name}-id'

    @property
    def label(self):
        return f'{self._db.label} ID'

    @property
    def cache_id(self):
        """Database :attr:`~.WebDbApiBase.name` and :attr:`query`"""
        return self.query

    @property
    def db(self):
        """:class:`~.webdbs.base.WebDbApiBase` instance"""
        return self._db

    @property
    def query(self):
        """:class:`~.webdbs.common.Query` instance"""
        return self._query

    @property
    def no_id_ok(self):
        """Whether this job will finish gracefully if no ID is selected"""
        return self._no_id_ok

    @no_id_ok.setter
    def no_id_ok(self, value):
        self._no_id_ok = bool(value)

    @property
    def is_searching(self):
        """Whether a search request is currently being made"""
        return self._is_searching

    def initialize(self, *, db, query, no_id_ok=False):
        """
        Set internal state

        :param WebDbApiBase db: Return value of :func:`.utils.webdbs.webdb`
        :param query: Path of the release (doesn't have to exist) or
            :class:`~.webdbs.common.Query` instance
        :param no_id_ok: Whether this job may finish without error if no ID is
            selected
        """
        assert isinstance(db, webdbs.WebDbApiBase), f'Not a WebDbApiBase: {db!r}'
        self._db = db
        if not isinstance(query, webdbs.Query):
            query = webdbs.Query.from_any(query)
        self._query = self._db.sanitize_query(query)

        self.signal.add('search_results')
        self.signal.add('searching_status')
        self.signal.add('info_updating')
        self.signal.add('info_updated')
        self.signal.add('query_updated')
        self.signal.add('selected', record=True)

        # Call search() when `self.query` is changed externally, e.g. by another
        # job. This happens if we have multiple WebDbSearchJobs with different
        # WebDbs and we want to update the query of the second job with accurate
        # information from the first job. This means the user only has to fix a
        # potentially badly autodetected query once and not for each
        # WebDbSearchJob.
        self._query.signal.register('changed', self.search)

        # Hold some information about the selected search result.
        self._selected = {}
        self.signal.register('selected', self._selected.update)

        self.no_id_ok = no_id_ok
        self._is_searching = False
        self._search_task = None
        self._info_callbacks_task = None
        self._info_callbacks = _InfoCallbacks(
            callbacks={
                'id': self._make_update_info_func('id'),
                'summary': self._make_update_info_func('summary'),
                'title_original': self._make_update_info_func('title_original'),
                'title_english': self._make_update_info_func('title_english'),
                'genres': self._make_update_info_func('genres'),
                'directors': self._make_update_info_func('directors'),
                'cast': self._make_update_info_func('cast'),
                'countries': self._make_update_info_func('countries'),
            },
            error_callback=self.warn,
        )

    def _make_update_info_func(self, key):
        return lambda value: self._update_info_value(key, value)

    def _update_info_value(self, attr, value):
        if value is Ellipsis:
            # Indicate that we are updating `attr` by not providing a value.
            self.signal.emit('info_updating', attr)
        else:
            self.signal.emit('info_updated', attr, value)

    async def run(self):
        # Trigger initial search.
        self.search(self.query)

        # Block until finalize() is called.
        await self.finalization()

    async def _search(self, query):
        self._set_state(is_searching=True, results=())

        try:
            # Get new search results
            results = await self._db.search(query)
            self._set_state(is_searching=False, results=results)

            # Get details about the first search result
            self._run_info_callbacks(results[0] if results else None)

        except errors.RequestError as e:
            self.warn(e)

        finally:
            self._set_state(is_searching=False)

    def _set_state(self, *, is_searching=None, results=None):
        if is_searching is not None:
            self._is_searching = bool(is_searching)
            self.signal.emit('searching_status', self._is_searching)
        if results is not None:
            self.signal.emit('search_results', results)

    def _run_info_callbacks(self, result):
        if self._info_callbacks_task:
            self._info_callbacks_task.cancel()
        self._info_callbacks_task = self.add_task(
            self._info_callbacks(result),
            # Auto-remove reference to task when it is done.
            callback=self._unset_info_callbacks_task,
        )

    def _unset_info_callbacks_task(self, task_):
        self._info_callbacks_task = None

    def _cancel_tasks(self):
        for task_attr in ('_search_task', '_info_callbacks_task'):
            task = getattr(self, task_attr, None)
            if task:
                task.cancel()
                setattr(self, task_attr, None)

    def search(self, query):
        """
        Make new search request

        Any currently ongoing search is cancelled.

        :param str query: Query that is first passed through
            :meth:`~.webdbs.Query.from_any` and then
            :meth:`~.webdbs.WebDbApiBase.sanitize_query`
        """
        # Stop any ongoing requests. They are outdated.
        self._cancel_tasks()

        # Remove any warnings from previous requests (e.g. network issues).
        self.clear_warnings()

        # Update search query silently (without emitting any signals).
        new_query = self._db.sanitize_query(webdbs.Query.from_any(query))
        self.query.update(new_query, silent=True)
        self.signal.emit('query_updated', self.query)

        # This method is called to fix the initial query. If this happens before
        # this job is started, we cannot add any tasks yet. But that's ok
        # because we already updated our internal query, which will be used for
        # the initial search if this job is started.
        if self.was_started:
            # Initiate new search in a separate task so we can cancel it.
            self._search_task = self.add_task(
                self._search(new_query),
                # Auto-remove reference to task when it is done.
                callback=self._unset_search_task,
            )

    def _unset_search_task(self, task_):
        self._search_task = None

    def result_focused(self, result):
        """
        Must be called by the UI when the user focuses a different search result

        :param SearchResult result: Focused search result or `None` if there are
            no results
        """
        self._run_info_callbacks(result)

    def result_selected(self, result):
        """
        Must be called by the UI when the user accepts a search result

        :param SearchResult result: Focused search result or `None` if there are
            no results and the user accepted that (see :attr:`no_id_ok`)
        """
        if not self.is_searching:
            if result is not None:
                self.signal.emit('selected', {
                    'id': result.id,
                    'title': result.title,
                    'type': result.type,
                    'url': result.url,
                    'year': result.year,
                })
                self.send(result.id)

            if result is not None or self.no_id_ok:
                self._cancel_tasks()
                self.finalize()

    @property
    def selected(self):
        """
        Some more information about the selected search result

        This is a :class:`dict` that is empty before :meth:`result_selected`
        is called. After :meth:`result_selected` is called, it contains the
        following keys:

            - ``id``
            - ``title``
            - ``type``
            - ``url``
            - ``year``
        """
        return self._selected.copy()

    @property
    def exit_code(self):
        """`0` if job was successful, `> 0` otherwise, None while job is not finished"""
        exit_code = super().exit_code
        if exit_code is not None:
            if exit_code != 0 and self.no_id_ok:
                return 0
            else:
                return exit_code


class _InfoCallbacks:
    def __init__(self, callbacks, error_callback):
        # `callbacks` maps names of SearchResult attributes to callbacks that
        # get the value of each attribute.
        #
        # For example, {"title": handle_title} means: Get the "title" attribute
        # from a search result and pass it to handle_title().
        #
        # Values of SearchResult attributes may also be coroutine functions that
        # return the value. In that case, call
        # handle_title(await search_result.title()).
        self._callbacks = callbacks
        self._error_callback = error_callback

    async def __call__(self, result):
        if not result:
            for callback in self._callbacks.values():
                callback('')
        else:
            self._update_text_values(result)
            await self._update_awaitable_values(result)

    def _update_text_values(self, result):
        # Update plain, non-callable values first before awaiting coroutines.
        for attr, callback in self._callbacks.items():
            value = getattr(result, attr)
            if not callable(value):
                callback(self._value_as_string(value))

    async def _update_awaitable_values(self, result):
        # Update values that are coroutine functions that return the value.
        tasks = []
        for attr, callback in self._callbacks.items():
            value = getattr(result, attr)
            if callable(value):
                tasks.append(self._call_callback(
                    value_getter=value,
                    callback=callback,
                    cache_key=(result.id, attr),
                ))
        # Get all values concurrently
        await asyncio.gather(*tasks)

    _cache = {}
    _delay_between_updates = 0.5

    async def _call_callback(self, value_getter, callback, cache_key):
        cached_value = self._cache.get(cache_key, None)
        if cached_value is not None:
            callback(cached_value)
        else:
            # Wait for a bit before starting to load info. If the user rapidly
            # focuses different search results, we don't want to start lots of
            # requests every few fractions of a second.
            callback(Ellipsis)
            await asyncio.sleep(self._delay_between_updates)

            # Get attribute value from from search result
            try:
                value = await value_getter()
            except errors.RequestError as e:
                callback('')
                self._error_callback(e)
            else:
                value_str = self._value_as_string(value)
                self._cache[cache_key] = value_str
                callback(value_str)

    @staticmethod
    def _value_as_string(value):
        if not isinstance(value, str) and isinstance(value, collections.abc.Iterable):
            return ', '.join(str(v) for v in value)
        else:
            return str(value)
