"""Ports — the contracts the application layer depends on.

These are Protocols, not implementations. Concrete adapters live in
``app.infrastructure`` (repositories) and ``app.core.security`` (crypto) and
are bound to these ports at the composition root (``app.api.deps``).
"""

import datetime as dt
import uuid
from collections.abc import AsyncIterator, Iterator
from contextlib import AbstractAsyncContextManager
from pathlib import Path
from typing import Protocol

from app.domain.entities import (
    Album,
    AudioTags,
    CoverArt,
    Credentials,
    Fingerprint,
    Like,
    ObjectStat,
    PlayHistoryEntry,
    Playlist,
    RecordingMatch,
    SubsonicCredentials,
    User,
)
from app.domain.entities.track import Artist, Track
from app.domain.sources import SourceFile, SourceInfo
from app.domain.tokens import IssuedToken, TokenClaims, TokenType


class UserRepository(Protocol):
    async def get_by_id(self, user_id: uuid.UUID) -> User | None: ...
    async def get_credentials_by_username(self, username: str) -> Credentials | None: ...
    async def add(self, *, username: str, password_hash: str, is_superuser: bool) -> User: ...
    async def list(self, *, limit: int, offset: int) -> list[User]: ...
    async def set_password_hash(self, user_id: uuid.UUID, password_hash: str) -> None: ...
    async def set_superuser(self, user_id: uuid.UUID, is_superuser: bool) -> User: ...
    async def set_active(self, user_id: uuid.UUID, is_active: bool) -> User: ...
    async def count(self) -> int: ...
    # -- subsonic app-password (recoverable, encrypted at rest) ----------
    async def get_subsonic_credentials_by_username(
        self, username: str
    ) -> SubsonicCredentials | None: ...
    async def get_subsonic_password_enc(self, user_id: uuid.UUID) -> str | None: ...
    async def set_subsonic_password_enc(self, user_id: uuid.UUID, password_enc: str) -> None: ...


class SubsonicCipher(Protocol):
    """Symmetric encrypt/decrypt for the recoverable Subsonic app-password."""

    def encrypt(self, plaintext: str) -> str: ...
    def decrypt(self, token: str) -> str: ...


class RefreshTokenRepository(Protocol):
    async def add(
        self,
        *,
        jti: uuid.UUID,
        user_id: uuid.UUID,
        token_hash: str,
        expires_at: dt.datetime,
    ) -> None: ...
    async def is_valid(self, jti: uuid.UUID) -> bool:
        """True iff a row exists for ``jti`` that is neither revoked nor expired."""
        ...

    async def revoke(self, jti: uuid.UUID) -> None: ...
    async def revoke_all_for_user(self, user_id: uuid.UUID) -> None: ...


class PasswordHasher(Protocol):
    def hash(self, password: str) -> str: ...
    def verify_and_update(self, password: str, password_hash: str) -> tuple[bool, str | None]:
        """Verify ``password`` against ``password_hash``. Returns
        ``(is_valid, updated_hash)`` where ``updated_hash`` is a fresh hash to
        persist when the stored one uses outdated parameters, else ``None``."""
        ...


class TokenService(Protocol):
    def issue(self, *, subject: uuid.UUID, token_type: TokenType) -> IssuedToken: ...
    def decode(self, encoded: str) -> TokenClaims:
        """Verify signature + expiry and return claims. Raises
        :class:`~app.domain.errors.AuthenticationError` on any failure."""
        ...


class FileStorage(Protocol):
    async def save_file(self, key: str, src_path: Path) -> int: ...
    async def open_range(
        self, key: str, start: int, end: int | None
    ) -> tuple[AsyncIterator[bytes], int]: ...
    async def stat(self, key: str) -> ObjectStat: ...
    async def exists(self, key: str) -> bool: ...
    async def delete(self, key: str) -> None: ...
    def as_local_path(self, key: str) -> AbstractAsyncContextManager[Path]: ...


