"""Engine-boundary tests for the automation handler registration.

Per-handler boundary tests in the sibling test files prove each
handler's body works in isolation. These tests prove the
**registration layer** wires every handler to the right action name,
attaches the right guard, and registers the four progress callbacks
in the slots the engine expects.

The kettui standard for refactor PRs: don't ship a "behavior
preserved" claim that's only validated at the function boundary.
Wire the seam — engine + register_all + deps — and exercise it.

These tests use a minimal recording engine that captures every
``register_action_handler`` / ``register_progress_callbacks`` call,
plus a no-op ``add_scan_completion_callback`` on a fake scan
manager. No real AutomationEngine, no real DB, no real Flask app.
"""

from __future__ import annotations

import threading
from typing import Any, Dict, List, Tuple

import pytest

from core.automation.deps import AutomationDeps, AutomationState
from core.automation.handlers import register_all


# Every action name `register_all` is expected to register. Drift
# (rename / new handler / removed handler) fails this test
# immediately so refactor PRs can't quietly drop a handler.
EXPECTED_ACTION_NAMES = frozenset({
    'process_wishlist',
    'scan_watchlist',
    'scan_library',
    'refresh_mirrored',
    'sync_playlist',
    'discover_playlist',
    'playlist_pipeline',
    'personalized_pipeline',
    'start_database_update',
    'deep_scan_library',
    'run_duplicate_cleaner',
    'clear_quarantine',
    'cleanup_wishlist',
    'update_discovery_pool',
    'start_quality_scan',
    'backup_database',
    'refresh_beatport_cache',
    'clean_search_history',
    'clean_completed_downloads',
    'full_cleanup',
    'run_script',
    'search_and_download',
})

# Action names that MUST register a guard (duplicate-run prevention).
EXPECTED_GUARDED_ACTIONS = frozenset({
    'process_wishlist',
    'scan_watchlist',
    'scan_library',
    'playlist_pipeline',
    'personalized_pipeline',
    'start_database_update',
    'deep_scan_library',
    'run_duplicate_cleaner',
    'start_quality_scan',
})


class _RecordingEngine:
    """Minimal AutomationEngine stand-in. Captures everything
    register_all does so tests can assert on it."""

    def __init__(self):
        self.action_handlers: Dict[str, Dict[str, Any]] = {}
        self.progress_callbacks: Tuple = ()
        self.emits: List[Tuple[str, dict]] = []

    def register_action_handler(self, action_type, handler_fn, guard_fn=None):
        self.action_handlers[action_type] = {'handler': handler_fn, 'guard': guard_fn}

    def register_progress_callbacks(self, init_fn, finish_fn, update_fn=None, history_fn=None):
        self.progress_callbacks = (init_fn, finish_fn, update_fn, history_fn)

    def emit(self, event_name, payload):
        self.emits.append((event_name, payload))


class _RecordingScanMgr:
    _current_server_type = 'plex'

    def __init__(self):
        self.callbacks: List = []

    def add_scan_completion_callback(self, cb):
        self.callbacks.append(cb)


def _build_deps(engine, scan_mgr=None) -> AutomationDeps:
    class _Logger:
        def debug(self, *a, **k): pass
        def info(self, *a, **k): pass
        def warning(self, *a, **k): pass
        def error(self, *a, **k): pass

    class _Cfg:
        def get(self, key, default=None): return default
        def get_active_media_server(self): return 'plex'

    return AutomationDeps(
        engine=engine,
        state=AutomationState(),
        config_manager=_Cfg(),
        update_progress=lambda *a, **k: None,
        logger=_Logger(),
        get_database=lambda: object(),
        spotify_client=None,
        tidal_client=None,
        web_scan_manager=scan_mgr,
        process_wishlist_automatically=lambda **k: None,
        process_watchlist_scan_automatically=lambda **k: None,
        is_wishlist_actually_processing=lambda: False,
        is_watchlist_actually_scanning=lambda: False,
        get_watchlist_scan_state=lambda: {},
        run_playlist_discovery_worker=lambda *a, **k: None,
        run_sync_task=lambda *a, **k: None,
        load_sync_status_file=lambda: {},
        get_deezer_client=lambda: None,
        parse_youtube_playlist=lambda url: None,
        get_sync_states=lambda: {},
        set_db_update_automation_id=lambda v: None,
        get_db_update_state=lambda: {},
        db_update_lock=threading.Lock(),
        db_update_executor=None,
        run_db_update_task=lambda *a, **k: None,
        run_deep_scan_task=lambda *a, **k: None,
        get_duplicate_cleaner_state=lambda: {},
        duplicate_cleaner_lock=threading.Lock(),
        duplicate_cleaner_executor=None,
        run_duplicate_cleaner=lambda: None,
        get_quality_scanner_state=lambda: {},
        quality_scanner_lock=threading.Lock(),
        quality_scanner_executor=None,
        run_quality_scanner=lambda *a, **k: None,
        download_orchestrator=None,
        run_async=lambda coro: None,
        tasks_lock=threading.Lock(),
        get_download_batches=lambda: {},
        get_download_tasks=lambda: {},
        sweep_empty_download_directories=lambda: 0,
        get_staging_path=lambda: '/staging',
        docker_resolve_path=lambda p: p,
        get_current_profile_id=lambda: 1,
        get_watchlist_scanner=lambda spc: None,
        get_app=lambda: None,
        get_beatport_data_cache=lambda: {'cache_lock': threading.Lock(), 'homepage': {}},
        init_automation_progress=lambda *a, **k: None,
        record_progress_history=lambda *a, **k: None,
        build_personalized_manager=lambda: None,
    )


