"""Tests for core/discovery/quality_scanner.py — library quality scanner."""

from __future__ import annotations

import threading
from dataclasses import dataclass

import pytest

from core.discovery import quality_scanner as qs


# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------

@dataclass
class _FakeSpotifyTrack:
    id: str = 'spt-1'
    name: str = 'Found'
    artists: list = None
    album: str = 'Found Album'
    duration_ms: int = 200000
    popularity: int = 50
    preview_url: str = ''
    external_urls: dict = None
    album_type: str = 'album'
    release_date: str = '2024-01-01'

    def __post_init__(self):
        if self.artists is None:
            self.artists = ['Found Artist']
        if self.external_urls is None:
            self.external_urls = {}


class _FakeMetadataClient:
    def __init__(self, results=None):
        self._results = results if results is not None else []
        self.search_calls = []

    def search_tracks(self, query, limit=5, allow_fallback=True):
        self.search_calls.append((query, limit, allow_fallback))
        return self._results


_TEST_PRIMARY_SOURCE = 'spotify'
_TEST_SOURCE_CLIENTS = {}


@pytest.fixture(autouse=True)
def _patch_source_resolution(monkeypatch):
    monkeypatch.setattr(qs, 'get_primary_source', lambda: _TEST_PRIMARY_SOURCE)
    monkeypatch.setattr(qs, 'get_client_for_source', lambda source, **_kwargs: _TEST_SOURCE_CLIENTS.get(source))
    monkeypatch.setattr(qs.time, 'sleep', lambda *_args, **_kwargs: None)
    yield
    _TEST_SOURCE_CLIENTS.clear()
    globals()['_TEST_PRIMARY_SOURCE'] = 'spotify'


class _FakeMatchingEngine:
    def generate_download_queries(self, t):
        return [f"{t.artists[0]} {t.name}"]

    def normalize_string(self, s):
        return (s or '').lower().strip()

    def similarity_score(self, a, b):
        if a == b:
            return 1.0
        if not a or not b:
            return 0.0
        return 0.95 if a in b or b in a else 0.0


class _MultiQueryMatchingEngine(_FakeMatchingEngine):
    def generate_download_queries(self, t):
        return [
            f"{t.artists[0]} {t.name} first",
            f"{t.artists[0]} {t.name} second",
        ]


class _FakeAutomationEngine:
    def __init__(self):
        self.events = []

    def emit(self, event_type, data):
        self.events.append((event_type, data))


class _FakeWishlistService:
    def __init__(self):
        self.added = []

    def add_spotify_track_to_wishlist(self, **kwargs):
        self.added.append(kwargs)
        return True


class _FakeMusicDB:
    def __init__(self, watchlist_artists=None, tracks=None, profile=None):
        self._watchlist_artists = watchlist_artists if watchlist_artists is not None else []
        self._tracks = tracks if tracks is not None else []
        self._profile = profile or {'qualities': {'flac': {'enabled': True}}}

    def get_quality_profile(self):
        return self._profile

    def get_watchlist_artists(self, profile_id=1):
        return self._watchlist_artists

    def _get_connection(self):
        rows = self._tracks
        return _FakeConn(rows)


class _FakeConn:
    def __init__(self, rows):
        self._rows = rows

    def execute(self, query, params=None):
        return _FakeCursor(self._rows)

    def close(self):
        pass


class _FakeCursor:
    def __init__(self, rows):
        self._rows = rows

    def fetchall(self):
        return self._rows


@dataclass
class _WatchlistArtist:
    artist_name: str


def _build_deps(
    *,
    state=None,
    source_clients=None,
    primary_source='spotify',
    quality_tier_result=('lossless', 1),
    automation=None,
):
    globals()['_TEST_PRIMARY_SOURCE'] = primary_source
    _TEST_SOURCE_CLIENTS.clear()
    if source_clients is not None:
        _TEST_SOURCE_CLIENTS.update(source_clients)
    elif primary_source:
        _TEST_SOURCE_CLIENTS[primary_source] = _FakeMetadataClient(results=[])

    deps = qs.QualityScannerDeps(
        quality_scanner_state=state if state is not None else {},
        quality_scanner_lock=threading.Lock(),
        QUALITY_TIERS={'lossless': {'tier': 1}, 'low_lossy': {'tier': 4}, 'lossy': {'tier': 3}},
        matching_engine=_FakeMatchingEngine(),
        automation_engine=automation or _FakeAutomationEngine(),
        get_quality_tier_from_extension=lambda fp: quality_tier_result,
        add_activity_item=lambda *a, **kw: None,
    )
    return deps


