"""Unit tests for AnimeService.

Tests cover service initialization, async operations, caching,
error handling, progress reporting integration, scan/download status
event handling, database persistence, and WebSocket broadcasting.
"""
from __future__ import annotations

import asyncio
import time
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from src.server.services.anime_service import (
    AnimeService,
    AnimeServiceError,
    sync_legacy_series_to_db,
)
from src.server.services.progress_service import ProgressService


@pytest.fixture
def mock_series_app(tmp_path):
    """Create a mock SeriesApp instance."""
    mock_instance = MagicMock()
    mock_instance.directory_to_search = str(tmp_path)
    mock_instance.series_list = []
    mock_instance.search = AsyncMock(return_value=[])
    mock_instance.rescan = AsyncMock()
    mock_instance.download = AsyncMock(return_value=True)
    mock_instance.download_status = None
    mock_instance.scan_status = None
    return mock_instance


@pytest.fixture
def mock_progress_service():
    """Create a mock ProgressService instance."""
    service = MagicMock(spec=ProgressService)
    service.start_progress = AsyncMock()
    service.update_progress = AsyncMock()
    service.complete_progress = AsyncMock()
    service.fail_progress = AsyncMock()
    return service


@pytest.fixture
def anime_service(tmp_path, mock_series_app, mock_progress_service):
    """Create an AnimeService instance for testing."""
    return AnimeService(
        series_app=mock_series_app,
        progress_service=mock_progress_service,
    )


class TestAnimeServiceInitialization:
    """Test AnimeService initialization."""

    def test_initialization_success(
        self, mock_series_app, mock_progress_service
    ):
        """Test successful service initialization."""
        service = AnimeService(
            series_app=mock_series_app,
            progress_service=mock_progress_service,
        )
        
        assert service._app is mock_series_app
        assert service._progress_service is mock_progress_service

    def test_initialization_failure_raises_error(
        self, tmp_path, mock_progress_service
    ):
        """Test SeriesApp initialization failure raises error."""
        bad_series_app = MagicMock()
        bad_series_app.directory_to_search = str(tmp_path)

        # Make event subscription fail by raising on property access
        type(bad_series_app).download_status = property(
            lambda self: None,
            lambda self, value: (_ for _ in ()).throw(
                Exception("Initialization failed")
            )
        )
        
        with pytest.raises(
            AnimeServiceError, match="Initialization failed"
        ):
            AnimeService(
                series_app=bad_series_app,
                progress_service=mock_progress_service,
            )


class TestListMissing:
    """Test list_missing operation."""

    @pytest.mark.asyncio
    async def test_list_missing_empty(self, anime_service, mock_series_app):
        """Test listing missing episodes when list is empty."""
        mock_series_app.series_list = []
        
        result = await anime_service.list_missing()
        
        assert isinstance(result, list)
        assert len(result) == 0

    @pytest.mark.asyncio
    async def test_list_missing_with_series(
        self, anime_service, mock_series_app
    ):
        """Test listing missing episodes with series data."""
        mock_series_app.series_list = [
            {"name": "Test Series 1", "missing": [1, 2]},
            {"name": "Test Series 2", "missing": [3]},
        ]
        
        result = await anime_service.list_missing()
        
        assert len(result) == 2
        assert result[0]["name"] == "Test Series 1"
        assert result[1]["name"] == "Test Series 2"

    @pytest.mark.asyncio
    async def test_list_missing_caching(self, anime_service, mock_series_app):
        """Test that list_missing uses caching."""
        mock_series_app.series_list = [{"name": "Test Series"}]
        
        # First call
        result1 = await anime_service.list_missing()
        
        # Second call (should use cache)
        result2 = await anime_service.list_missing()
        
        assert result1 == result2

    @pytest.mark.asyncio
    async def test_list_missing_error_handling(
        self, anime_service, mock_series_app
    ):
        """Test error handling in list_missing."""
        mock_series_app.series_list = None  # Cause an error
        
        # Error message will be about NoneType not being iterable
        with pytest.raises(AnimeServiceError):
            await anime_service.list_missing()


class TestSearch:
    """Test search operation."""

    @pytest.mark.asyncio
    async def test_search_empty_query(self, anime_service):
        """Test search with empty query returns empty list."""
        result = await anime_service.search("")
        
        assert result == []

    @pytest.mark.asyncio
    async def test_search_success(self, anime_service, mock_series_app):
        """Test successful search operation."""
        mock_series_app.search.return_value = [
            {"name": "Test Anime", "url": "http://example.com"}
        ]
        
        result = await anime_service.search("test")
        
        assert len(result) == 1
        assert result[0]["name"] == "Test Anime"
        mock_series_app.search.assert_called_once_with("test")

    @pytest.mark.asyncio
    async def test_search_error_handling(
        self, anime_service, mock_series_app
    ):
        """Test error handling during search."""
        mock_series_app.search.side_effect = Exception("Search failed")
        
        with pytest.raises(AnimeServiceError, match="Search failed"):
            await anime_service.search("test query")


