"""Resolve proposed tracks with bounded concurrency and conservative matching."""

import asyncio
import time
from collections import OrderedDict
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum

import httpx2
import structlog

from app.adapters.spotify.errors import SpotifyError
from app.config import Settings
from app.domain.matching import candidate_key, judge_candidate_match, track_key
from app.domain.models import Familiarity, Track, TrackCandidate
from app.observability.timing import increment_cache_hits
from app.ports.protocols import CatalogQuotaExhaustedError, MusicCatalog


class ResolutionStatus(StrEnum):
    """Terminal outcome of one candidate resolution attempt."""

    RESOLVED = "resolved"
    MISS = "miss"
    MISMATCH = "mismatch"
    FAILED = "failed"
    QUOTA = "quota"


@dataclass(frozen=True)
class GroundingMetrics:
    """Separate resolver outcomes for operational visibility."""

    attempted_count: int
    miss_count: int
    mismatch_guard_count: int
    failed_count: int
    cache_hit_count: int
    did_reach_deadline: bool
    did_exhaust_quota: bool

    @property
    def miss_rate(self) -> float:
        """Return the share of attempted candidates with no search result."""
        return self.miss_count / self.attempted_count if self.attempted_count else 0.0

    @property
    def mismatch_guard_rate(self) -> float:
        """Return the share rejected by client-side identity checks."""
        return self.mismatch_guard_count / self.attempted_count if self.attempted_count else 0.0


@dataclass(frozen=True)
class GroundingResult:
    """Resolved pool and its operational metrics."""

    tracks: tuple[Track, ...]
    metrics: GroundingMetrics


@dataclass(frozen=True)
class _ResolutionAttempt:
    index: int
    status: ResolutionStatus
    track: Track | None = None
    is_cache_hit: bool = False


class ResolutionCache:
    """Bound successful name resolutions by age and least-recent use."""

    def __init__(
        self,
        ttl_seconds: float,
        max_entries: int,
        clock: Callable[[], float] = time.monotonic,
    ) -> None:
        """Create an empty successful-resolution cache."""
        self.ttl_seconds = ttl_seconds
        self.max_entries = max_entries
        self.clock = clock
        self._entries: OrderedDict[str, tuple[float, Track]] = OrderedDict()

    def get(self, key: str) -> Track | None:
        """Return a fresh cached track and refresh its recency."""
        entry = self._entries.get(key)
        if entry is None:
            return None
        created_at, track = entry
        if self.clock() - created_at >= self.ttl_seconds:
            del self._entries[key]
            return None
        self._entries.move_to_end(key)
        increment_cache_hits()
        return track

    def put(self, key: str, track: Track) -> None:
        """Cache a successful unambiguous resolution."""
        if self.max_entries <= 0 or self.ttl_seconds <= 0:
            return
        self._entries[key] = (self.clock(), track)
        self._entries.move_to_end(key)
        while len(self._entries) > self.max_entries:
            self._entries.popitem(last=False)


