"""Unit tests for RemoteLibraryService — DB-free, in-memory fakes (plan: Model C
remote browse + lazy materialization)."""

import datetime as dt
import uuid

import pytest
from app.application.remote_library_service import RemoteLibraryService
from app.domain.entities import Artist, DownloadJob, Track
from app.domain.errors import NotFoundError, ValidationError

pytestmark = pytest.mark.asyncio


class FakeArtistRepo:
    def __init__(self) -> None:
        self._by_name: dict[str, Artist] = {}

    async def get_or_create(self, name: str) -> Artist:
        if name not in self._by_name:
            now = dt.datetime.now(dt.UTC)
            self._by_name[name] = Artist(
                id=uuid.uuid4(),
                name=name,
                source=None,
                source_id=None,
                created_at=now,
                updated_at=now,
            )
        return self._by_name[name]


class FakeTrackRepo:
    def __init__(self) -> None:
        self.by_id: dict[uuid.UUID, Track] = {}
        self.by_source: dict[tuple[str, str], Track] = {}

    async def get_by_id(self, track_id: uuid.UUID) -> Track | None:
        return self.by_id.get(track_id)

    async def get_by_source(self, source: str, source_id: str) -> Track | None:
        return self.by_source.get((source, source_id))

    async def add(self, **kw: object) -> Track:
        now = dt.datetime.now(dt.UTC)
        track = Track(
            id=kw["id"],  # type: ignore[arg-type]
            title=str(kw["title"]),
            artist_id=kw["artist_id"],  # type: ignore[arg-type]
            album_id=None,
            storage_uri=kw["storage_uri"],  # type: ignore[arg-type]
            file_format=kw["file_format"],  # type: ignore[arg-type]
            file_size=kw["file_size"],  # type: ignore[arg-type]
            source=str(kw["source"]),
            source_id=str(kw["source_id"]),
            duration_seconds=None,
            genre=None,
            year=None,
            track_number=None,
            metadata_status=str(kw["metadata_status"]),
            metadata_error=None,
            enriched_at=None,
            availability=str(kw["availability"]),
            created_at=now,
            updated_at=now,
        )
        self.by_id[track.id] = track
        self.by_source[(track.source, track.source_id)] = track
        return track


def _local_track(source: str = "youtube", source_id: str = "local-1") -> Track:
    now = dt.datetime.now(dt.UTC)
    return Track(
        id=uuid.uuid4(),
        title="Already Here",
        artist_id=uuid.uuid4(),
        album_id=None,
        storage_uri="tracks/aa/aa.m4a",
        file_format="m4a",
        file_size=123,
        source=source,
        source_id=source_id,
        duration_seconds=None,
        genre=None,
        year=None,
        track_number=None,
        metadata_status="pending",
        metadata_error=None,
        enriched_at=None,
        availability="local",
        created_at=now,
        updated_at=now,
    )


class FakeJobRepo:
    def __init__(self) -> None:
        self.jobs: dict[uuid.UUID, DownloadJob] = {}
        self.active: dict[tuple[str, str], DownloadJob] = {}

    async def add(self, **kw: object) -> DownloadJob:
        now = dt.datetime.now(dt.UTC)
        job = DownloadJob(
            id=uuid.uuid4(),
            source=str(kw["source"]),
            source_id=kw.get("source_id"),  # type: ignore[arg-type]
            query=kw.get("query"),  # type: ignore[arg-type]
            requested_by=kw.get("requested_by"),  # type: ignore[arg-type]
            status="queued",
            progress=0.0,
            error_message=None,
            retry_count=0,
            track_id=None,
            created_at=now,
            updated_at=now,
        )
        self.jobs[job.id] = job
        return job

    async def get_by_id(self, job_id: uuid.UUID) -> DownloadJob | None:
        return self.jobs.get(job_id)

    async def get_active_for_source(self, source: str, source_id: str) -> DownloadJob | None:
        return self.active.get((source, source_id))

    async def set_status(self, job_id: uuid.UUID, **kw: object) -> None:
        job = self.jobs[job_id]
        track_id = kw.get("track_id")
        if track_id is not None:
            self.jobs[job_id] = DownloadJob(
                id=job.id,
                source=job.source,
                source_id=job.source_id,
                query=job.query,
                requested_by=job.requested_by,
                status=str(kw.get("status", job.status)),
                progress=job.progress,
                error_message=job.error_message,
                retry_count=job.retry_count,
                track_id=track_id,  # type: ignore[arg-type]
                created_at=job.created_at,
                updated_at=job.updated_at,
            )


def _service(
    *,
    tracks: FakeTrackRepo,
    artists: FakeArtistRepo,
    jobs: FakeJobRepo,
    enqueued: list[uuid.UUID],
) -> RemoteLibraryService:
    async def enqueue_materialize(job_id: uuid.UUID) -> None:
        enqueued.append(job_id)

    return RemoteLibraryService(
        tracks=tracks,  # type: ignore[arg-type]
        artists=artists,  # type: ignore[arg-type]
        jobs=jobs,  # type: ignore[arg-type]
        enqueue_materialize=enqueue_materialize,
    )


async def test_save_remote_creates_placeholder() -> None:
    tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), []
    service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq)

    track = await service.save_remote(
        source="youtube", source_id="abc", title="Bohemian Rhapsody", artist="Queen", added_by=None
    )

    assert track.availability == "remote"
    assert track.storage_uri is None
    assert track.file_format is None
    assert track.source == "youtube"
    assert track.source_id == "abc"


async def test_save_remote_is_idempotent() -> None:
    tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), []
    service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq)

    first = await service.save_remote(
        source="youtube", source_id="abc", title="A", artist="Queen", added_by=None
    )
    second = await service.save_remote(
        source="youtube", source_id="abc", title="B", artist="Other", added_by=None
    )

    assert first.id == second.id
    assert second.title == "A"  # untouched by the second call


async def test_materialize_already_local_is_noop() -> None:
    tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), []
    track = _local_track()
    tracks.by_id[track.id] = track
    service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq)

    outcome = await service.request_materialize(track.id, requested_by=None)

    assert outcome.job is None
    assert outcome.track.id == track.id
    assert enq == []


async def test_materialize_remote_creates_and_enqueues_job() -> None:
    tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), []
    service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq)
    track = await service.save_remote(
        source="youtube", source_id="abc", title="A", artist="Queen", added_by=None
    )

    outcome = await service.request_materialize(track.id, requested_by=None)

    assert outcome.job is not None
    assert outcome.job.source == "youtube"
    assert outcome.job.source_id == "abc"
    assert outcome.job.track_id == track.id
    assert enq == [outcome.job.id]


async def test_materialize_reuses_active_job() -> None:
    tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), []
    service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq)
    track = await service.save_remote(
        source="youtube", source_id="abc", title="A", artist="Queen", added_by=None
    )
    existing = await jobs.add(source="youtube", source_id="abc", query=None, requested_by=None)
    jobs.active[("youtube", "abc")] = existing

    outcome = await service.request_materialize(track.id, requested_by=None)

    assert outcome.job is not None
    assert outcome.job.id == existing.id
    assert enq == []  # not re-enqueued


async def test_materialize_missing_track_raises() -> None:
    tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), []
    service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq)

    with pytest.raises(NotFoundError):
        await service.request_materialize(uuid.uuid4(), requested_by=None)


async def test_save_remote_requires_source_id() -> None:
    tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), []
    service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq)

    with pytest.raises(ValidationError):
        await service.save_remote(
            source="youtube", source_id="  ", title="A", artist=None, added_by=None
        )