class TestRescan:
    """Test rescan operation."""

    @pytest.mark.asyncio
    async def test_rescan_success(
        self, anime_service, mock_series_app, mock_progress_service
    ):
        """Test successful rescan operation."""
        # Mock rescan to return empty list (no DB save needed)
        mock_series_app.rescan.return_value = []
        
        # Mock the database operations
        with patch.object(
            anime_service, '_save_scan_results_to_db', new_callable=AsyncMock
        ):
            with patch.object(
                anime_service, '_load_series_from_db', new_callable=AsyncMock
            ):
                await anime_service.rescan()
        
        # Verify SeriesApp.rescan was called (lowercase, not ReScan)
        mock_series_app.rescan.assert_called_once()

    @pytest.mark.asyncio
    async def test_rescan_with_callback(self, anime_service, mock_series_app):
        """Test rescan operation (callback parameter removed)."""
        # Rescan no longer accepts callback parameter
        # Progress is tracked via event handlers automatically
        mock_series_app.rescan.return_value = []
        
        with patch.object(
            anime_service, '_save_scan_results_to_db', new_callable=AsyncMock
        ):
            with patch.object(
                anime_service, '_load_series_from_db', new_callable=AsyncMock
            ):
                await anime_service.rescan()
        
        # Verify rescan was called
        mock_series_app.rescan.assert_called_once()

    @pytest.mark.asyncio
    async def test_rescan_clears_cache(self, anime_service, mock_series_app):
        """Test that rescan clears the list cache."""
        # Populate cache
        mock_series_app.series_list = [{"name": "Test"}]
        await anime_service.list_missing()
        
        # Update series list
        mock_series_app.series_list = [{"name": "Test"}, {"name": "New"}]
        mock_series_app.rescan.return_value = []
        
        # Mock the database operations
        with patch.object(
            anime_service, '_save_scan_results_to_db', new_callable=AsyncMock
        ):
            with patch.object(
                anime_service, '_load_series_from_db', new_callable=AsyncMock
            ):
                # Rescan should clear cache
                await anime_service.rescan()
        
        # Next list_missing should return updated data
        result = await anime_service.list_missing()
        assert len(result) == 2

    @pytest.mark.asyncio
    async def test_rescan_error_handling(
        self, anime_service, mock_series_app, mock_progress_service
    ):
        """Test error handling during rescan."""
        mock_series_app.rescan.side_effect = Exception("Rescan failed")
        
        with pytest.raises(AnimeServiceError, match="Rescan failed"):
            await anime_service.rescan()


class TestDownload:
    """Test download operation."""

    @pytest.mark.asyncio
    async def test_download_success(self, anime_service, mock_series_app):
        """Test successful download operation."""
        mock_series_app.download.return_value = True
        
        result = await anime_service.download(
            serie_folder="test_series",
            season=1,
            episode=1,
            key="test_key",
        )
        
        assert result is True
        mock_series_app.download.assert_called_once_with(
            serie_folder="test_series",
            season=1,
            episode=1,
            key="test_key",
            item_id=None,
        )

    @pytest.mark.asyncio
    async def test_download_with_callback(
        self, anime_service, mock_series_app
    ):
        """Test download operation (callback parameter removed)."""
        # Download no longer accepts callback parameter
        # Progress is tracked via event handlers automatically
        mock_series_app.download.return_value = True
        
        result = await anime_service.download(
            serie_folder="test_series",
            season=1,
            episode=1,
            key="test_key",
        )
        
        assert result is True
        # Verify download was called with correct parameters
        mock_series_app.download.assert_called_once_with(
            serie_folder="test_series",
            season=1,
            episode=1,
            key="test_key",
            item_id=None,
        )

    @pytest.mark.asyncio
    async def test_download_error_handling(
        self, anime_service, mock_series_app
    ):
        """Test error handling during download."""
        mock_series_app.download.side_effect = Exception("Download failed")
        
        with pytest.raises(AnimeServiceError, match="Download failed"):
            await anime_service.download(
                serie_folder="test_series",
                season=1,
                episode=1,
                key="test_key",
            )


class TestConcurrency:
    """Test concurrent operations."""

    @pytest.mark.asyncio
    async def test_multiple_concurrent_operations(
        self, anime_service, mock_series_app
    ):
        """Test that multiple operations can run concurrently."""
        mock_series_app.search.return_value = [{"name": "Test"}]
        
        # Run multiple searches concurrently
        tasks = [
            anime_service.search("query1"),
            anime_service.search("query2"),
            anime_service.search("query3"),
        ]
        
        results = await asyncio.gather(*tasks)
        
        assert len(results) == 3
        assert all(len(r) == 1 for r in results)


