"""Registry for tracker definitions.

Loads YAML definition files and vends TrackerPipeline instances by tracker ID.
"""

import dataclasses
import datetime as dt
import importlib.resources
import logging
import pathlib
from typing import Literal

import yaml

from mariner import exceptions
from mariner.clients import http
from mariner.models import json as json_model
from mariner.models import tracker
from mariner.services import (
    definition_loader,
    detail_resolver,
    downloader,
    fetcher,
    parser,
)
from mariner.types import YAML, TrackerID
from mariner.utils import path as pathutil

log = logging.getLogger(__name__)

_DEFAULT_DEFINITIONS_DIR = pathlib.Path(
    str(importlib.resources.files("mariner") / "definitions")
)

_JACKETT_BASE_URL = (
    "https://raw.githubusercontent.com/Jackett/Jackett"
    "/refs/heads/master/src/Jackett.Common/Definitions/{name}.yml"
)

_METADATA_FILE = "tracker_metadata.yaml"

TrackerSource = Literal["shipped", "user"]


@dataclasses.dataclass(frozen=True)
class TrackerPipeline:
    """A fetcher+parser+resolver pipeline for searching a single tracker."""

    fetcher: fetcher.Service
    parser: parser.Parser
    resolver: detail_resolver.Service | None = None


@dataclasses.dataclass(frozen=True)
class TrackerInfo:
    """Loaded tracker with provenance metadata."""

    definition: tracker.Definition
    source: TrackerSource
    path: pathlib.Path


@dataclasses.dataclass(frozen=True)
class TrackerMetadata:
    """Persisted metadata for a user-managed tracker."""

    source_url: str
    added_at: str
    updated_at: str


