"""Tests for ``core/downloads/album_bundle_dispatch.py``.

Pins the gate predicate, the resolution + run flow, and the
fail / fall-through return contract. Mocks the config, plugin
resolver, and state access so the dispatcher is testable without
standing up runtime_state or a real plugin.
"""

from __future__ import annotations

from unittest.mock import MagicMock

import pytest

from core.downloads.album_bundle_dispatch import (
    BatchStateAccess,
    is_eligible,
    try_dispatch,
)


class _FakeState:
    """In-memory ``BatchStateAccess`` for tests — records every
    update so assertions can check the sequence of fields set."""

    def __init__(self) -> None:
        self.fields: dict = {}
        self.update_calls: list = []
        self.failed_with: str = ''

    def update_fields(self, batch_id: str, fields: dict) -> None:
        self.update_calls.append((batch_id, dict(fields)))
        self.fields.update(fields)

    def mark_failed(self, batch_id: str, error: str) -> None:
        self.failed_with = error
        self.fields['phase'] = 'failed'
        self.fields['error'] = error
        self.fields['album_bundle_state'] = 'failed'


def _config(values: dict):
    """Build a config_get callable from a flat dict."""
    def _get(key, default=None):
        return values.get(key, default)
    return _get


# ---------------------------------------------------------------------------
# is_eligible pure predicate
# ---------------------------------------------------------------------------


def test_is_eligible_requires_album_flag() -> None:
    assert is_eligible(mode='torrent', is_album=False,
                       album_name='X', artist_name='Y') is False


def test_is_eligible_requires_album_bundle_mode() -> None:
    for mode in ('youtube', 'tidal', 'qobuz', 'hifi',
                 'deezer_dl', 'amazon', 'lidarr', 'soundcloud', 'hybrid'):
        assert is_eligible(mode=mode, is_album=True,
                           album_name='X', artist_name='Y') is False


def test_is_eligible_accepts_torrent_usenet_and_soulseek() -> None:
    assert is_eligible(mode='torrent', is_album=True,
                       album_name='X', artist_name='Y') is True
    assert is_eligible(mode='usenet', is_album=True,
                       album_name='X', artist_name='Y') is True
    assert is_eligible(mode='soulseek', is_album=True,
                       album_name='X', artist_name='Y') is True


def test_is_eligible_requires_non_empty_names() -> None:
    assert is_eligible(mode='torrent', is_album=True,
                       album_name='', artist_name='Y') is False
    assert is_eligible(mode='torrent', is_album=True,
                       album_name='X', artist_name='') is False
    assert is_eligible(mode='torrent', is_album=True,
                       album_name='   ', artist_name='Y') is False


def test_is_eligible_case_insensitive_mode() -> None:
    assert is_eligible(mode='TORRENT', is_album=True,
                       album_name='X', artist_name='Y') is True


# ---------------------------------------------------------------------------
# try_dispatch — gate evaluation
# ---------------------------------------------------------------------------


def test_dispatch_returns_false_when_not_album() -> None:
    state = _FakeState()
    plugin = MagicMock()
    result = try_dispatch(
        batch_id='b1', is_album=False,
        album_context={'name': 'X'}, artist_context={'name': 'Y'},
        config_get=_config({'download_source.mode': 'torrent'}),
        plugin_resolver=lambda _name: plugin, state=state,
    )
    assert result is False
    assert state.update_calls == []
    plugin.download_album_to_staging.assert_not_called()


def test_dispatch_returns_false_for_non_album_bundle_modes() -> None:
    state = _FakeState()
    plugin = MagicMock()
    result = try_dispatch(
        batch_id='b1', is_album=True,
        album_context={'name': 'X'}, artist_context={'name': 'Y'},
        config_get=_config({'download_source.mode': 'youtube'}),
        plugin_resolver=lambda _name: plugin, state=state,
    )
    assert result is False
    assert state.update_calls == []


def test_dispatch_returns_false_when_plugin_missing() -> None:
    """No plugin available → fall through to per-track flow with a
    warning. The state SHOULD NOT have been touched."""
    state = _FakeState()
    result = try_dispatch(
        batch_id='b1', is_album=True,
        album_context={'name': 'X'}, artist_context={'name': 'Y'},
        config_get=_config({'download_source.mode': 'torrent'}),
        plugin_resolver=lambda _name: None, state=state,
    )
    assert result is False
    assert state.update_calls == []