class TestNFOTracking:
    """Test NFO status tracking methods."""

    @pytest.mark.asyncio
    async def test_update_nfo_status_success(self, anime_service):
        """Test successful NFO status update."""
        mock_series = MagicMock()
        mock_series.key = "test-series"
        mock_series.id = 1
        mock_series.has_nfo = False
        mock_series.nfo_created_at = None
        mock_series.nfo_updated_at = None
        mock_series.tmdb_id = None

        mock_db = AsyncMock()

        with patch(
            'src.server.database.service.AnimeSeriesService.get_by_key',
            new_callable=AsyncMock,
            return_value=mock_series
        ):
            await anime_service.update_nfo_status(
                key="test-series",
                has_nfo=True,
                tmdb_id=12345,
                db=mock_db
            )

        # Verify series was updated via direct attribute setting
        assert mock_series.has_nfo is True
        assert mock_series.tmdb_id == 12345
        assert mock_series.nfo_created_at is not None
        assert mock_series.nfo_updated_at is not None
        mock_db.commit.assert_called_once()

    @pytest.mark.asyncio
    async def test_update_nfo_status_not_found(self, anime_service):
        """Test NFO status update when series not found."""
        mock_db = AsyncMock()

        with patch(
            'src.server.database.service.AnimeSeriesService.get_by_key',
            new_callable=AsyncMock,
            return_value=None
        ):
            await anime_service.update_nfo_status(
                key="nonexistent",
                has_nfo=True,
                db=mock_db
            )

        # Should not commit if series not found
        mock_db.commit.assert_not_called()

    @pytest.mark.asyncio
    async def test_get_series_without_nfo(self, anime_service):
        """Test getting series without NFO files."""
        mock_series1 = MagicMock()
        mock_series1.key = "series-1"
        mock_series1.name = "Series 1"
        mock_series1.folder = "Series 1 (2020)"
        mock_series1.tmdb_id = 123
        mock_series1.tvdb_id = None

        mock_series2 = MagicMock()
        mock_series2.key = "series-2"
        mock_series2.name = "Series 2"
        mock_series2.folder = "Series 2 (2021)"
        mock_series2.tmdb_id = None
        mock_series2.tvdb_id = 456

        mock_db = AsyncMock()

        with patch(
            'src.server.database.service.AnimeSeriesService.get_series_without_nfo',
            new_callable=AsyncMock,
            return_value=[mock_series1, mock_series2]
        ):
            result = await anime_service.get_series_without_nfo(db=mock_db)

        assert len(result) == 2
        assert result[0]["key"] == "series-1"
        assert result[0]["has_nfo"] is False
        assert result[0]["tmdb_id"] == 123
        assert result[1]["key"] == "series-2"
        assert result[1]["tvdb_id"] == 456

    @pytest.mark.asyncio
    async def test_get_nfo_statistics(self, anime_service):
        """Test getting NFO statistics."""
        mock_db = AsyncMock()

        # Mock the scalar result for the tvdb execute query
        mock_result = MagicMock()
        mock_result.scalar.return_value = 60
        mock_db.execute = AsyncMock(return_value=mock_result)

        with patch(
            'src.server.database.service.AnimeSeriesService.count_all',
            new_callable=AsyncMock, return_value=100
        ), patch(
            'src.server.database.service.AnimeSeriesService.count_with_nfo',
            new_callable=AsyncMock, return_value=75
        ), patch(
            'src.server.database.service.AnimeSeriesService.count_with_tmdb_id',
            new_callable=AsyncMock, return_value=80
        ), patch(
            'src.server.database.service.AnimeSeriesService.count_with_tvdb_id',
            new_callable=AsyncMock, return_value=60
        ):
            result = await anime_service.get_nfo_statistics(db=mock_db)

        assert result["total"] == 100
        assert result["with_nfo"] == 75
        assert result["without_nfo"] == 25
        assert result["with_tmdb_id"] == 80
        assert result["with_tvdb_id"] == 60


class TestFactoryFunction:
    """Test factory function."""

    def test_get_anime_service(self, mock_series_app):
        """Test get_anime_service factory function."""
        from src.server.services.anime_service import get_anime_service

        # The factory function requires a series_app parameter
        service = get_anime_service(mock_series_app)
        
        assert isinstance(service, AnimeService)
        assert service._app is mock_series_app


# =============================================================================
# New coverage tests – download / scan status, DB persistence, broadcasting
# =============================================================================


class _FakeDownloadArgs:
    """Minimal stand-in for DownloadStatusEventArgs."""

    def __init__(self, **kwargs):
        self.status = kwargs.get("status", "started")
        self.serie_folder = kwargs.get("serie_folder", "TestFolder")
        self.season = kwargs.get("season", 1)
        self.episode = kwargs.get("episode", 1)
        self.item_id = kwargs.get("item_id", None)
        self.progress = kwargs.get("progress", 0)
        self.message = kwargs.get("message", None)
        self.error = kwargs.get("error", None)
        self.mbper_sec = kwargs.get("mbper_sec", None)
        self.eta = kwargs.get("eta", None)


class _FakeScanArgs:
    """Minimal stand-in for ScanStatusEventArgs."""

    def __init__(self, **kwargs):
        self.status = kwargs.get("status", "started")
        self.current = kwargs.get("current", 0)
        self.total = kwargs.get("total", 10)
        self.folder = kwargs.get("folder", "")
        self.message = kwargs.get("message", None)
        self.error = kwargs.get("error", None)


class TestOnDownloadStatus:
    """Test _on_download_status event handler."""

    def test_download_started_schedules_start_progress(
        self, anime_service, mock_progress_service
    ):
        """started event should schedule start_progress."""
        loop = asyncio.new_event_loop()
        anime_service._event_loop = loop
        try:
            with patch("asyncio.get_running_loop", side_effect=RuntimeError):
                with patch("asyncio.run_coroutine_threadsafe") as mock_run:
                    args = _FakeDownloadArgs(
                        status="started", item_id="q-1"
                    )
                    anime_service._on_download_status(args)
                    mock_run.assert_called_once()
                    coro = mock_run.call_args[0][0]
                    assert coro is not None
        finally:
            loop.close()

    def test_download_progress_schedules_update(
        self, anime_service, mock_progress_service
    ):
        """progress event should schedule update_progress."""
        loop = asyncio.new_event_loop()
        anime_service._event_loop = loop
        try:
            with patch("asyncio.get_running_loop", side_effect=RuntimeError):
                with patch("asyncio.run_coroutine_threadsafe") as mock_run:
                    args = _FakeDownloadArgs(
                        status="progress",
                        progress=42,
                        message="Downloading...",
                        mbper_sec=5.5,
                        eta=30,
                    )
                    anime_service._on_download_status(args)
                    mock_run.assert_called_once()
        finally:
            loop.close()

    def test_download_completed_schedules_complete(
        self, anime_service, mock_progress_service
    ):
        """completed event should schedule complete_progress."""
        loop = asyncio.new_event_loop()
        anime_service._event_loop = loop
        try:
            with patch("asyncio.get_running_loop", side_effect=RuntimeError):
                with patch("asyncio.run_coroutine_threadsafe") as mock_run:
                    args = _FakeDownloadArgs(status="completed")
                    anime_service._on_download_status(args)
                    mock_run.assert_called_once()
        finally:
            loop.close()

    def test_download_failed_schedules_fail(
        self, anime_service, mock_progress_service
    ):
        """failed event should schedule fail_progress."""
        loop = asyncio.new_event_loop()
        anime_service._event_loop = loop
        try:
            with patch("asyncio.get_running_loop", side_effect=RuntimeError):
                with patch("asyncio.run_coroutine_threadsafe") as mock_run:
                    args = _FakeDownloadArgs(
                        status="failed", error=Exception("Err")
                    )
                    anime_service._on_download_status(args)
                    mock_run.assert_called_once()
        finally:
            loop.close()

    def test_progress_id_from_item_id(self, anime_service):
        """item_id should be used as progress_id when available."""
        loop = asyncio.new_event_loop()
        anime_service._event_loop = loop
        try:
            with patch("asyncio.get_running_loop", side_effect=RuntimeError):
                with patch("asyncio.run_coroutine_threadsafe") as mock_run:
                    args = _FakeDownloadArgs(
                        status="started", item_id="queue-42"
                    )
                    anime_service._on_download_status(args)
                    coro = mock_run.call_args[0][0]
                    # The coroutine was created with progress_id="queue-42"
                    assert mock_run.called
        finally:
            loop.close()

    def test_progress_id_fallback_without_item_id(self, anime_service):
        """Without item_id, progress_id is built from folder/season/episode."""
        loop = asyncio.new_event_loop()
        anime_service._event_loop = loop
        try:
            with patch("asyncio.get_running_loop", side_effect=RuntimeError):
                with patch("asyncio.run_coroutine_threadsafe") as mock_run:
                    args = _FakeDownloadArgs(
                        status="started",
                        item_id=None,
                        serie_folder="FolderX",
                        season=2,
                        episode=5,
                    )
                    anime_service._on_download_status(args)
                    assert mock_run.called
        finally:
            loop.close()

    def test_no_event_loop_returns_silently(self, anime_service):
        """No loop available should not raise."""
        anime_service._event_loop = None
        with patch("asyncio.get_running_loop", side_effect=RuntimeError):
            args = _FakeDownloadArgs(status="started")
            anime_service._on_download_status(args)  # should not raise


