"""DownloadService — request external downloads and import their results.

Two roles (plan §6.1):

* **Request side** (HTTP): validate + dedup a download request, create a
  ``queued`` job, and enqueue the worker. Dedup is on ``(source, source_id)``
  against both the library (already imported) and in-flight jobs (a double-click
  must not queue twice) — idempotency per CLAUDE.md.
* **Worker side**: ``store_result`` turns a backend's :class:`DownloadResult`
  into a managed file + minimal ``pending`` track (sibling of
  :class:`~app.application.import_service.LibraryImportService`); enrichment
  (§6.2) fills the rest.

The fingerprint-level dedup (a different id that turns out to be the same audio)
happens later in enrichment, where the fingerprint is computed.
"""

import contextlib
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass

import anyio

from app.core.logging import get_logger
from app.domain.entities.download import DownloadJob
from app.domain.errors import NotFoundError, ValidationError
from app.domain.ports import (
    ArtistRepository,
    DownloadJobRepository,
    FileStorage,
    TrackRepository,
)
from app.domain.sources import DownloadResult

log = get_logger(__name__)

_UNKNOWN_ARTIST = "Unknown Artist"

# (job_id) -> None — enqueue the download worker, deferred so the job row is
# committed before the worker reads it (same pattern as enrich).
DownloadEnqueuer = Callable[[uuid.UUID], Awaitable[None]]
EnrichEnqueuer = Callable[[uuid.UUID], Awaitable[None]]


@dataclass(frozen=True)
class DownloadRequest:
    """Outcome of asking for a download.

    Exactly one of the three states holds: the item is already in the library
    (``track_id`` set, ``already_in_library``), a job already covers it / was
    just created (``job`` set), so the UI can route to the download manager.
    """

    job: DownloadJob | None
    track_id: uuid.UUID | None
    already_in_library: bool


class DownloadService:
    def __init__(
        self,
        *,
        jobs: DownloadJobRepository,
        tracks: TrackRepository,
        artists: ArtistRepository,
        storage: FileStorage,
        enqueue_download: DownloadEnqueuer | None = None,
        enqueue_enrich: EnrichEnqueuer | None = None,
    ) -> None:
        self._jobs = jobs
        self._tracks = tracks
        self._artists = artists
        self._storage = storage
        self._enqueue_download = enqueue_download
        self._enqueue_enrich = enqueue_enrich

    # -- request side ---------------------------------------------------------
    async def request(
        self,
        *,
        source: str,
        source_id: str,
        query: str | None,
        requested_by: uuid.UUID | None,
    ) -> DownloadRequest:
        source_id = source_id.strip()
        if not source_id:
            raise ValidationError("A source_id is required to download.")

        existing = await self._tracks.get_by_source(source, source_id)
        if existing is not None:
            return DownloadRequest(job=None, track_id=existing.id, already_in_library=True)

        active = await self._jobs.get_active_for_source(source, source_id)
        if active is not None:
            return DownloadRequest(job=active, track_id=None, already_in_library=False)

        job = await self._jobs.add(
            source=source,
            source_id=source_id,
            query=query,
            requested_by=requested_by,
        )
        if self._enqueue_download is not None:
            await self._enqueue_download(job.id)
        return DownloadRequest(job=job, track_id=None, already_in_library=False)

    async def list(
        self,
        *,
        requested_by: uuid.UUID | None,
        status: str | None,
        limit: int,
        offset: int,
    ) -> tuple[list[DownloadJob], int]:
        jobs = await self._jobs.list(
            requested_by=requested_by, status=status, limit=limit, offset=offset
        )
        total = await self._jobs.count(requested_by=requested_by, status=status)
        return jobs, total

    async def get(self, job_id: uuid.UUID) -> DownloadJob:
        job = await self._jobs.get_by_id(job_id)
        if job is None:
            raise NotFoundError(f"Download job {job_id} not found.")
        return job

    async def cancel(self, job_id: uuid.UUID) -> None:
        """Remove the job record. True mid-flight cancellation of an in-progress
        yt-dlp download is out of scope (MVP); the worker tolerates a vanished
        job row (its status writes become no-ops)."""
        job = await self._jobs.get_by_id(job_id)
        if job is None:
            raise NotFoundError(f"Download job {job_id} not found.")
        await self._jobs.delete(job_id)

    async def retry(self, job_id: uuid.UUID) -> DownloadJob:
        job = await self.get(job_id)
        await self._jobs.set_status(job_id, status="queued", error_message=None)
        if self._enqueue_download is not None:
            await self._enqueue_download(job_id)
        refreshed = await self._jobs.get_by_id(job_id)
        return refreshed if refreshed is not None else job

    # -- worker side ----------------------------------------------------------
    async def store_result(
        self,
        *,
        source: str,
        result: DownloadResult,
        requested_by: uuid.UUID | None,
    ) -> uuid.UUID:
        """Store a freshly downloaded file and create a minimal ``pending`` track.

        Returns the new track id (the caller enqueues enrichment after commit).
        The temp file produced by the backend is always removed."""
        track_id = uuid.uuid4()
        key = f"tracks/{str(track_id)[:2]}/{track_id}.{result.file_format}"
        try:
            await self._storage.save_file(key, result.path)
            try:
                artist = await self._artists.get_or_create(_UNKNOWN_ARTIST)
                await self._tracks.add(
                    id=track_id,
                    title=result.suggested_title,
                    artist_id=artist.id,
                    storage_uri=key,
                    file_format=result.file_format,
                    file_size=result.file_size,
                    source=source,
                    source_id=result.source_id,
                    metadata_status="pending",
                    added_by=requested_by,
                )
            except Exception:
                with contextlib.suppress(Exception):
                    await self._storage.delete(key)
                raise
        finally:
            with contextlib.suppress(Exception):
                await anyio.Path(result.path).unlink(missing_ok=True)
        return track_id