class ArtistRepository(Protocol):
    async def get_or_create(self, name: str) -> Artist: ...
    async def get_by_id(self, artist_id: uuid.UUID) -> Artist | None: ...
    async def get_many(self, ids: list[uuid.UUID]) -> list[Artist]: ...
    async def list(self, *, q: str | None, limit: int, offset: int) -> list[Artist]: ...
    async def count(self, *, q: str | None) -> int: ...
    async def album_count(self, artist_id: uuid.UUID) -> int: ...
    async def track_count(self, artist_id: uuid.UUID) -> int: ...


class TrackRepository(Protocol):
    async def get_by_id(self, track_id: uuid.UUID) -> Track | None: ...
    async def get_by_source(self, source: str, source_id: str) -> Track | None: ...
    async def add(
        self,
        *,
        id: uuid.UUID,
        title: str,
        artist_id: uuid.UUID,
        storage_uri: str,
        file_format: str,
        file_size: int,
        source: str,
        source_id: str,
        metadata_status: str,
        added_by: uuid.UUID | None,
    ) -> Track: ...
    async def delete(self, track_id: uuid.UUID) -> None: ...
    # genres must come before ``list`` — the method named ``list`` shadows the
    # builtin in later annotations (same pattern as AlbumRepository below).
    async def genres(self) -> list[tuple[str, int]]: ...
    async def list(
        self,
        *,
        artist_id: uuid.UUID | None,
        album_id: uuid.UUID | None,
        q: str | None,
        sort_by: str,
        order: str,
        limit: int,
        offset: int,
    ) -> list[Track]: ...
    async def count(
        self,
        *,
        artist_id: uuid.UUID | None,
        album_id: uuid.UUID | None,
        q: str | None,
    ) -> int: ...
    async def update(
        self,
        track_id: uuid.UUID,
        *,
        title: str | None,
        genre: str | None,
        year: int | None,
    ) -> Track: ...
    async def apply_enrichment(
        self,
        track_id: uuid.UUID,
        *,
        title: str,
        artist_id: uuid.UUID,
        album_id: uuid.UUID | None,
        genre: str | None,
        year: int | None,
        track_number: int | None,
        duration_seconds: int | None,
        bitrate: int | None,
        acoustid_fingerprint: str | None,
        musicbrainz_id: str | None,
        metadata_status: str,
        metadata_error: str | None = None,
    ) -> Track:
        """Persist auto-enrichment results. Nullable fields are filled only when
        a non-``None`` value is supplied (re-enrich never erases prior data);
        ``title``/``artist_id``/``metadata_status`` are always written, and the
        run's outcome (``metadata_error`` + completion time) is always stamped.
        Callers must not invoke this for ``metadata_status == 'manual'`` tracks."""
        ...

    async def mark_enrichment_failed(self, track_id: uuid.UUID, *, error: str) -> None:
        """Record that an enrichment run crashed unexpectedly: set ``failed`` +
        the error reason. A no-op for ``manual`` or missing tracks."""
        ...