class TestOnScanStatus:
    """Test _on_scan_status event handler."""

    def test_scan_started_schedules_progress_and_broadcast(
        self, anime_service, mock_progress_service
    ):
        """started scan event should schedule start_progress and broadcast."""
        loop = asyncio.new_event_loop()
        anime_service._event_loop = loop
        try:
            with patch("asyncio.get_running_loop", side_effect=RuntimeError):
                with patch("asyncio.run_coroutine_threadsafe") as mock_run:
                    args = _FakeScanArgs(status="started", total=5)
                    anime_service._on_scan_status(args)
                    # 2 calls: start_progress + broadcast_scan_started_safe
                    assert mock_run.call_count == 2
                    assert anime_service._is_scanning is True
        finally:
            loop.close()

    def test_scan_progress_updates_counters(
        self, anime_service, mock_progress_service
    ):
        """progress scan event should update counters."""
        loop = asyncio.new_event_loop()
        anime_service._event_loop = loop
        try:
            with patch("asyncio.get_running_loop", side_effect=RuntimeError):
                with patch("asyncio.run_coroutine_threadsafe"):
                    args = _FakeScanArgs(
                        status="progress", current=3, total=10,
                        folder="Naruto"
                    )
                    anime_service._on_scan_status(args)
                    assert anime_service._scan_directories_count == 3
                    assert anime_service._scan_current_directory == "Naruto"
        finally:
            loop.close()

    def test_scan_completed_marks_done(
        self, anime_service, mock_progress_service
    ):
        """completed scan event should mark scanning as False."""
        loop = asyncio.new_event_loop()
        anime_service._event_loop = loop
        anime_service._is_scanning = True
        anime_service._scan_start_time = time.time() - 5
        try:
            with patch("asyncio.get_running_loop", side_effect=RuntimeError):
                with patch("asyncio.run_coroutine_threadsafe"):
                    args = _FakeScanArgs(status="completed", total=10)
                    anime_service._on_scan_status(args)
                    assert anime_service._is_scanning is False
        finally:
            loop.close()

    def test_scan_failed_marks_done(
        self, anime_service, mock_progress_service
    ):
        """failed scan event should reset scanning state."""
        loop = asyncio.new_event_loop()
        anime_service._event_loop = loop
        anime_service._is_scanning = True
        try:
            with patch("asyncio.get_running_loop", side_effect=RuntimeError):
                with patch("asyncio.run_coroutine_threadsafe"):
                    args = _FakeScanArgs(
                        status="failed", error=Exception("boom")
                    )
                    anime_service._on_scan_status(args)
                    assert anime_service._is_scanning is False
        finally:
            loop.close()

    def test_scan_cancelled_marks_done(
        self, anime_service, mock_progress_service
    ):
        """cancelled scan event should reset scanning state."""
        loop = asyncio.new_event_loop()
        anime_service._event_loop = loop
        anime_service._is_scanning = True
        try:
            with patch("asyncio.get_running_loop", side_effect=RuntimeError):
                with patch("asyncio.run_coroutine_threadsafe"):
                    args = _FakeScanArgs(status="cancelled")
                    anime_service._on_scan_status(args)
                    assert anime_service._is_scanning is False
        finally:
            loop.close()

    def test_scan_no_loop_returns_silently(self, anime_service):
        """No loop available should not raise for scan events."""
        anime_service._event_loop = None
        with patch("asyncio.get_running_loop", side_effect=RuntimeError):
            args = _FakeScanArgs(status="started")
            anime_service._on_scan_status(args)  # no error


class TestGetScanStatus:
    """Test get_scan_status method."""

    def test_returns_status_dict(self, anime_service):
        """Should return dict with expected keys."""
        anime_service._is_scanning = True
        anime_service._scan_total_items = 42
        anime_service._scan_directories_count = 7
        anime_service._scan_current_directory = "Naruto"
        result = anime_service.get_scan_status()
        assert result["is_scanning"] is True
        assert result["total_items"] == 42
        assert result["directories_scanned"] == 7
        assert result["current_directory"] == "Naruto"


