"""Search aggregator service.

Coordinates multi-tracker searches by delegating to pipelines obtained from the
TrackerRegistry. Provides sorting, limiting, caching, and result storage for the
CLI layer.
"""

import asyncio
import datetime as dt
import logging
import pathlib

import cachalot

from mariner import exceptions
from mariner.models.torrent import Torrent
from mariner.services.registry import TrackerRegistry
from mariner.types import TrackerID
from mariner.utils import path

log = logging.getLogger(__name__)

_CACHE_TIMEOUT = 14400  # 4 hours
_CACHE_SIZE = 100
_RESULTS_SIZE = 1000


class Service:
    """Service to search for torrents across multiple trackers.

    Provides a synchronous interface for the CLI, with Cachalot caching
    of search results and a result store for ID-based lookups.

    Args:
        registry: A TrackerRegistry that provides fetcher+parser pairs.
        loop: A persistent event loop for async operations. Kept alive
            across REPL searches so the shared httpx client stays usable.

    """

    def __init__(
        self,
        registry: TrackerRegistry,
        loop: asyncio.AbstractEventLoop,
        cache_path: str | pathlib.Path | None = None,
        results_path: str | pathlib.Path | None = None,
    ) -> None:
        self._registry = registry
        self._loop = loop
        _results_path = str(results_path or path.data() / "results.json")
        self._results = cachalot.Cache(path=_results_path, size=_RESULTS_SIZE)
        _cache_path = str(cache_path or path.cache() / "cache.json")
        self._cache = cachalot.Cache(
            path=_cache_path,
            timeout=_CACHE_TIMEOUT,
            size=_CACHE_SIZE,
        )

    @property
    def available_trackers(self) -> list[TrackerID]:
        """Return tracker IDs known to the registry."""
        return self._registry.available_trackers

    def result(self, tid: int) -> Torrent:
        """Get a torrent from the last search by its ID.

        If the torrent needs detail resolution (has a details_url but
        no download/magnet links), the detail page is fetched lazily.

        Args:
            tid: The numeric ID assigned during the last search.

        Returns:
            The Torrent with the given ID.

        Raises:
            NoResultError: If no torrent with the given ID exists.

        """
        torrent = self._results.get(str(tid))
        if not torrent:
            raise exceptions.ResultNotFoundError(tid)

        if self._needs_resolution(torrent):
            torrent = self._loop.run_until_complete(self._resolve(torrent))
            self._results.insert(str(tid), torrent)

        return torrent

    @staticmethod
    def _needs_resolution(t: Torrent) -> bool:
        """Check whether a torrent needs detail page resolution."""
        return t.details_url is not None and t.magnet is None and t.download is None

    async def _resolve(self, torrent: Torrent) -> Torrent:
        """Resolve download links from the torrent's detail page."""
        tracker_id = TrackerID(torrent.tracker)
        try:
            pipeline = await self._registry.get(tracker_id)
        except Exception:
            log.warning("Could not get pipeline for %s", tracker_id)
            return torrent

        if pipeline.resolver is None:
            return torrent

        return await pipeline.resolver.resolve(torrent)

    def search(
        self,
        keywords: str,
        tracker_ids: list[TrackerID],
        limit: int | None = 10,
        sort_by_newest: bool = False,
    ) -> list[tuple[int, Torrent]]:
        """Search for torrents, with caching, sorting, and limiting.

        Args:
            keywords: The search query.
            tracker_ids: List of tracker identifiers to search.
            limit: Maximum number of results to return.
            sort_by_newest: Sort by date instead of seeders.

        Returns:
            List of (id, Torrent) tuples.

        Raises:
            EmptyKeywordsError: If keywords are empty.
            NoTrackersError: If tracker_ids is empty.
            InvalidLimitError: If limit <= 0.
            NoSearchResultsError: If no results are found.

        """
        if not keywords:
            raise exceptions.EmptyKeywordsError()
        if not tracker_ids:
            raise exceptions.NoTrackersError()
        if limit is not None and limit <= 0:
            raise exceptions.InvalidLimitError(limit)

        cache_key = f"{keywords}:{','.join(sorted(tracker_ids))}"
        torrents = self._cache.get(cache_key)
        if not torrents:
            log.debug("Cache miss for %r on %s", keywords, tracker_ids)
            torrents = self._loop.run_until_complete(
                self._search_trackers(keywords, tracker_ids),
            )
            self._cache.insert(cache_key, torrents)

        sorted_torrents = self._sort(torrents, sort_by_newest)

        results = list(enumerate(sorted_torrents[:limit]))
        if results:
            self._save_results(results)
            return results
        raise exceptions.NoSearchResultsError(keywords)

    async def _search_trackers(
        self,
        keywords: str,
        tracker_ids: list[TrackerID],
    ) -> list[Torrent]:
        """Search multiple trackers concurrently.

        Each tracker is fetched and parsed independently. Trackers that
        fail are logged and skipped.

        Args:
            keywords: The search query.
            tracker_ids: List of tracker identifiers to search.

        Returns:
            A flat list of Torrent results from all successful trackers.

        """
        tasks = [self._search_single(keywords, tid) for tid in tracker_ids]
        nested = await asyncio.gather(*tasks)
        return [torrent for results in nested for torrent in results]

    async def _search_single(
        self,
        keywords: str,
        tracker_id: TrackerID,
    ) -> list[Torrent]:
        """Search a single tracker, returning an empty list on failure."""
        try:
            pipeline = await self._registry.get(tracker_id)
            html = await pipeline.fetcher.fetch(keywords)
            results = pipeline.parser.parse_results(
                html, base_url=pipeline.fetcher.server
            )
            return results
        except exceptions.TrackerNotFoundError:
            log.warning("Unknown tracker: %s", tracker_id)
            return []
        except (exceptions.HTTPError, exceptions.NoServerAvailableError, OSError) as e:
            log.warning("Search failed for tracker %s: %s", tracker_id, e)
            return []
        except Exception:
            log.exception("Unexpected error searching tracker %s", tracker_id)
            return []

    def _save_results(self, results: list[tuple[int, Torrent]]) -> None:
        """Save results to the result store for later lookup by ID."""
        self._results.clear()
        for tid, torrent in results:
            self._results.insert(str(tid), torrent)

    @staticmethod
    def _sort(
        torrents: list[Torrent],
        sort_by_newest: bool,
    ) -> list[Torrent]:
        """Sort torrents by seeders (default) or date."""
        if sort_by_newest:
            return sorted(
                torrents,
                key=lambda t: t.date or dt.datetime.min,
                reverse=True,
            )
        return sorted(torrents, key=lambda t: t.seeders, reverse=True)
