import asyncio
from collections import deque
from functools import partial
import logging
from typing import Optional, Union

from ..base_manager import BaseManager
from ..constants import DEFAULT_WISHLIST_INTERVAL
from ..events import (
    on_message,
    build_message_map,
    ConnectionStateChangedEvent,
    EventBus,
    MessageReceivedEvent,
    SearchRequestReceivedEvent,
    SearchRequestRemovedEvent,
    SearchRequestSentEvent,
    SearchResultEvent,
    SessionDestroyedEvent,
    SessionInitializedEvent,
)
from ..protocol.messages import (
    DistributedSearchRequest,
    DistributedServerSearchRequest,
    ExcludedSearchPhrases,
    FileSearch,
    PeerSearchReply,
    RoomSearch,
    SearchInactivityTimeout,
    ServerSearchRequest,
    UserSearch,
    WishlistInterval,
    WishlistSearch,
)
from ..network.connection import (
    CloseReason,
    ConnectionState,
    PeerConnection,
    ServerConnection,
)
from ..network.network import Network
from ..room.model import Room
from ..settings import Settings
from ..shares.manager import SharesManager
from ..shares.utils import convert_items_to_file_data
from ..session import Session
from ..tasks import BackgroundTask, Timer
from ..transfer.interface import UploadInfoProvider
from ..user.model import BlockingFlag
from ..utils import task_counter, ticket_generator
from .model import ReceivedSearch, SearchResult, SearchRequest, SearchType


logger = logging.getLogger(__name__)