def test_dispatch_returns_false_when_plugin_lacks_method() -> None:
    state = _FakeState()
    # Plugin that doesn't implement download_album_to_staging.
    class _LegacyPlugin:
        pass
    result = try_dispatch(
        batch_id='b1', is_album=True,
        album_context={'name': 'X'}, artist_context={'name': 'Y'},
        config_get=_config({'download_source.mode': 'torrent'}),
        plugin_resolver=lambda _name: _LegacyPlugin(), state=state,
    )
    assert result is False
    assert state.update_calls == []


def test_dispatch_returns_false_when_resolver_raises() -> None:
    """Plugin resolution can fail (registry not initialised); we log
    and fall through rather than crashing the master worker."""
    state = _FakeState()
    def _boom(_name):
        raise RuntimeError("registry not initialised")
    result = try_dispatch(
        batch_id='b1', is_album=True,
        album_context={'name': 'X'}, artist_context={'name': 'Y'},
        config_get=_config({'download_source.mode': 'torrent'}),
        plugin_resolver=_boom, state=state,
    )
    assert result is False


# ---------------------------------------------------------------------------
# try_dispatch — success / failure paths
# ---------------------------------------------------------------------------


def test_dispatch_success_returns_false_so_per_track_can_run() -> None:
    """Success → master worker should CONTINUE to per-track flow so
    each task can hit try_staging_match and find its file."""
    state = _FakeState()
    plugin = MagicMock()
    plugin.download_album_to_staging.return_value = {
        'success': True, 'files': ['/tmp/a.flac', '/tmp/b.flac'], 'error': None,
    }
    result = try_dispatch(
        batch_id='b1', is_album=True,
        album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
        config_get=_config({
            'download_source.mode': 'torrent',
            'import.staging_path': '/staging/path',
        }),
        plugin_resolver=lambda _name: plugin, state=state,
    )
    assert result is False
    # Plugin was called with the right args.
    args = plugin.download_album_to_staging.call_args
    assert args.args[0] == 'GNX'
    assert args.args[1] == 'Kendrick Lamar'
    assert args.args[2].replace('\\', '/').endswith('storage/album_bundle_staging/b1')
    # Phase transitioned through searching → analysis.
    assert state.fields['phase'] == 'analysis'
    assert state.fields['album_bundle_state'] == 'staged'
    assert state.fields['album_bundle_source'] == 'torrent'
    assert state.fields['album_bundle_private_staging'] is True
    assert state.fields['album_bundle_staging_path'].replace('\\', '/').endswith('storage/album_bundle_staging/b1')
    assert state.failed_with == ''


def test_dispatch_uses_configured_private_album_bundle_staging_root() -> None:
    state = _FakeState()
    plugin = MagicMock()
    plugin.download_album_to_staging.return_value = {'success': True, 'files': ['/tmp/a.flac']}

    try_dispatch(
        batch_id='batch:with/slash', is_album=True,
        album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
        config_get=_config({
            'download_source.mode': 'torrent',
            'download_source.album_bundle_staging_path': '/private/staging',
        }),
        plugin_resolver=lambda _name: plugin, state=state,
    )

    staging_arg = plugin.download_album_to_staging.call_args.args[2].replace('\\', '/')
    assert staging_arg == '/private/staging/batch_with_slash'
    assert state.fields['album_bundle_staging_path'].replace('\\', '/') == staging_arg


def test_dispatch_failure_returns_true_so_master_stops() -> None:
    state = _FakeState()
    plugin = MagicMock()
    plugin.download_album_to_staging.return_value = {
        'success': False, 'files': [], 'error': 'No torrent results found',
    }
    result = try_dispatch(
        batch_id='b1', is_album=True,
        album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
        config_get=_config({'download_source.mode': 'torrent'}),
        plugin_resolver=lambda _name: plugin, state=state,
    )
    assert result is True
    assert state.failed_with == 'No torrent results found'
    assert state.fields['phase'] == 'failed'


def test_dispatch_fallback_failure_returns_false_for_per_track_flow() -> None:
    state = _FakeState()
    plugin = MagicMock()
    plugin.download_album_to_staging.return_value = {
        'success': False,
        'files': [],
        'error': 'No complete Soulseek album folders found',
        'fallback': True,
    }
    result = try_dispatch(
        batch_id='b1', is_album=True,
        album_context={'name': 'Album'}, artist_context={'name': 'Artist'},
        config_get=_config({'download_source.mode': 'soulseek'}),
        plugin_resolver=lambda _name: plugin, state=state,
    )
    assert result is False
    assert state.failed_with == ''
    assert state.fields['phase'] == 'analysis'
    assert state.fields['album_bundle_state'] == 'fallback'
    assert state.fields['album_bundle_error'] == 'No complete Soulseek album folders found'