def _track_row(track_id=1, title='Track', artist_id=1, album_id=1,
               file_path='/x.mp3', bitrate=128, artist_name='Artist',
               album_title='Album'):
    return (track_id, title, artist_id, album_id, file_path, bitrate, artist_name, album_title)


@pytest.fixture
def mock_db_and_wishlist(monkeypatch):
    """Patches MusicDatabase and get_wishlist_service used inside the worker."""
    db = _FakeMusicDB()
    ws = _FakeWishlistService()
    monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
    monkeypatch.setattr('core.wishlist_service.get_wishlist_service', lambda: ws)
    return db, ws


# ---------------------------------------------------------------------------
# State init + DB load
# ---------------------------------------------------------------------------

def test_state_initialized_on_run(mock_db_and_wishlist):
    """Scanner resets state to running with cleared counters."""
    db, _ = mock_db_and_wishlist
    db._watchlist_artists = []  # no artists → exits early but after init
    state = {}
    deps = _build_deps(state=state)

    qs.run_quality_scanner('watchlist', 1, deps)

    assert state['status'] == 'finished'  # exited early since no artists
    assert state['error_message'] == 'Please add artists to watchlist first'


def test_no_watchlist_artists_short_circuit(mock_db_and_wishlist):
    """Scope=watchlist with no artists → status=finished, error message."""
    db, _ = mock_db_and_wishlist
    db._watchlist_artists = []
    state = {}
    deps = _build_deps(state=state)

    qs.run_quality_scanner('watchlist', 1, deps)

    assert state['status'] == 'finished'
    assert 'add artists' in state['error_message']


# ---------------------------------------------------------------------------
# Provider availability gate
# ---------------------------------------------------------------------------

def test_no_available_provider_marks_error(mock_db_and_wishlist):
    """No available metadata providers → state['status']='error'."""
    db, _ = mock_db_and_wishlist
    db._watchlist_artists = [_WatchlistArtist('A')]
    db._tracks = [_track_row()]
    state = {}
    deps = _build_deps(state=state, source_clients={}, quality_tier_result=('low_lossy', 4))

    qs.run_quality_scanner('watchlist', 1, deps)

    assert state['status'] == 'error'
    assert 'metadata provider' in state['error_message'].lower()


# ---------------------------------------------------------------------------
# Quality tier check + skip
# ---------------------------------------------------------------------------

def test_high_quality_tracks_skipped(mock_db_and_wishlist):
    """Tracks meeting quality (tier_num <= min_acceptable) → quality_met += 1."""
    db, _ = mock_db_and_wishlist
    db._watchlist_artists = [_WatchlistArtist('A')]
    db._tracks = [_track_row(file_path='/x.flac')]
    state = {}
    # Default min_acceptable is from {flac: enabled} → tier 1 (lossless)
    # quality_tier_result=('lossless', 1) → 1 <= 1 → skip
    deps = _build_deps(state=state, quality_tier_result=('lossless', 1))

    qs.run_quality_scanner('watchlist', 1, deps)

    assert state['quality_met'] == 1
    assert state['low_quality'] == 0


def test_low_quality_tracks_attempted(mock_db_and_wishlist):
    """Low-quality tracks (tier_num > min) trigger a metadata search."""
    db, _ = mock_db_and_wishlist
    db._watchlist_artists = [_WatchlistArtist('Artist')]
    db._tracks = [_track_row(file_path='/x.mp3', artist_name='Artist', title='Track')]
    state = {}
    match = _FakeSpotifyTrack(name='Track', artists=['Artist'])
    spotify_client = _FakeMetadataClient(results=[match])
    deps = _build_deps(
        state=state,
        quality_tier_result=('low_lossy', 4),
        source_clients={'spotify': spotify_client},
        primary_source='spotify',
    )

    qs.run_quality_scanner('watchlist', 1, deps)

    assert state['low_quality'] == 1
    assert spotify_client.search_calls
    assert spotify_client.search_calls[0][2] is False