class TestBroadcastHelpers:
    """Test WebSocket broadcast safety wrappers."""

    @pytest.mark.asyncio
    async def test_broadcast_scan_started_safe(self, anime_service):
        """Should call websocket_service.broadcast_scan_started."""
        anime_service._websocket_service.broadcast_scan_started = AsyncMock()
        await anime_service._broadcast_scan_started_safe(total_items=5)
        anime_service._websocket_service.broadcast_scan_started.assert_called_once()

    @pytest.mark.asyncio
    async def test_broadcast_scan_started_safe_handles_error(
        self, anime_service
    ):
        """WS failure should be swallowed, not raised."""
        anime_service._websocket_service.broadcast_scan_started = AsyncMock(
            side_effect=Exception("ws-down")
        )
        # Should NOT raise
        await anime_service._broadcast_scan_started_safe(total_items=5)

    @pytest.mark.asyncio
    async def test_broadcast_scan_progress_safe(self, anime_service):
        """Should call broadcast_scan_progress."""
        anime_service._websocket_service.broadcast_scan_progress = AsyncMock()
        await anime_service._broadcast_scan_progress_safe(
            directories_scanned=3, files_found=3,
            current_directory="AOT", total_items=10,
        )
        anime_service._websocket_service.broadcast_scan_progress.assert_called_once()

    @pytest.mark.asyncio
    async def test_broadcast_scan_progress_safe_handles_error(
        self, anime_service
    ):
        """WS failure should be swallowed."""
        anime_service._websocket_service.broadcast_scan_progress = AsyncMock(
            side_effect=Exception("ws-down")
        )
        await anime_service._broadcast_scan_progress_safe(
            directories_scanned=0, files_found=0,
            current_directory="", total_items=0,
        )

    @pytest.mark.asyncio
    async def test_broadcast_scan_completed_safe(self, anime_service):
        """Should call broadcast_scan_completed."""
        anime_service._websocket_service.broadcast_scan_completed = AsyncMock()
        await anime_service._broadcast_scan_completed_safe(
            total_directories=10, total_files=10, elapsed_seconds=5.0,
        )
        anime_service._websocket_service.broadcast_scan_completed.assert_called_once()

    @pytest.mark.asyncio
    async def test_broadcast_scan_completed_safe_handles_error(
        self, anime_service
    ):
        """WS failure should be swallowed."""
        anime_service._websocket_service.broadcast_scan_completed = AsyncMock(
            side_effect=Exception("ws-down")
        )
        await anime_service._broadcast_scan_completed_safe(
            total_directories=0, total_files=0, elapsed_seconds=0,
        )

    @pytest.mark.asyncio
    async def test_broadcast_series_updated(self, anime_service):
        """Should broadcast series_updated over WebSocket."""
        anime_service._websocket_service.broadcast = AsyncMock()
        await anime_service._broadcast_series_updated("aot")
        anime_service._websocket_service.broadcast.assert_called_once()
        payload = anime_service._websocket_service.broadcast.call_args[0][0]
        assert payload["type"] == "series_updated"
        assert payload["key"] == "aot"

    @pytest.mark.asyncio
    async def test_broadcast_series_updated_no_ws_service(self, anime_service):
        """Should return silently if no websocket service."""
        anime_service._websocket_service = None
        await anime_service._broadcast_series_updated("aot")  # no error


class TestListSeriesWithFilters:
    """Test list_series_with_filters with database enrichment."""

    @pytest.mark.asyncio
    async def test_returns_enriched_list(
        self, anime_service, mock_series_app
    ):
        """Should merge SeriesApp data with DB metadata."""
        mock_serie = MagicMock()
        mock_serie.key = "aot"
        mock_serie.name = "Attack on Titan"
        mock_serie.site = "aniworld.to"
        mock_serie.folder = "Attack on Titan (2013)"
        mock_serie.episodeDict = {1: [2, 3]}

        mock_list = MagicMock()
        mock_list.GetList.return_value = [mock_serie]
        mock_series_app.list = mock_list

        mock_db_series = MagicMock()
        mock_db_series.folder = "Attack on Titan (2013)"
        mock_db_series.has_nfo = True
        mock_db_series.nfo_created_at = None
        mock_db_series.nfo_updated_at = None
        mock_db_series.tmdb_id = 1234
        mock_db_series.tvdb_id = None
        mock_db_series.id = 1

        mock_session = AsyncMock()
        mock_ctx = AsyncMock()
        mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
        mock_ctx.__aexit__ = AsyncMock(return_value=False)

        with patch(
            "src.server.database.connection.get_db_session",
            return_value=mock_ctx,
        ), patch(
            "src.server.database.service.AnimeSeriesService"
        ) as MockASS:
            MockASS.get_all = AsyncMock(return_value=[mock_db_series])
            result = await anime_service.list_series_with_filters()

        assert len(result) == 1
        assert result[0]["key"] == "aot"
        assert result[0]["has_nfo"] is True
        assert result[0]["tmdb_id"] == 1234

    @pytest.mark.asyncio
    async def test_empty_series_returns_empty(
        self, anime_service, mock_series_app
    ):
        """Should return [] when SeriesApp has no series."""
        mock_list = MagicMock()
        mock_list.GetList.return_value = []
        mock_series_app.list = mock_list

        result = await anime_service.list_series_with_filters()
        assert result == []

    @pytest.mark.asyncio
    async def test_no_list_attribute_returns_empty(
        self, anime_service, mock_series_app
    ):
        """Should return [] when SeriesApp has no list attribute."""
        del mock_series_app.list
        result = await anime_service.list_series_with_filters()
        assert result == []

    @pytest.mark.asyncio
    async def test_db_error_raises_anime_service_error(
        self, anime_service, mock_series_app
    ):
        """DB failure should raise AnimeServiceError."""
        mock_serie = MagicMock()
        mock_serie.key = "aot"
        mock_serie.name = "AOT"
        mock_serie.site = "x"
        mock_serie.folder = "AOT"
        mock_serie.episodeDict = {}

        mock_list = MagicMock()
        mock_list.GetList.return_value = [mock_serie]
        mock_series_app.list = mock_list

        mock_ctx = AsyncMock()
        mock_ctx.__aenter__ = AsyncMock(
            side_effect=RuntimeError("DB down")
        )
        mock_ctx.__aexit__ = AsyncMock(return_value=False)

        with patch(
            "src.server.database.connection.get_db_session",
            return_value=mock_ctx,
        ):
            with pytest.raises(AnimeServiceError):
                await anime_service.list_series_with_filters()