def test_dispatch_plugin_exception_treated_as_failure() -> None:
    """A bug / network error in the plugin must not propagate into
    the master worker — caught + treated as a normal failure so
    the batch reports the error cleanly."""
    state = _FakeState()
    plugin = MagicMock()
    plugin.download_album_to_staging.side_effect = RuntimeError("network down")
    result = try_dispatch(
        batch_id='b1', is_album=True,
        album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
        config_get=_config({'download_source.mode': 'torrent'}),
        plugin_resolver=lambda _name: plugin, state=state,
    )
    assert result is True
    assert 'network down' in state.failed_with


def test_dispatch_strips_whitespace_from_names() -> None:
    """Trailing whitespace in batch context shouldn't fail the
    eligibility predicate AND should be cleaned before passing to
    the plugin."""
    state = _FakeState()
    plugin = MagicMock()
    plugin.download_album_to_staging.return_value = {'success': True, 'files': ['/x']}
    try_dispatch(
        batch_id='b1', is_album=True,
        album_context={'name': '  GNX  '}, artist_context={'name': '  Kendrick  '},
        config_get=_config({'download_source.mode': 'torrent'}),
        plugin_resolver=lambda _name: plugin, state=state,
    )
    args = plugin.download_album_to_staging.call_args
    assert args.args[0] == 'GNX'
    assert args.args[1] == 'Kendrick'


def test_dispatch_source_override_uses_first_hybrid_source() -> None:
    state = _FakeState()
    plugin = MagicMock()
    plugin.download_album_to_staging.return_value = {'success': True, 'files': ['/x']}
    seen = []

    try_dispatch(
        batch_id='b1', is_album=True,
        album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
        config_get=_config({'download_source.mode': 'hybrid'}),
        plugin_resolver=lambda name: seen.append(name) or plugin,
        state=state,
        source_override='soulseek',
    )

    assert seen == ['soulseek']
    assert state.fields['album_bundle_source'] == 'soulseek'


def test_dispatch_progress_callback_mirrors_payload_to_state() -> None:
    """The progress callback the plugin gets must mirror its
    payload onto the batch state under ``album_bundle_*`` keys so
    the Downloads page can render progress while the torrent
    download runs."""
    state = _FakeState()
    captured_emit = {}

    def _capture(album, artist, staging, emit):
        captured_emit['fn'] = emit
        emit({'state': 'searching', 'release': 'GNX [FLAC]'})
        emit({'state': 'downloading', 'progress': 0.42, 'speed': 1024 * 1024})
        emit({'state': 'staged', 'count': 12})
        return {'success': True, 'files': []}

    plugin = MagicMock()
    plugin.download_album_to_staging.side_effect = _capture
    try_dispatch(
        batch_id='b1', is_album=True,
        album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
        config_get=_config({'download_source.mode': 'torrent'}),
        plugin_resolver=lambda _name: plugin, state=state,
    )
    # State should have seen each of the three lifecycle emissions.
    states_seen = [fields.get('album_bundle_state')
                   for _, fields in state.update_calls
                   if 'album_bundle_state' in fields]
    assert 'searching' in states_seen
    assert 'downloading' in states_seen
    assert 'staged' in states_seen
    # Numeric progress + release name made it through.
    assert state.fields['album_bundle_release'] == 'GNX [FLAC]'
    assert state.fields['album_bundle_progress'] == 0.42
    assert state.fields['album_bundle_count'] == 12


# ---------------------------------------------------------------------------
# Protocol conformance — runtime impl must satisfy the contract
# ---------------------------------------------------------------------------


def test_runtime_state_impl_matches_protocol() -> None:
    """Sanity check that the concrete BatchStateAccess impl in
    master.py implements both methods. We don't import master.py
    here (would pull in heavy deps); duck-check on the _FakeState
    instead since it's a sibling impl of the same Protocol."""
    state: BatchStateAccess = _FakeState()
    state.update_fields('b1', {'x': 1})
    state.mark_failed('b1', 'oops')
    assert state.fields['x'] == 1
    assert state.fields['error'] == 'oops'