def test_client_lookup_happens_per_query(mock_db_and_wishlist, monkeypatch):
    """Each generated query re-resolves the source client."""
    db, _ = mock_db_and_wishlist
    db._watchlist_artists = [_WatchlistArtist('Artist')]
    db._tracks = [_track_row(file_path='/x.mp3', artist_name='Artist', title='Track')]
    state = {}
    spotify_client = _FakeMetadataClient(results=[])
    lookups = []

    monkeypatch.setattr(
        qs,
        'get_client_for_source',
        lambda source, **_kwargs: (lookups.append(source), _TEST_SOURCE_CLIENTS.get(source))[1],
    )

    deps = _build_deps(
        state=state,
        quality_tier_result=('low_lossy', 4),
        source_clients={'spotify': spotify_client},
        primary_source='spotify',
    )
    deps.matching_engine = _MultiQueryMatchingEngine()

    qs.run_quality_scanner('watchlist', 1, deps)

    assert len(lookups) % 2 == 0
    midpoint = len(lookups) // 2
    assert lookups[:midpoint] == lookups[midpoint:]
    assert len(spotify_client.search_calls) == 2


def test_low_quality_tracks_follow_source_priority(mock_db_and_wishlist):
    """Primary source is searched before Spotify."""
    db, ws = mock_db_and_wishlist
    db._watchlist_artists = [_WatchlistArtist('Artist')]
    db._tracks = [_track_row(file_path='/x.mp3', artist_name='Artist', title='Track')]
    state = {}
    match = _FakeSpotifyTrack(name='Track', artists=['Artist'])
    deezer_client = _FakeMetadataClient(results=[match])
    spotify_client = _FakeMetadataClient(results=[])
    deps = _build_deps(
        state=state,
        source_clients={'deezer': deezer_client, 'spotify': spotify_client},
        primary_source='deezer',
        quality_tier_result=('low_lossy', 4),
    )

    qs.run_quality_scanner('watchlist', 1, deps)

    assert state['matched'] == 1
    assert deezer_client.search_calls
    assert spotify_client.search_calls == []
    assert len(ws.added) == 1
    add_args = ws.added[0]
    assert add_args['track_data']['provider'] == 'deezer'
    assert add_args['track_data']['source'] == 'deezer'


# ---------------------------------------------------------------------------
# Match → wishlist add
# ---------------------------------------------------------------------------

def test_match_adds_to_wishlist(mock_db_and_wishlist):
    """High-confidence match → wishlist_service.add_spotify_track_to_wishlist called."""
    db, ws = mock_db_and_wishlist
    db._watchlist_artists = [_WatchlistArtist('Artist')]
    db._tracks = [_track_row(artist_name='Artist', title='Track', file_path='/x.mp3', bitrate=128)]
    state = {}
    match = _FakeSpotifyTrack(name='Track', artists=['Artist'])
    deps = _build_deps(
        state=state,
        quality_tier_result=('low_lossy', 4),
        source_clients={'spotify': _FakeMetadataClient(results=[match])},
        primary_source='spotify',
    )

    qs.run_quality_scanner('watchlist', 1, deps)

    assert state['matched'] == 1
    assert len(ws.added) == 1
    add_args = ws.added[0]
    assert add_args['source_type'] == 'quality_scanner'
    assert add_args['source_context']['original_file_path'] == '/x.mp3'