class TestSaveAndLoadDB:
    """Test DB persistence helpers."""

    @pytest.mark.asyncio
    async def test_save_scan_results_creates_new(
        self, anime_service
    ):
        """New series should be created in DB."""
        mock_serie = MagicMock()
        mock_serie.key = "naruto"
        mock_serie.name = "Naruto"
        mock_serie.site = "aniworld.to"
        mock_serie.folder = "Naruto"
        mock_serie.year = 2002
        mock_serie.episodeDict = {1: [1, 2]}

        mock_session = AsyncMock()
        mock_ctx = AsyncMock()
        mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
        mock_ctx.__aexit__ = AsyncMock(return_value=False)

        with patch(
            "src.server.database.connection.get_db_session",
            return_value=mock_ctx,
        ), patch(
            "src.server.database.service.AnimeSeriesService.get_by_key",
            new_callable=AsyncMock,
            return_value=None,
        ), patch(
            "src.server.database.service.AnimeSeriesService.create",
            new_callable=AsyncMock,
            return_value=MagicMock(id=1),
        ) as mock_create, patch(
            "src.server.database.service.EpisodeService.create",
            new_callable=AsyncMock,
        ) as mock_ep_create:
            count = await anime_service._save_scan_results_to_db(
                [mock_serie]
            )
        assert count == 1
        mock_create.assert_called_once()
        assert mock_ep_create.call_count == 2

    @pytest.mark.asyncio
    async def test_save_scan_results_updates_existing(
        self, anime_service
    ):
        """Existing series should be updated in DB."""
        mock_serie = MagicMock()
        mock_serie.key = "naruto"
        mock_serie.name = "Naruto"
        mock_serie.site = "aniworld.to"
        mock_serie.folder = "Naruto"
        mock_serie.episodeDict = {1: [3]}

        existing = MagicMock()
        existing.id = 1
        existing.folder = "Naruto"

        mock_session = AsyncMock()
        mock_ctx = AsyncMock()
        mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
        mock_ctx.__aexit__ = AsyncMock(return_value=False)

        with patch(
            "src.server.database.connection.get_db_session",
            return_value=mock_ctx,
        ), patch(
            "src.server.database.service.AnimeSeriesService.get_by_key",
            new_callable=AsyncMock,
            return_value=existing,
        ), patch.object(
            anime_service,
            "_update_series_in_db",
            new_callable=AsyncMock,
        ) as mock_update:
            count = await anime_service._save_scan_results_to_db(
                [mock_serie]
            )
        assert count == 1
        mock_update.assert_called_once()

    @pytest.mark.asyncio
    async def test_load_series_from_db(
        self, anime_service, mock_series_app
    ):
        """Should populate SeriesApp from DB records."""
        mock_ep = MagicMock()
        mock_ep.season = 1
        mock_ep.episode_number = 5

        mock_db_series = MagicMock()
        mock_db_series.key = "naruto"
        mock_db_series.name = "Naruto"
        mock_db_series.site = "aniworld.to"
        mock_db_series.folder = "Naruto"
        mock_db_series.episodes = [mock_ep]

        mock_session = AsyncMock()
        mock_ctx = AsyncMock()
        mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
        mock_ctx.__aexit__ = AsyncMock(return_value=False)

        with patch(
            "src.server.database.connection.get_db_session",
            return_value=mock_ctx,
        ), patch(
            "src.server.database.service.AnimeSeriesService.get_all",
            new_callable=AsyncMock,
            return_value=[mock_db_series],
        ):
            await anime_service._load_series_from_db()

        mock_series_app.load_series_from_list.assert_called_once()
        loaded = mock_series_app.load_series_from_list.call_args[0][0]
        assert len(loaded) == 1
        assert loaded[0].key == "naruto"

    @pytest.mark.asyncio
    async def test_sync_episodes_to_db(
        self, anime_service, mock_series_app
    ):
        """Should sync missing episodes from memory to DB."""
        mock_serie = MagicMock()
        mock_serie.episodeDict = {1: [4, 5]}

        mock_list = MagicMock()
        mock_list.keyDict = {"aot": mock_serie}
        mock_series_app.list = mock_list

        mock_db_series = MagicMock()
        mock_db_series.id = 10

        mock_session = AsyncMock()
        mock_ctx = AsyncMock()
        mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
        mock_ctx.__aexit__ = AsyncMock(return_value=False)

        anime_service._websocket_service = MagicMock()
        anime_service._websocket_service.broadcast = AsyncMock()

        with patch(
            "src.server.database.connection.get_db_session",
            return_value=mock_ctx,
        ), patch(
            "src.server.database.service.AnimeSeriesService.get_by_key",
            new_callable=AsyncMock,
            return_value=mock_db_series,
        ), patch(
            "src.server.database.service.EpisodeService.get_by_series",
            new_callable=AsyncMock,
            return_value=[],
        ), patch(
            "src.server.database.service.EpisodeService.create",
            new_callable=AsyncMock,
        ) as mock_ep_create:
            count = await anime_service.sync_episodes_to_db("aot")

        assert count == 2
        assert mock_ep_create.call_count == 2

    @pytest.mark.asyncio
    async def test_sync_episodes_no_list_returns_zero(
        self, anime_service, mock_series_app
    ):
        """No series list should return 0."""
        del mock_series_app.list
        count = await anime_service.sync_episodes_to_db("aot")
        assert count == 0