class SearchManager(BaseManager):
    """Handler for searches requests"""

    def __init__(
            self, settings: Settings, event_bus: EventBus,
            shares_manager: SharesManager, upload_info_provider: UploadInfoProvider,
            network: Network):

        self._settings: Settings = settings
        self._event_bus: EventBus = event_bus
        self._network: Network = network
        self._shares_manager: SharesManager = shares_manager
        self._upload_info_provider: UploadInfoProvider = upload_info_provider

        self._ticket_generator = ticket_generator()
        self._session: Optional[Session] = None

        self.received_searches: deque[ReceivedSearch] = deque(
            list(), maxlen=self._settings.searches.receive.store_amount)
        self.requests: dict[int, SearchRequest] = {}

        # Server variables
        self.search_inactivity_timeout: Optional[int] = None
        self.wishlist_interval: Optional[int] = None
        self.excluded_search_phrases: list[str] = []

        self.register_listeners()

        self._MESSAGE_MAP = build_message_map(self)

        self._search_reply_tasks: list[asyncio.Task] = []
        self._wishlist_task: BackgroundTask = BackgroundTask(
            interval=DEFAULT_WISHLIST_INTERVAL,
            task_coro=self._wishlist_job,
            name='wishlist-task'
        )

    def register_listeners(self):
        self._event_bus.register(
            ConnectionStateChangedEvent, self._on_state_changed)
        self._event_bus.register(
            MessageReceivedEvent, self._on_message_received)
        self._event_bus.register(
            SessionInitializedEvent, self._on_session_initialized)
        self._event_bus.register(
            SessionDestroyedEvent, self._on_session_destroyed)

    def remove_request(self, request: Union[SearchRequest, int]):
        """Removes the search request from the client. Incoming results after
        the request has been removed will be ignored

        :param request: :class:`.SearchRequest` object or ticket number to
            remove
        """
        ticket = request if isinstance(request, int) else request.ticket
        self.requests.pop(ticket)

    async def search(self, query: str) -> SearchRequest:
        """Performs a global search. The results generated by this query will
        stored in the returned object or can be listened to through the
        :class:`.SearchResultEvent` event

        :param query: The search query
        :return: An object containing the search request details and results
        """
        ticket = next(self._ticket_generator)

        await self._network.send_server_messages(
            FileSearch.Request(ticket, query)
        )
        request = SearchRequest(
            ticket=ticket,
            query=query,
            search_type=SearchType.NETWORK
        )
        await self._attach_request_timer_and_emit(request)

        return request

    async def search_room(self, room: Union[str, Room], query: str) -> SearchRequest:
        """Performs a search request on the specific room. The results generated
        by this query will stored in the returned object or can be listened to
        through the :class:`.SearchResultEvent` event

        :param room: Room object or name to query
        :param query: The search query
        :return: An object containing the search request details and results
        """
        room_name = room.name if isinstance(room, Room) else room
        ticket = next(self._ticket_generator)

        await self._network.send_server_messages(
            RoomSearch.Request(room_name, ticket, query)
        )
        request = SearchRequest(
            ticket=ticket,
            query=query,
            search_type=SearchType.ROOM,
            room=room_name
        )
        await self._attach_request_timer_and_emit(request)

        return request

    async def search_user(self, username: str, query: str) -> SearchRequest:
        """Performs a search request on the specific user. The results generated
        by this query will stored in the returned object or can be listened to
        through the :class:`.SearchResultEvent` event

        :param user: User object or name to query
        :param query: The search query
        :return: An object containing the search request details and results
        """
        ticket = next(self._ticket_generator)

        await self._network.send_server_messages(
            UserSearch.Request(username, ticket, query)
        )
        request = SearchRequest(
            ticket=ticket,
            query=query,
            search_type=SearchType.USER,
            username=username
        )
        await self._attach_request_timer_and_emit(request)

        return request

    async def _query_shares_and_reply(self, ticket: int, username: str, query: str):
        """Performs a query on the shares manager and reports the results to the
        user
        """
        if not self._session:
            logger.warning("not returning search results : no valid session was set")
            return

        if self._settings.users.is_blocked(username, BlockingFlag.SEARCHES):
            return

        visible, locked = self._shares_manager.query(
            query,
            username=username,
            excluded_search_phrases=self.excluded_search_phrases
        )

        result_count = len(visible) + len(locked)
        self.received_searches.append(
            ReceivedSearch(
                username=username,
                query=query,
                result_count=result_count
            )
        )
        await self._event_bus.emit(
            SearchRequestReceivedEvent(
                username=username,
                query=query,
                result_count=result_count
            )
        )

        if len(visible) + len(locked) == 0:
            return

        logger.info(
            "found %d/%d results for query %r (username=%r)",
            len(visible), len(locked), query, username
        )

        task = asyncio.create_task(
            self._network.send_peer_messages(
                username,
                PeerSearchReply.Request(
                    username=self._session.user.name,
                    ticket=ticket,
                    results=convert_items_to_file_data(visible, use_full_path=True),
                    has_slots_free=self._upload_info_provider.has_slots_free(),
                    avg_speed=int(self._upload_info_provider.get_average_upload_speed()),
                    queue_size=self._upload_info_provider.get_queue_size(),
                    locked_results=convert_items_to_file_data(locked, use_full_path=True)
                )
            ),
            name=f'search-reply-{task_counter()}'
        )
        task.add_done_callback(
            partial(self._search_reply_task_callback, ticket, username, query))
        self._search_reply_tasks.append(task)

    def _search_reply_task_callback(self, ticket: int, username: str, query: str, task: asyncio.Task):
        """Callback for a search reply task. This callback simply logs the
        results and removes the task from the list
        """
        try:
            task.result()

        except asyncio.CancelledError:
            logger.debug(
                "cancelled delivery of search results (ticket=%d, username=%s, query=%s)",
                ticket, username, query
            )
        except Exception as exc:
            logger.warning(
                "failed to deliver search results : %r (ticket=%d, username=%s, query=%s)",
                exc, ticket, username, query
            )
        else:
            logger.info(
                "delivered search results (ticket=%d, username=%s, query=%s)",
                ticket, username, query
            )
        finally:
            self._search_reply_tasks.remove(task)

    async def _wishlist_job(self):
        """Job handling wishlist queries, this method is intended to be run as
        a task. This method will run at the given ``interval`` (returned by the
        server after logon).
        """
        items = self._settings.searches.wishlist

        timeout = self._get_wishlist_request_timeout()

        enabled_items = list(filter(lambda item: item.enabled, items))
        logger.info("starting wishlist search of %d items", len(enabled_items))
        # Recreate
        for item in enabled_items:
            ticket = next(self._ticket_generator)

            await self._network.send_server_messages(
                WishlistSearch.Request(ticket, item.query)
            )

            request = SearchRequest(
                ticket,
                item.query,
                search_type=SearchType.WISHLIST
            )
            request.timer = Timer(
                timeout=timeout,
                callback=partial(self._timeout_search_request, request)
            ) if timeout else None
            self.requests[ticket] = request

            if request.timer:
                request.timer.start()

            await self._event_bus.emit(SearchRequestSentEvent(request))

    def _get_wishlist_request_timeout(self) -> int:
        timeout = self._settings.searches.send.wishlist_request_timeout
        if self._settings.searches.send.wishlist_request_timeout < 0:
            if self.wishlist_interval is None:
                timeout = DEFAULT_WISHLIST_INTERVAL
            else:
                timeout = self.wishlist_interval

        return timeout

    async def _attach_request_timer_and_emit(self, request: SearchRequest):
        self.requests[request.ticket] = request

        if self._settings.searches.send.request_timeout > 0:
            request.timer = Timer(
                timeout=self._settings.searches.send.request_timeout,
                callback=partial(self._timeout_search_request, request)
            )
            request.timer.start()

        await self._event_bus.emit(SearchRequestSentEvent(request))

    async def _timeout_search_request(self, request: SearchRequest):
        del self.requests[request.ticket]
        await self._event_bus.emit(SearchRequestRemovedEvent(request))

    async def _on_message_received(self, event: MessageReceivedEvent):
        message = event.message
        if message.__class__ in self._MESSAGE_MAP:
            await self._MESSAGE_MAP[message.__class__](message, event.connection)

    @on_message(SearchInactivityTimeout.Response)
    async def _on_search_inactivity_timeout(self, message: SearchInactivityTimeout.Response, connection):
        self.search_inactivity_timeout = message.timeout

    @on_message(DistributedSearchRequest.Request)
    async def _on_distributed_search_request(
            self, message: DistributedSearchRequest.Request, connection: PeerConnection):

        await self._query_shares_and_reply(message.ticket, message.username, message.query)

    @on_message(DistributedServerSearchRequest.Request)
    async def _on_distributed_server_search_request(
            self, message: DistributedServerSearchRequest.Request, connection: PeerConnection):

        if message.distributed_code != DistributedSearchRequest.Request.MESSAGE_ID:
            logger.warning("no handling for server search request with code %d", message.distributed_code)
            return

        await self._query_shares_and_reply(message.ticket, message.username, message.query)

    @on_message(ServerSearchRequest.Response)
    async def _on_server_search_request(self, message: ServerSearchRequest.Response, connection: ServerConnection):
        if self._session is None:
            return

        username = self._session.user.name
        if message.username == username:
            return

        await self._query_shares_and_reply(
            message.ticket, message.username, message.query)

    @on_message(FileSearch.Response)
    async def _on_file_search(self, message: FileSearch.Response, connection: ServerConnection):
        """Received when user performs a user or room search"""
        if self._session is None:
            return

        if message.username == self._session.user.name:
            return

        await self._query_shares_and_reply(
            message.ticket, message.username, message.query)

    @on_message(PeerSearchReply.Request)
    async def _on_peer_search_reply(self, message: PeerSearchReply.Request, connection: PeerConnection):
        search_result = SearchResult(
            ticket=message.ticket,
            username=message.username,
            has_free_slots=message.has_slots_free,
            avg_speed=message.avg_speed,
            queue_size=message.queue_size,
            shared_items=message.results,
            locked_results=message.locked_results or []
        )
        try:
            query = self.requests[message.ticket]

        except KeyError:
            logger.warning("search reply ticket does not match any search request : %d", message.ticket)

        else:
            if self._settings.searches.send.store_results:
                query.results.append(search_result)

            await self._event_bus.emit(SearchResultEvent(query, search_result))

        await connection.disconnect(reason=CloseReason.REQUESTED)

    @on_message(WishlistInterval.Response)
    async def _on_wish_list_interval(self, message: WishlistInterval.Response, connection: ServerConnection):
        self.wishlist_interval = message.interval

        if task := self._wishlist_task.cancel():
            await task

        self._wishlist_task.interval = self.wishlist_interval
        self._wishlist_task.start()

    @on_message(ExcludedSearchPhrases.Response)
    async def _on_excluded_search_phrases(self, message: ExcludedSearchPhrases.Response, connection: ServerConnection):
        self.excluded_search_phrases = message.phrases

    async def _on_state_changed(self, event: ConnectionStateChangedEvent):
        if not isinstance(event.connection, ServerConnection):
            return

        if event.state == ConnectionState.CLOSING:
            self._wishlist_task.cancel()

    async def _on_session_initialized(self, event: SessionInitializedEvent):
        self._session = event.session

    async def _on_session_destroyed(self, event: SessionDestroyedEvent):
        self._session = None

    async def stop(self) -> list[asyncio.Task]:
        """Cancels all pending tasks

        :return: a list of tasks that have been cancelled so that they can be
            awaited
        """
        cancelled_tasks = []

        for task in self._search_reply_tasks:
            task.cancel()
            cancelled_tasks.append(task)

        if wishlist_task := self._wishlist_task.cancel():
            cancelled_tasks.append(wishlist_task)

        return cancelled_tasks