def test_match_preserves_album_and_artist_images(mock_db_and_wishlist):
    """Image metadata from the provider payload should survive the wishlist handoff."""
    db, ws = mock_db_and_wishlist
    db._watchlist_artists = [_WatchlistArtist('Artist')]
    db._tracks = [_track_row(artist_name='Artist', title='Track', file_path='/x.mp3', bitrate=128)]
    state = {}
    match = {
        'id': 'sp-1',
        'name': 'Track',
        'artists': [{'name': 'Artist', 'image_url': 'https://example.test/artist.jpg'}],
        'album': 'Album',
        'image_url': 'https://example.test/cover.jpg',
        'duration_ms': 200000,
        'popularity': 50,
        'external_urls': {},
        'album_type': 'album',
        'release_date': '2024-01-01',
    }
    deps = _build_deps(
        state=state,
        quality_tier_result=('low_lossy', 4),
        source_clients={'spotify': _FakeMetadataClient(results=[match])},
        primary_source='spotify',
    )

    qs.run_quality_scanner('watchlist', 1, deps)

    assert state['matched'] == 1
    assert len(ws.added) == 1
    add_args = ws.added[0]
    assert add_args['track_data']['image_url'] == 'https://example.test/cover.jpg'
    assert add_args['track_data']['album']['image_url'] == 'https://example.test/cover.jpg'
    assert add_args['track_data']['album']['images'] == [{'url': 'https://example.test/cover.jpg'}]
    assert add_args['track_data']['artists'][0]['image_url'] == 'https://example.test/artist.jpg'


def test_no_match_no_wishlist_add(mock_db_and_wishlist):
    """No match found → no wishlist add, matched stays 0."""
    db, ws = mock_db_and_wishlist
    db._watchlist_artists = [_WatchlistArtist('A')]
    db._tracks = [_track_row(artist_name='A', title='Z', file_path='/x.mp3')]
    state = {}
    # No spotify results → no match
    deps = _build_deps(
        state=state,
        quality_tier_result=('low_lossy', 4),
        source_clients={'spotify': _FakeMetadataClient(results=[])},
        primary_source='spotify',
    )

    qs.run_quality_scanner('watchlist', 1, deps)

    assert state['matched'] == 0
    assert ws.added == []


# ---------------------------------------------------------------------------
# Stop request gate
# ---------------------------------------------------------------------------

def test_stop_request_halts_loop(mock_db_and_wishlist):
    """Setting state['status'] != 'running' mid-loop halts processing."""
    db, _ = mock_db_and_wishlist
    db._watchlist_artists = [_WatchlistArtist('A')]
    db._tracks = [_track_row(track_id=1), _track_row(track_id=2)]
    state = {}
    deps = _build_deps(state=state, quality_tier_result=('lossless', 1))

    # Override get_quality_tier_from_extension to set stop after first track
    call_count = [0]

    def stop_after_first(fp):
        call_count[0] += 1
        if call_count[0] == 1:
            # Set status to non-running BEFORE second track iter checks
            with deps.quality_scanner_lock:
                state['status'] = 'stopping'
        return ('lossless', 1)

    deps.get_quality_tier_from_extension = stop_after_first

    qs.run_quality_scanner('watchlist', 1, deps)

    # Only first track processed
    assert state['quality_met'] == 1


# ---------------------------------------------------------------------------
# Completion
# ---------------------------------------------------------------------------

def test_completion_marks_finished(mock_db_and_wishlist):
    """All tracks processed → status='finished', progress=100."""
    db, _ = mock_db_and_wishlist
    db._watchlist_artists = [_WatchlistArtist('A')]
    db._tracks = [_track_row()]
    state = {}
    deps = _build_deps(state=state)

    qs.run_quality_scanner('watchlist', 1, deps)

    assert state['status'] == 'finished'
    assert state['progress'] == 100


def test_automation_event_emitted(mock_db_and_wishlist):
    """Successful completion emits 'quality_scan_completed' on automation engine."""
    db, _ = mock_db_and_wishlist
    db._watchlist_artists = [_WatchlistArtist('A')]
    db._tracks = [_track_row()]
    automation = _FakeAutomationEngine()
    state = {}
    deps = _build_deps(state=state, automation=automation)

    qs.run_quality_scanner('watchlist', 1, deps)

    assert any(name == 'quality_scan_completed' for name, _ in automation.events)


# ---------------------------------------------------------------------------
# All-library scope
# ---------------------------------------------------------------------------

def test_scope_all_loads_all_tracks(mock_db_and_wishlist):
    """scope != 'watchlist' loads all tracks (no watchlist filter)."""
    db, _ = mock_db_and_wishlist
    db._tracks = [_track_row(track_id=1), _track_row(track_id=2)]
    state = {}
    deps = _build_deps(state=state)

    qs.run_quality_scanner('all', 1, deps)

    assert state['total'] == 2