class TestAddSeriesToDB:
    """Test add_series_to_db method."""

    @pytest.mark.asyncio
    async def test_creates_new_series(self, anime_service):
        """New series should be created in DB."""
        mock_serie = MagicMock()
        mock_serie.key = "x"
        mock_serie.name = "X"
        mock_serie.site = "y"
        mock_serie.folder = "X"
        mock_serie.year = 2020
        mock_serie.episodeDict = {1: [1]}

        mock_db = AsyncMock()
        mock_created = MagicMock(id=99)

        with patch(
            "src.server.database.service.AnimeSeriesService.get_by_key",
            new_callable=AsyncMock,
            return_value=None,
        ), patch(
            "src.server.database.service.AnimeSeriesService.create",
            new_callable=AsyncMock,
            return_value=mock_created,
        ), patch(
            "src.server.database.service.EpisodeService.create",
            new_callable=AsyncMock,
        ):
            result = await anime_service.add_series_to_db(mock_serie, mock_db)
        assert result is mock_created

    @pytest.mark.asyncio
    async def test_existing_returns_none(self, anime_service):
        """Already-existing series should return None."""
        mock_serie = MagicMock()
        mock_serie.key = "x"
        mock_serie.name = "X"
        mock_db = AsyncMock()

        with patch(
            "src.server.database.service.AnimeSeriesService.get_by_key",
            new_callable=AsyncMock,
            return_value=MagicMock(),
        ):
            result = await anime_service.add_series_to_db(mock_serie, mock_db)
        assert result is None


class TestContainsInDB:
    """Test contains_in_db method."""

    @pytest.mark.asyncio
    async def test_exists(self, anime_service):
        """Should return True when series exists."""
        mock_db = AsyncMock()
        with patch(
            "src.server.database.service.AnimeSeriesService.get_by_key",
            new_callable=AsyncMock,
            return_value=MagicMock(),
        ):
            assert await anime_service.contains_in_db("aot", mock_db) is True

    @pytest.mark.asyncio
    async def test_not_exists(self, anime_service):
        """Should return False when series missing."""
        mock_db = AsyncMock()
        with patch(
            "src.server.database.service.AnimeSeriesService.get_by_key",
            new_callable=AsyncMock,
            return_value=None,
        ):
            assert await anime_service.contains_in_db("x", mock_db) is False


class TestUpdateNFOStatusWithoutSession:
    """Test update_nfo_status when no db session is passed (self-managed)."""

    @pytest.mark.asyncio
    async def test_update_creates_session_and_commits(self, anime_service):
        """Should open its own session and commit."""
        mock_series = MagicMock()
        mock_series.id = 1
        mock_series.has_nfo = False
        mock_series.nfo_created_at = None
        mock_series.nfo_updated_at = None

        mock_session = AsyncMock()
        mock_ctx = AsyncMock()
        mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
        mock_ctx.__aexit__ = AsyncMock(return_value=False)

        with patch(
            "src.server.database.connection.get_db_session",
            return_value=mock_ctx,
        ), patch(
            "src.server.database.service.AnimeSeriesService.get_by_key",
            new_callable=AsyncMock,
            return_value=mock_series,
        ), patch(
            "src.server.database.service.AnimeSeriesService.update",
            new_callable=AsyncMock,
        ):
            await anime_service.update_nfo_status(
                key="test", has_nfo=True, tmdb_id=42
            )

        # commit called by update path
        mock_session.commit.assert_called_once()

    @pytest.mark.asyncio
    async def test_update_not_found_skips(self, anime_service):
        """Should return without error if series not in DB."""
        mock_session = AsyncMock()
        mock_ctx = AsyncMock()
        mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
        mock_ctx.__aexit__ = AsyncMock(return_value=False)

        with patch(
            "src.server.database.connection.get_db_session",
            return_value=mock_ctx,
        ), patch(
            "src.server.database.service.AnimeSeriesService.get_by_key",
            new_callable=AsyncMock,
            return_value=None,
        ):
            await anime_service.update_nfo_status(key="missing", has_nfo=True)

        mock_session.commit.assert_not_called()


class TestGetSeriesWithoutNFOSelfManaged:
    """Test get_series_without_nfo when db=None (self-managed session)."""

    @pytest.mark.asyncio
    async def test_returns_list(self, anime_service):
        """Should return formatted dicts."""
        mock_s = MagicMock()
        mock_s.key = "test"
        mock_s.name = "Test"
        mock_s.folder = "Test"
        mock_s.tmdb_id = 1
        mock_s.tvdb_id = 2

        mock_session = AsyncMock()
        mock_ctx = AsyncMock()
        mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
        mock_ctx.__aexit__ = AsyncMock(return_value=False)

        with patch(
            "src.server.database.connection.get_db_session",
            return_value=mock_ctx,
        ), patch(
            "src.server.database.service.AnimeSeriesService"
            ".get_series_without_nfo",
            new_callable=AsyncMock,
            return_value=[mock_s],
        ):
            result = await anime_service.get_series_without_nfo()

        assert len(result) == 1
        assert result[0]["has_nfo"] is False