# ─── action handler registration ─────────────────────────────────────


class TestActionHandlerRegistration:
    def test_every_expected_action_name_registered(self):
        engine = _RecordingEngine()
        register_all(_build_deps(engine))
        registered = set(engine.action_handlers.keys())
        missing = EXPECTED_ACTION_NAMES - registered
        extra = registered - EXPECTED_ACTION_NAMES
        assert not missing, f"register_all dropped: {missing}"
        assert not extra, f"register_all added unexpected: {extra}"

    def test_guarded_actions_have_a_guard(self):
        engine = _RecordingEngine()
        register_all(_build_deps(engine))
        for name in EXPECTED_GUARDED_ACTIONS:
            assert engine.action_handlers[name]['guard'] is not None, (
                f"action {name!r} expected to register a guard but didn't"
            )

    def test_unguarded_actions_have_no_guard(self):
        engine = _RecordingEngine()
        register_all(_build_deps(engine))
        unguarded = EXPECTED_ACTION_NAMES - EXPECTED_GUARDED_ACTIONS
        for name in unguarded:
            assert engine.action_handlers[name]['guard'] is None, (
                f"action {name!r} unexpectedly registered a guard"
            )

    def test_every_handler_callable(self):
        # Every registered handler must be callable with a config dict.
        engine = _RecordingEngine()
        register_all(_build_deps(engine))
        for name, entry in engine.action_handlers.items():
            handler = entry['handler']
            assert callable(handler), f"{name} handler is not callable"

    def test_every_guard_callable_returns_bool(self):
        engine = _RecordingEngine()
        register_all(_build_deps(engine))
        for name, entry in engine.action_handlers.items():
            guard = entry['guard']
            if guard is None:
                continue
            value = guard()
            assert isinstance(value, bool), (
                f"{name} guard returned non-bool: {type(value).__name__}"
            )


# ─── progress callback registration ──────────────────────────────────


class TestProgressCallbackRegistration:
    def test_all_four_callbacks_registered(self):
        engine = _RecordingEngine()
        register_all(_build_deps(engine))
        init_fn, finish_fn, update_fn, history_fn = engine.progress_callbacks
        assert callable(init_fn)
        assert callable(finish_fn)
        assert callable(update_fn)
        assert callable(history_fn)

    def test_progress_init_callback_invocable(self):
        # Engine signature: init_fn(aid, name, action_type)
        engine = _RecordingEngine()
        captured: List[Tuple] = []
        deps = _build_deps(engine)
        deps = AutomationDeps(**{
            **{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()},
            'init_automation_progress': lambda aid, name, at: captured.append((aid, name, at)),
        })
        register_all(deps)
        init_fn, _, _, _ = engine.progress_callbacks
        init_fn('auto-1', 'My Auto', 'wishlist')
        assert captured == [('auto-1', 'My Auto', 'wishlist')]

    def test_progress_finish_callback_invocable(self):
        # Engine signature: finish_fn(aid, result)
        engine = _RecordingEngine()
        captured: List[Tuple] = []
        deps = _build_deps(engine)
        deps = AutomationDeps(**{
            **{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()},
            'update_progress': lambda aid, **kw: captured.append((aid, kw)),
        })
        register_all(deps)
        _, finish_fn, _, _ = engine.progress_callbacks
        # Non-_manages_own_progress result triggers update_progress emit.
        finish_fn('auto-1', {'status': 'completed'})
        assert len(captured) == 1
        assert captured[0][0] == 'auto-1'
        assert captured[0][1]['status'] == 'finished'

    def test_progress_finish_skips_self_managed(self):
        engine = _RecordingEngine()
        captured: List[Tuple] = []
        deps = _build_deps(engine)
        deps = AutomationDeps(**{
            **{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()},
            'update_progress': lambda aid, **kw: captured.append((aid, kw)),
        })
        register_all(deps)
        _, finish_fn, _, _ = engine.progress_callbacks
        finish_fn('auto-1', {'_manages_own_progress': True, 'status': 'completed'})
        assert captured == []

    def test_history_callback_invocable_with_db(self):
        # Engine signature: history_fn(aid, result)
        engine = _RecordingEngine()
        captured: List[Tuple] = []
        db_obj = object()
        deps = _build_deps(engine)
        deps = AutomationDeps(**{
            **{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()},
            'get_database': lambda: db_obj,
            'record_progress_history': lambda aid, result, db: captured.append((aid, result, db)),
        })
        register_all(deps)
        _, _, _, history_fn = engine.progress_callbacks
        history_fn('auto-1', {'status': 'completed'})
        assert captured == [('auto-1', {'status': 'completed'}, db_obj)]