class Grounder:
    """Build a safe Spotify pool with early stop and bounded fan-out."""

    def __init__(self, settings: Settings, cache: ResolutionCache | None = None) -> None:
        """Bind resolver settings and a process-local resolution cache."""
        self.settings = settings
        self.cache = cache or ResolutionCache(
            settings.resolution_cache_ttl_seconds,
            settings.resolution_cache_max_entries,
        )
        self._semaphore = asyncio.Semaphore(settings.grounding_concurrency)

    async def ground(
        self,
        catalog: MusicCatalog,
        candidates: tuple[TrackCandidate, ...],
        known_track_ids: frozenset[str],
        familiarity: Familiarity,
        pool_target: int,
        deadline_at: float,
    ) -> GroundingResult:
        """Resolve candidates until the target, deadline, or quota boundary."""
        accepted: dict[int, Track] = {}
        seen_ids: set[str] = set()
        seen_keys: set[str] = set()
        metrics = _MutableMetrics()
        pending: dict[asyncio.Task[_ResolutionAttempt], int] = {}
        next_index = 0
        try:
            while next_index < len(candidates) or pending:
                if len(accepted) >= pool_target:
                    break
                if time.monotonic() >= deadline_at:
                    metrics.did_reach_deadline = True
                    break
                next_index = self._launch_tasks(
                    catalog,
                    candidates,
                    pending,
                    next_index,
                )
                if not pending:
                    break
                remaining_seconds = deadline_at - time.monotonic()
                if remaining_seconds <= 0:
                    metrics.did_reach_deadline = True
                    break
                done, _ = await asyncio.wait(
                    pending,
                    timeout=remaining_seconds,
                    return_when=asyncio.FIRST_COMPLETED,
                )
                if not done:
                    metrics.did_reach_deadline = True
                    break
                self._collect_done(
                    done,
                    pending,
                    accepted,
                    seen_ids,
                    seen_keys,
                    known_track_ids,
                    familiarity,
                    pool_target,
                    metrics,
                )
                if metrics.did_exhaust_quota:
                    break
        finally:
            await _cancel_tasks(tuple(pending))

        tracks = tuple(track for _, track in sorted(accepted.items()))
        if familiarity is Familiarity.FAMILIAR:
            tracks = tuple(sorted(tracks, key=lambda track: track.id not in known_track_ids))
        result = GroundingResult(tracks=tracks, metrics=metrics.freeze())
        _log_grounding(result)
        return result

    def _launch_tasks(
        self,
        catalog: MusicCatalog,
        candidates: tuple[TrackCandidate, ...],
        pending: dict[asyncio.Task[_ResolutionAttempt], int],
        next_index: int,
    ) -> int:
        while next_index < len(candidates) and len(pending) < self.settings.grounding_concurrency:
            task = asyncio.create_task(self._resolve(catalog, next_index, candidates[next_index]))
            pending[task] = next_index
            next_index += 1
        return next_index

    def _collect_done(
        self,
        done: set[asyncio.Task[_ResolutionAttempt]],
        pending: dict[asyncio.Task[_ResolutionAttempt], int],
        accepted: dict[int, Track],
        seen_ids: set[str],
        seen_keys: set[str],
        known_track_ids: frozenset[str],
        familiarity: Familiarity,
        pool_target: int,
        metrics: "_MutableMetrics",
    ) -> None:
        for task in sorted(done, key=pending.__getitem__):
            del pending[task]
            attempt = task.result()
            metrics.record(attempt)
            if attempt.status is ResolutionStatus.QUOTA:
                continue
            track = attempt.track
            if track is None or len(accepted) >= pool_target:
                continue
            if familiarity is Familiarity.NEW and track.id in known_track_ids:
                continue
            normalized_key = track_key(track)
            if track.id in seen_ids or normalized_key in seen_keys:
                continue
            seen_ids.add(track.id)
            seen_keys.add(normalized_key)
            accepted[attempt.index] = track

    async def _resolve(
        self,
        catalog: MusicCatalog,
        index: int,
        candidate: TrackCandidate,
    ) -> _ResolutionAttempt:
        async with self._semaphore:
            return await self._resolve_with_slot(catalog, index, candidate)

    async def _resolve_with_slot(
        self,
        catalog: MusicCatalog,
        index: int,
        candidate: TrackCandidate,
    ) -> _ResolutionAttempt:
        key = candidate_key(candidate)
        cached_track = self.cache.get(key)
        if cached_track is not None:
            return _ResolutionAttempt(
                index=index,
                status=ResolutionStatus.RESOLVED,
                track=cached_track,
                is_cache_hit=True,
            )

        try:
            field_results = await catalog.search_tracks(_field_query(candidate))
            matched_track = _best_match(
                candidate, field_results, self.settings.title_similarity_threshold
            )
            if matched_track is None:
                bare_results = await catalog.search_tracks(f"{candidate.title} {candidate.artist}")
                matched_track = _best_match(
                    candidate,
                    bare_results,
                    self.settings.title_similarity_threshold,
                )
            else:
                bare_results = []
        except CatalogQuotaExhaustedError:
            return _ResolutionAttempt(index=index, status=ResolutionStatus.QUOTA)
        except (SpotifyError, httpx2.HTTPError) as error:
            structlog.get_logger().info(
                "candidate_unresolved",
                title=candidate.title,
                artist=candidate.artist,
                status=ResolutionStatus.FAILED,
                error_type=type(error).__name__,
            )
            return _ResolutionAttempt(index=index, status=ResolutionStatus.FAILED)

        if matched_track is not None:
            self.cache.put(key, matched_track)
            return _ResolutionAttempt(
                index=index, status=ResolutionStatus.RESOLVED, track=matched_track
            )
        status = (
            ResolutionStatus.MISMATCH if field_results or bare_results else ResolutionStatus.MISS
        )
        structlog.get_logger().info(
            "candidate_unresolved",
            title=candidate.title,
            artist=candidate.artist,
            status=status,
            result_count=len(field_results) + len(bare_results),
        )
        return _ResolutionAttempt(index=index, status=status)