class TestGetNFOStatisticsSelfManaged:
    """Test get_nfo_statistics when db=None (self-managed session)."""

    @pytest.mark.asyncio
    async def test_returns_stats(self, anime_service):
        """Should compute statistics correctly."""
        mock_session = AsyncMock()
        mock_ctx = AsyncMock()
        mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
        mock_ctx.__aexit__ = AsyncMock(return_value=False)

        with patch(
            "src.server.database.connection.get_db_session",
            return_value=mock_ctx,
        ), patch(
            "src.server.database.service.AnimeSeriesService.count_all",
            new_callable=AsyncMock,
            return_value=50,
        ), patch(
            "src.server.database.service.AnimeSeriesService.count_with_nfo",
            new_callable=AsyncMock,
            return_value=30,
        ), patch(
            "src.server.database.service.AnimeSeriesService"
            ".count_with_tmdb_id",
            new_callable=AsyncMock,
            return_value=40,
        ), patch(
            "src.server.database.service.AnimeSeriesService"
            ".count_with_tvdb_id",
            new_callable=AsyncMock,
            return_value=20,
        ):
            result = await anime_service.get_nfo_statistics()

        assert result["total"] == 50
        assert result["without_nfo"] == 20
        assert result["with_tmdb_id"] == 40


class TestSyncSeriesFromDataFiles:
    """Test module-level sync_legacy_series_to_db function."""

    @pytest.mark.asyncio
    async def test_sync_adds_new_series(self, tmp_path):
        """Should create series for data files not in DB."""
        mock_serie = MagicMock()
        mock_serie.key = "new-series"
        mock_serie.name = "New Series"
        mock_serie.site = "aniworld.to"
        mock_serie.folder = "New Series"
        mock_serie.episodeDict = {1: [1]}

        mock_session = AsyncMock()
        mock_ctx = AsyncMock()
        mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
        mock_ctx.__aexit__ = AsyncMock(return_value=False)

        with patch(
            "src.server.services.anime_service.SeriesApp"
        ) as MockApp, patch(
            "src.server.database.connection.get_db_session",
            return_value=mock_ctx,
        ), patch(
            "src.server.database.service.AnimeSeriesService.get_by_key",
            new_callable=AsyncMock,
            return_value=None,
        ), patch(
            "src.server.database.service.AnimeSeriesService.create",
            new_callable=AsyncMock,
            return_value=MagicMock(id=1),
        ) as mock_create, patch(
            "src.server.database.service.EpisodeService.create",
            new_callable=AsyncMock,
        ):
            mock_app_instance = MagicMock()
            mock_app_instance.get_all_series_from_data_files.return_value = [
                mock_serie
            ]
            MockApp.return_value = mock_app_instance

            count = await sync_legacy_series_to_db(str(tmp_path))

        assert count == 1
        mock_create.assert_called_once()

    @pytest.mark.asyncio
    async def test_sync_skips_existing(self, tmp_path):
        """Already-existing series should be skipped."""
        mock_serie = MagicMock()
        mock_serie.key = "exists"
        mock_serie.name = "Exists"
        mock_serie.site = "x"
        mock_serie.folder = "Exists"
        mock_serie.episodeDict = {}

        mock_session = AsyncMock()
        mock_ctx = AsyncMock()
        mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
        mock_ctx.__aexit__ = AsyncMock(return_value=False)

        with patch(
            "src.server.services.anime_service.SeriesApp"
        ) as MockApp, patch(
            "src.server.database.connection.get_db_session",
            return_value=mock_ctx,
        ), patch(
            "src.server.database.service.AnimeSeriesService.get_by_key",
            new_callable=AsyncMock,
            return_value=MagicMock(),
        ), patch(
            "src.server.database.service.AnimeSeriesService.create",
            new_callable=AsyncMock,
        ) as mock_create:
            mock_app_instance = MagicMock()
            mock_app_instance.get_all_series_from_data_files.return_value = [
                mock_serie
            ]
            MockApp.return_value = mock_app_instance

            count = await sync_legacy_series_to_db(str(tmp_path))

        assert count == 0
        mock_create.assert_not_called()

    @pytest.mark.asyncio
    async def test_sync_no_data_files(self, tmp_path):
        """Empty directory should return 0."""
        with patch(
            "src.server.services.anime_service.SeriesApp"
        ) as MockApp:
            mock_app_instance = MagicMock()
            mock_app_instance.get_all_series_from_data_files.return_value = []
            MockApp.return_value = mock_app_instance

            count = await sync_legacy_series_to_db(str(tmp_path))

        assert count == 0

    @pytest.mark.asyncio
    async def test_sync_handles_empty_name(self, tmp_path):
        """Series with empty name should use folder as fallback."""
        mock_serie = MagicMock()
        mock_serie.key = "no-name"
        mock_serie.name = ""
        mock_serie.site = "x"
        mock_serie.folder = "FallbackFolder"
        mock_serie.episodeDict = {}

        mock_session = AsyncMock()
        mock_ctx = AsyncMock()
        mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
        mock_ctx.__aexit__ = AsyncMock(return_value=False)

        with patch(
            "src.server.services.anime_service.SeriesApp"
        ) as MockApp, patch(
            "src.server.database.connection.get_db_session",
            return_value=mock_ctx,
        ), patch(
            "src.server.database.service.AnimeSeriesService.get_by_key",
            new_callable=AsyncMock,
            return_value=None,
        ), patch(
            "src.server.database.service.AnimeSeriesService.create",
            new_callable=AsyncMock,
            return_value=MagicMock(id=1),
        ) as mock_create:
            mock_app_instance = MagicMock()
            mock_app_instance.get_all_series_from_data_files.return_value = [
                mock_serie
            ]
            MockApp.return_value = mock_app_instance

            count = await sync_legacy_series_to_db(str(tmp_path))

        assert count == 1
        # The name should have been set to folder
        assert mock_serie.name == "FallbackFolder"