class TrackerRegistry:
    """Registry that loads YAML tracker definitions and creates service pairs.

    The registry scans shipped and user definition directories, parses YAML
    files into ``tracker.Definition`` objects, and provides
    ``(fetcher.Service, parser.Service)`` pairs on demand.

    Args:
        client: A shared async HTTP client for all trackers.
        definition_downloader: Downloader for fetching remote definitions.
        definitions_dir: Path to the shipped ``.yml`` files.
        user_definitions_dir: Path to user-managed ``.yml`` files.

    """

    def __init__(
        self,
        client: http.AsyncClientProtocol,
        definition_downloader: downloader.Service,
        definitions_dir: pathlib.Path = _DEFAULT_DEFINITIONS_DIR,
        user_definitions_dir: pathlib.Path | None = None,
    ) -> None:
        self._client = client
        self._downloader = definition_downloader
        self._definitions_dir = definitions_dir
        self._user_definitions_dir = user_definitions_dir or pathutil.user_definitions()
        self._trackers: dict[TrackerID, TrackerInfo] = {}
        self._fetchers: dict[TrackerID, fetcher.Service] = {}
        self._parsers: dict[TrackerID, parser.Parser] = {}
        self._load()

    def _load(self) -> None:
        """Scan shipped and user definition directories.

        Raises:
            DefinitionsDirectoryError: If the shipped directory does not exist.
            NoDefinitionsFoundError: If no valid YAML definitions are found.

        """
        if not self._definitions_dir.is_dir():
            raise exceptions.DefinitionsDirectoryError(self._definitions_dir)

        self._load_directory(self._definitions_dir, source="shipped")

        if self._user_definitions_dir.is_dir():
            self._load_directory(self._user_definitions_dir, source="user")

        if not self._trackers:
            raise exceptions.NoDefinitionsFoundError(self._definitions_dir)

    def _load_directory(
        self,
        directory: pathlib.Path,
        source: TrackerSource,
    ) -> None:
        """Load all YAML definitions from a directory."""
        for yaml_path in sorted(directory.glob("*.yml")):
            if yaml_path.name == _METADATA_FILE:
                continue
            try:
                yaml_data = yaml_path.read_text(encoding="utf-8")
                definition = definition_loader.from_yaml(YAML(yaml_data))
                tracker_id = TrackerID(definition.id)
                self._trackers[tracker_id] = TrackerInfo(
                    definition=definition,
                    source=source,
                    path=yaml_path,
                )
                log.debug("Loaded definition: %s (%s)", tracker_id, source)
            except Exception:
                log.exception(
                    "Failed to load definition from %s",
                    yaml_path,
                )

    @property
    def available_trackers(self) -> list[TrackerID]:
        """Return the IDs of all loaded tracker definitions."""
        return list(self._trackers.keys())

    def get_definition(self, tracker_id: TrackerID) -> tracker.Definition:
        """Return the raw definition for the given tracker.

        Args:
            tracker_id: The tracker identifier.

        Returns:
            The tracker Definition.

        Raises:
            TrackerNotFoundError: If the tracker ID is not in the registry.

        """
        try:
            return self._trackers[tracker_id].definition
        except KeyError:
            raise exceptions.TrackerNotFoundError(
                tracker_id,
                available=self.available_trackers,
            ) from None

    def get_info(self, tracker_id: TrackerID) -> TrackerInfo:
        """Return full tracker info including provenance.

        Args:
            tracker_id: The tracker identifier.

        Raises:
            TrackerNotFoundError: If the tracker ID is not in the registry.

        """
        try:
            return self._trackers[tracker_id]
        except KeyError:
            raise exceptions.TrackerNotFoundError(
                tracker_id,
                available=self.available_trackers,
            ) from None

    async def get(self, tracker_id: TrackerID) -> TrackerPipeline:
        """Return a pipeline for the given tracker.

        Fetcher instances are cached so that server probing only
        happens on the first access for each tracker.

        Args:
            tracker_id: The tracker identifier.

        Returns:
            A TrackerPipeline with fetcher and parser services.

        Raises:
            TrackerNotFoundError: If the tracker ID is not in the registry.

        """
        definition = self.get_definition(tracker_id)
        if tracker_id not in self._fetchers:
            self._fetchers[tracker_id] = await fetcher.Service.create(
                client=self._client,
                tracker=definition,
            )
        if tracker_id not in self._parsers:
            if isinstance(definition.spec.fields, json_model.SearchFields):
                self._parsers[tracker_id] = parser.JSON(tracker=definition)
            else:
                self._parsers[tracker_id] = parser.HTML(tracker=definition)

        resolver = None
        if definition.spec.download is not None:
            resolver = detail_resolver.Service(
                client=self._client,
                download_spec=definition.spec.download,
                config=definition.spec.config,
            )

        return TrackerPipeline(
            fetcher=self._fetchers[tracker_id],
            parser=self._parsers[tracker_id],
            resolver=resolver,
        )

    async def add(
        self,
        name: str,
        *,
        url: str | None = None,
    ) -> TrackerInfo:
        """Download and add a tracker definition.

        Args:
            name: The tracker name (used as filename and Jackett ID).
            url: Override URL to download the YAML from.

        Returns:
            The newly added TrackerInfo.

        Raises:
            HTTPError: If the download fails.

        """
        source_url = url or _JACKETT_BASE_URL.format(name=name)
        filename = f"{name}.yml"
        dest = await self._downloader.download_one(source_url, filename)

        yaml_data = dest.read_text(encoding="utf-8")
        definition = definition_loader.from_yaml(YAML(yaml_data))

        self._save_metadata(name, source_url)

        tracker_id = TrackerID(definition.id)
        info = TrackerInfo(
            definition=definition,
            source="user",
            path=dest,
        )
        self._trackers[tracker_id] = info
        self._invalidate_caches(tracker_id)
        log.debug("Added tracker: %s from %s", name, source_url)
        return info

    def remove(self, tracker_id: TrackerID) -> None:
        """Remove a user-managed tracker.

        If a shipped version of the tracker exists, it is restored
        automatically after removing the user override.

        Args:
            tracker_id: The tracker identifier.

        Raises:
            TrackerNotFoundError: If the tracker does not exist.
            TrackerNotUserManagedError: If the tracker is shipped.

        """
        info = self.get_info(tracker_id)
        if info.source != "user":
            raise exceptions.TrackerNotUserManagedError(tracker_id)

        info.path.unlink(missing_ok=True)
        self._remove_metadata(tracker_id)
        self._invalidate_caches(tracker_id)

        shipped_path = self._definitions_dir / f"{tracker_id}.yml"
        if shipped_path.is_file():
            try:
                yaml_data = shipped_path.read_text(encoding="utf-8")
                definition = definition_loader.from_yaml(YAML(yaml_data))
                self._trackers[tracker_id] = TrackerInfo(
                    definition=definition,
                    source="shipped",
                    path=shipped_path,
                )
                log.debug("Restored shipped tracker: %s", tracker_id)
                return
            except Exception:
                log.exception(
                    "Failed to restore shipped definition for %s",
                    tracker_id,
                )

        del self._trackers[tracker_id]
        log.debug("Removed tracker: %s", tracker_id)

    async def update(self, tracker_id: TrackerID) -> TrackerInfo:
        """Re-download a tracker from its source URL.

        For shipped trackers, the updated definition is saved to the user
        directory, flipping the tracker from shipped to user-managed.

        Args:
            tracker_id: The tracker identifier.

        Returns:
            The updated TrackerInfo.

        Raises:
            TrackerNotFoundError: If the tracker does not exist.

        """
        self.get_info(tracker_id)

        metadata = self._load_metadata().get(tracker_id)
        source_url = (
            metadata.source_url
            if metadata
            else _JACKETT_BASE_URL.format(name=tracker_id)
        )

        filename = f"{tracker_id}.yml"
        dest = await self._downloader.download_one(source_url, filename)

        yaml_data = dest.read_text(encoding="utf-8")
        definition = definition_loader.from_yaml(YAML(yaml_data))

        self._save_metadata(
            tracker_id,
            source_url,
            added_at=metadata.added_at if metadata else None,
        )

        new_info = TrackerInfo(
            definition=definition,
            source="user",
            path=dest,
        )
        self._trackers[tracker_id] = new_info
        self._invalidate_caches(tracker_id)
        log.debug("Updated tracker: %s", tracker_id)
        return new_info

    def _invalidate_caches(self, tracker_id: TrackerID) -> None:
        """Clear cached fetcher and parser for a tracker."""
        self._fetchers.pop(tracker_id, None)
        self._parsers.pop(tracker_id, None)

    def user_trackers(self) -> list[TrackerID]:
        """Return IDs of all user-managed trackers."""
        return [tid for tid, info in self._trackers.items() if info.source == "user"]

    def _metadata_path(self) -> pathlib.Path:
        return self._user_definitions_dir / _METADATA_FILE

    def _load_metadata(self) -> dict[str, TrackerMetadata]:
        """Load the sidecar metadata file."""
        meta_path = self._metadata_path()
        if not meta_path.exists():
            return {}
        raw = yaml.safe_load(meta_path.read_text(encoding="utf-8")) or {}
        return {
            name: TrackerMetadata(
                source_url=entry["source_url"],
                added_at=entry["added_at"],
                updated_at=entry["updated_at"],
            )
            for name, entry in raw.items()
        }

    def _save_metadata(
        self,
        name: str,
        source_url: str,
        *,
        added_at: str | None = None,
    ) -> None:
        """Write or update a metadata entry for a tracker."""
        all_meta = self._load_metadata()
        now = dt.datetime.now(dt.timezone.utc).isoformat()
        existing = all_meta.get(name)
        all_meta[name] = TrackerMetadata(
            source_url=source_url,
            added_at=added_at or (existing.added_at if existing else now),
            updated_at=now,
        )
        self._write_metadata(all_meta)

    def _remove_metadata(self, name: str) -> None:
        """Remove a metadata entry for a tracker."""
        all_meta = self._load_metadata()
        all_meta.pop(name, None)
        self._write_metadata(all_meta)

    def _write_metadata(self, meta: dict[str, TrackerMetadata]) -> None:
        """Persist the full metadata dict to disk."""
        meta_path = self._metadata_path()
        pathutil.exists_or_create(meta_path)
        raw = {name: dataclasses.asdict(entry) for name, entry in meta.items()}
        meta_path.write_text(
            yaml.dump(raw, default_flow_style=False),
            encoding="utf-8",
        )