class AlbumRepository(Protocol):
    async def get_or_create(
        self,
        *,
        title: str,
        artist_id: uuid.UUID,
        year: int | None,
        musicbrainz_id: str | None,
    ) -> Album: ...
    async def set_cover_path(self, album_id: uuid.UUID, cover_path: str) -> None: ...
    async def get_by_id(self, album_id: uuid.UUID) -> Album | None: ...
    async def get_many(self, ids: list[uuid.UUID]) -> list[Album]: ...
    async def count(self, *, artist_id: uuid.UUID | None, q: str | None) -> int: ...
    async def track_count(self, album_id: uuid.UUID) -> int: ...
    async def track_count_many(self, album_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: ...
    # list must come after any method using list[...] in its signature (name shadowing)
    async def list(
        self,
        *,
        artist_id: uuid.UUID | None,
        q: str | None,
        limit: int,
        offset: int,
        sort_by: str = "title",
        order: str = "asc",
    ) -> list[Album]: ...


class PlaylistRepository(Protocol):
    async def get_by_id(self, playlist_id: uuid.UUID) -> Playlist | None: ...
    async def count(self, *, owner_id: uuid.UUID) -> int: ...
    async def add(self, *, name: str, description: str | None, owner_id: uuid.UUID) -> Playlist: ...
    async def update(
        self, playlist_id: uuid.UUID, *, name: str | None, description: str | None
    ) -> Playlist: ...
    async def delete(self, playlist_id: uuid.UUID) -> None: ...
    async def track_count(self, playlist_id: uuid.UUID) -> int: ...
    async def track_count_many(self, playlist_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: ...
    async def get_tracks(
        self, playlist_id: uuid.UUID, *, limit: int, offset: int
    ) -> list[Track]: ...
    async def get_track_total(self, playlist_id: uuid.UUID) -> int: ...
    async def add_track(
        self, playlist_id: uuid.UUID, track_id: uuid.UUID, *, position: float
    ) -> None: ...
    async def remove_track(self, playlist_id: uuid.UUID, track_id: uuid.UUID) -> None: ...
    async def max_position(self, playlist_id: uuid.UUID) -> float: ...
    # list must come after any method using list[...] in its signature (name shadowing)
    async def list(self, *, owner_id: uuid.UUID, limit: int, offset: int) -> list[Playlist]: ...


class LikeRepository(Protocol):
    async def add(self, *, user_id: uuid.UUID, track_id: uuid.UUID, value: str) -> Like: ...
    async def get_latest_state(
        self, *, user_id: uuid.UUID, track_ids: list[uuid.UUID]
    ) -> list[Like]: ...
    async def list_liked_tracks(
        self, *, user_id: uuid.UUID, limit: int, offset: int
    ) -> list[Track]: ...
    async def count_liked_tracks(self, *, user_id: uuid.UUID) -> int: ...


class HistoryRepository(Protocol):
    async def add(
        self,
        *,
        user_id: uuid.UUID,
        track_id: uuid.UUID,
        played_at: dt.datetime,
        play_duration_seconds: int | None,
        completed: bool,
    ) -> PlayHistoryEntry: ...
    async def list(
        self, *, user_id: uuid.UUID, limit: int, offset: int
    ) -> list[PlayHistoryEntry]: ...
    async def count(self, *, user_id: uuid.UUID) -> int: ...


class SourceBackend(Protocol):
    """A registered source of tracks (mounted folder, YouTube, …).

    ``name`` is the stable identifier used in URLs and stored on ``track.source``.
    """

    name: str

    def info(self) -> SourceInfo: ...
    def is_available(self) -> bool: ...


class IndexableSource(SourceBackend, Protocol):
    """A source that enumerates files already on disk (e.g. the local folder)."""

    def scan(self) -> Iterator[SourceFile]: ...


# -- metadata enrichment (plan §6.2) -----------------------------------------
class AudioTagReader(Protocol):
    """Reads embedded tags from a local audio file. Returns ``None`` only when
    the file can't be parsed at all — never raises (graceful degradation)."""

    async def read(self, path: Path) -> AudioTags | None: ...


class AudioFingerprinter(Protocol):
    """Chromaprint (fpcalc) wrapper. ``is_available`` reflects whether the
    binary is present; ``calculate`` returns ``None`` on any failure."""

    def is_available(self) -> bool: ...
    async def calculate(self, path: Path) -> Fingerprint | None: ...


class AcoustIdClient(Protocol):
    """AcoustID lookup. ``is_available`` is False without an API key (the whole
    fingerprint path is then skipped). ``lookup`` returns the best match or
    ``None`` (no result / service down), never raising. ``lookup_all`` returns
    the same candidates ranked by confidence (``[]`` on no result / unavailable
    / error), for the metadata editor's match picker."""

    def is_available(self) -> bool: ...
    async def lookup(self, fingerprint: Fingerprint) -> RecordingMatch | None: ...
    async def lookup_all(self, fingerprint: Fingerprint) -> list[RecordingMatch]: ...


class CoverArtExtractor(Protocol):
    """Pulls embedded cover art out of a local audio file (offline, no network).
    Returns ``None`` when the file has no picture or can't be parsed — never raises."""

    async def extract(self, path: Path) -> CoverArt | None: ...


class CoverArtProvider(Protocol):
    """Fetches cover art from an external service (Cover Art Archive) by
    MusicBrainz release-group id. ``is_available`` may gate it off; ``fetch``
    returns ``None`` (not found / service down), never raising."""

    def is_available(self) -> bool: ...
    async def fetch_release_group(self, release_group_mbid: str) -> CoverArt | None: ...