# ─── library_scan_completed wiring ───────────────────────────────────


class TestLibraryScanCompletedEmitter:
    def test_no_scan_manager_safe(self):
        # Should not raise when scan manager is absent (test/headless mode).
        engine = _RecordingEngine()
        register_all(_build_deps(engine, scan_mgr=None))
        # No callbacks captured (no scan manager to register against),
        # but engine still has all the handlers.
        assert engine.action_handlers

    def test_scan_completion_callback_registered(self):
        engine = _RecordingEngine()
        scan_mgr = _RecordingScanMgr()
        register_all(_build_deps(engine, scan_mgr=scan_mgr))
        assert len(scan_mgr.callbacks) == 1
        # Invoking the callback should fire engine.emit('library_scan_completed', ...).
        scan_mgr.callbacks[0]()
        assert engine.emits == [('library_scan_completed', {'server_type': 'plex'})]


# ─── handler invocation through the engine boundary ─────────────────


class TestHandlerInvocation:
    """For each registered handler, exercise the lambda the engine
    would call. Verifies the deps closure is captured correctly + the
    handler returns a result dict.

    Forces every long-running handler down its guard short-circuit
    path by pre-setting the relevant `*_state` dicts to ``running``
    (database_update, deep_scan, duplicate_cleaner, quality_scanner)
    or by pre-occupying the state flags (scan_library, playlist_pipeline).
    Other handlers either return error early (no playlist specified,
    no scan manager, etc) or run cleanly against the no-op stub deps.
    """

    def test_every_handler_returns_dict(self):
        engine = _RecordingEngine()
        # Pre-set state dicts so guarded handlers skip-return cleanly.
        running_state = {'status': 'running'}
        active_batches = {'b1': {'phase': 'downloading'}}

        # Stub DB that satisfies the handlers reaching for it on
        # short paths. cleanup_wishlist calls remove_wishlist_duplicates.
        # update_discovery_pool's exception path swallows missing
        # scanner. Other handlers either short-circuit or use stub
        # callables.
        class _StubDB:
            def remove_wishlist_duplicates(self, profile_id): return 0
            def get_mirrored_playlists(self): return []
            def get_mirrored_playlist(self, _id): return None
            def get_mirrored_playlist_tracks(self, _id): return []

        # Stub Flask app so refresh_beatport_cache's test_client call
        # doesn't crash. Use a minimal context manager.
        class _FakeResponse:
            status_code = 200
        class _FakeClient:
            def __enter__(self): return self
            def __exit__(self, *a): return False
            def get(self, _path): return _FakeResponse()
        class _StubApp:
            def test_client(self): return _FakeClient()

        deps = _build_deps(engine)
        deps = AutomationDeps(**{
            **{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()},
            'get_db_update_state': lambda: running_state,
            'get_duplicate_cleaner_state': lambda: running_state,
            'get_quality_scanner_state': lambda: running_state,
            'get_download_batches': lambda: active_batches,  # forces clean_completed_downloads to skip
            'get_database': lambda: _StubDB(),
            'get_app': lambda: _StubApp(),
        })
        # Pre-set state flags too so scan_library + playlist_pipeline guards fire.
        deps.state.scan_library_automation_id = 'someone-else'
        deps.state.pipeline_running = True

        # Patch time.sleep across handler modules so refresh_beatport_cache
        # (which sleeps 2s between sections) doesn't extend the test.
        import core.automation.handlers.maintenance as maint_mod
        original_sleep = maint_mod.time.sleep
        maint_mod.time.sleep = lambda _: None

        register_all(deps)

        try:
            for name, entry in engine.action_handlers.items():
                handler = entry['handler']
                # Minimal config: trigger the natural error/skip paths
                # for handlers that need a playlist_id, query, script_name etc.
                result = handler({'_automation_id': 'test', 'all': False})
                assert isinstance(result, dict), (
                    f"handler {name!r} returned {type(result).__name__}, expected dict"
                )
                assert 'status' in result, (
                    f"handler {name!r} returned dict without 'status': {list(result.keys())}"
                )
        finally:
            maint_mod.time.sleep = original_sleep