@dataclass
class _MutableMetrics:
    attempted_count: int = 0
    miss_count: int = 0
    mismatch_guard_count: int = 0
    failed_count: int = 0
    cache_hit_count: int = 0
    did_reach_deadline: bool = False
    did_exhaust_quota: bool = False

    def record(self, attempt: _ResolutionAttempt) -> None:
        self.attempted_count += 1
        self.miss_count += attempt.status is ResolutionStatus.MISS
        self.mismatch_guard_count += attempt.status is ResolutionStatus.MISMATCH
        self.failed_count += attempt.status is ResolutionStatus.FAILED
        self.cache_hit_count += attempt.is_cache_hit
        self.did_exhaust_quota = self.did_exhaust_quota or attempt.status is ResolutionStatus.QUOTA

    def freeze(self) -> GroundingMetrics:
        return GroundingMetrics(
            attempted_count=self.attempted_count,
            miss_count=self.miss_count,
            mismatch_guard_count=self.mismatch_guard_count,
            failed_count=self.failed_count,
            cache_hit_count=self.cache_hit_count,
            did_reach_deadline=self.did_reach_deadline,
            did_exhaust_quota=self.did_exhaust_quota,
        )


def _best_match(candidate: TrackCandidate, tracks: list[Track], threshold: float) -> Track | None:
    verdicts = ((judge_candidate_match(candidate, track, threshold), track) for track in tracks)
    accepted = [
        (verdict.title_similarity, track) for verdict, track in verdicts if verdict.is_match
    ]
    return max(accepted, key=lambda item: item[0])[1] if accepted else None


def _field_query(candidate: TrackCandidate) -> str:
    title = candidate.title.replace('"', " ")
    artist = candidate.artist.replace('"', " ")
    return f'track:"{title}" artist:"{artist}"'


async def _cancel_tasks(tasks: tuple[asyncio.Task[_ResolutionAttempt], ...]) -> None:
    for task in tasks:
        task.cancel()
    if tasks:
        await asyncio.gather(*tasks, return_exceptions=True)


def _log_grounding(result: GroundingResult) -> None:
    metrics = result.metrics
    structlog.get_logger().info(
        "grounding_complete",
        track_count=len(result.tracks),
        attempted_count=metrics.attempted_count,
        miss_rate=metrics.miss_rate,
        mismatch_guard_rate=metrics.mismatch_guard_rate,
        failed_count=metrics.failed_count,
        cache_hits=metrics.cache_hit_count,
        deadline_reached=metrics.did_reach_deadline,
        quota_exhausted=metrics.did_exhaust_quota,
    )
