"""Unit tests for FolderScanService (Tasks 1.2–1.5).

Covers:
- Prerequisites checking (TMDB key, anime directory)
- NFO repair integration (Task 1.3)
- Folder rename validation (Task 1.4)
- Poster check and download (Task 1.5)
- Exception handling and semaphore usage
"""
from __future__ import annotations

import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, Mock, patch

import pytest

from src.server.services.folder_scan_service import (
    _POSTER_DOWNLOAD_SEMAPHORE,
    _TMDB_SEMAPHORE,
    FolderScanService,
    FolderScanServiceError,
    perform_nfo_repair_scan,
)

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture
def folder_scan_service() -> FolderScanService:
    """Return a fresh FolderScanService instance."""
    return FolderScanService()


@pytest.fixture
def mock_settings(tmp_path: Path):
    """Return a mock settings object with valid prerequisites."""
    mock = MagicMock()
    mock.tmdb_api_key = "test-api-key"
    mock.anime_directory = str(tmp_path)
    mock.nfo_download_poster = True
    return mock


# ---------------------------------------------------------------------------
# 1.2 – Skeleton / prerequisites
# ---------------------------------------------------------------------------

class TestPrerequisites:
    """Test _prerequisites_met checks."""

    def test_prerequisites_met(self, folder_scan_service, tmp_path):
        """All prerequisites present → True."""
        with patch(
            "src.config.settings.settings"
        ) as mock_settings:
            mock_settings.tmdb_api_key = "key"
            mock_settings.anime_directory = str(tmp_path)
            assert folder_scan_service._prerequisites_met() is True

    def test_missing_tmdb_key(self, folder_scan_service, tmp_path):
        """Missing TMDB API key → False."""
        with patch(
            "src.config.settings.settings"
        ) as mock_settings:
            mock_settings.tmdb_api_key = None
            mock_settings.anime_directory = str(tmp_path)
            assert folder_scan_service._prerequisites_met() is False

    def test_missing_anime_directory(self, folder_scan_service):
        """Missing anime_directory → False."""
        with patch(
            "src.config.settings.settings"
        ) as mock_settings:
            mock_settings.tmdb_api_key = "key"
            mock_settings.anime_directory = None
            assert folder_scan_service._prerequisites_met() is False

    def test_anime_directory_not_found(self, folder_scan_service, tmp_path):
        """anime_directory points to non-existent path → False."""
        non_existent = tmp_path / "does_not_exist"
        with patch(
            "src.config.settings.settings"
        ) as mock_settings:
            mock_settings.tmdb_api_key = "key"
            mock_settings.anime_directory = str(non_existent)
            assert folder_scan_service._prerequisites_met() is False


class TestRunFolderScanPrerequisites:
    """Test run_folder_scan skips when prerequisites not met."""

    @pytest.mark.asyncio
    async def test_skips_when_prerequisites_missing(self, folder_scan_service):
        """If _prerequisites_met returns False, scan exits early."""
        with patch.object(
            folder_scan_service, "_prerequisites_met", return_value=False
        ), patch(
            "src.server.services.folder_scan_service.perform_nfo_repair_scan"
        ) as mock_repair:
            await folder_scan_service.run_folder_scan()
            mock_repair.assert_not_called()

    @pytest.mark.asyncio
    async def test_logs_start_and_completion(self, folder_scan_service, tmp_path):
        """Scan logs start and completion when prerequisites are met."""
        with patch.object(
            folder_scan_service, "_prerequisites_met", return_value=True
        ), patch(
            "src.server.services.folder_scan_service.perform_nfo_repair_scan",
            new_callable=AsyncMock,
        ), patch(
            "src.server.services.folder_rename_service.validate_and_rename_series_folders",
            new_callable=AsyncMock,
            return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
        ), patch.object(
            folder_scan_service,
            "check_and_download_missing_posters",
            new_callable=AsyncMock,
            return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
        ):
            # Should not raise
            await folder_scan_service.run_folder_scan()

    @pytest.mark.asyncio
    async def test_catches_unhandled_exceptions(self, folder_scan_service):
        """Unhandled exceptions are caught and logged, not re-raised."""
        with patch.object(
            folder_scan_service,
            "_prerequisites_met",
            side_effect=RuntimeError("boom"),
        ):
            # Must NOT raise
            await folder_scan_service.run_folder_scan()


# ---------------------------------------------------------------------------
# 1.3 – NFO repair integration
# ---------------------------------------------------------------------------

class TestNfoRepairIntegration:
    """Test perform_nfo_repair_scan is called inside run_folder_scan."""

    @pytest.mark.asyncio
    async def test_calls_perform_nfo_repair_scan(self, folder_scan_service, tmp_path):
        """run_folder_scan must call perform_nfo_repair_scan."""
        with patch.object(
            folder_scan_service, "_prerequisites_met", return_value=True
        ), patch(
            "src.server.services.folder_scan_service.perform_nfo_repair_scan",
            new_callable=AsyncMock,
        ) as mock_repair, patch(
            "src.server.services.folder_rename_service.validate_and_rename_series_folders",
            new_callable=AsyncMock,
            return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
        ), patch.object(
            folder_scan_service,
            "check_and_download_missing_posters",
            new_callable=AsyncMock,
            return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
        ):
            await folder_scan_service.run_folder_scan()
            mock_repair.assert_awaited_once_with(background_loader=None)

    @pytest.mark.asyncio
    async def test_nfo_repair_failure_does_not_crash_scan(
        self, folder_scan_service, tmp_path
    ):
        """If perform_nfo_repair_scan raises, the broad except catches it
        and the scan stops — remaining steps are NOT invoked."""
        with patch.object(
            folder_scan_service, "_prerequisites_met", return_value=True
        ), patch(
            "src.server.services.folder_scan_service.perform_nfo_repair_scan",
            new_callable=AsyncMock,
            side_effect=RuntimeError("repair failed"),
        ) as mock_repair, patch(
            "src.server.services.folder_rename_service.validate_and_rename_series_folders",
            new_callable=AsyncMock,
            return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
        ) as mock_rename, patch.object(
            folder_scan_service,
            "check_and_download_missing_posters",
            new_callable=AsyncMock,
            return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
        ):
            await folder_scan_service.run_folder_scan()
            mock_repair.assert_awaited_once()
            # Broad except stops the scan; rename/poster are skipped
            mock_rename.assert_not_called()


# ---------------------------------------------------------------------------
# 1.4 – Folder rename integration
# ---------------------------------------------------------------------------

class TestFolderRenameIntegration:
    """Test validate_and_rename_series_folders is called and stats logged."""

    @pytest.mark.asyncio
    async def test_calls_folder_rename_service(self, folder_scan_service, tmp_path):
        """run_folder_scan must call validate_and_rename_series_folders."""
        with patch.object(
            folder_scan_service, "_prerequisites_met", return_value=True
        ), patch(
            "src.server.services.folder_scan_service.perform_nfo_repair_scan",
            new_callable=AsyncMock,
        ), patch(
            "src.server.services.folder_rename_service.validate_and_rename_series_folders",
            new_callable=AsyncMock,
            return_value={"scanned": 5, "renamed": 2, "skipped": 2, "errors": 1},
        ) as mock_rename, patch.object(
            folder_scan_service,
            "check_and_download_missing_posters",
            new_callable=AsyncMock,
            return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
        ):
            await folder_scan_service.run_folder_scan()
            mock_rename.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_folder_rename_failure_does_not_crash_scan(
        self, folder_scan_service, tmp_path
    ):
        """If validate_and_rename_series_folders raises, the broad except
        catches it and the scan stops — poster check is NOT invoked."""
        with patch.object(
            folder_scan_service, "_prerequisites_met", return_value=True
        ), patch(
            "src.server.services.folder_scan_service.perform_nfo_repair_scan",
            new_callable=AsyncMock,
        ), patch(
            "src.server.services.folder_rename_service.validate_and_rename_series_folders",
            new_callable=AsyncMock,
            side_effect=RuntimeError("rename failed"),
        ), patch.object(
            folder_scan_service,
            "check_and_download_missing_posters",
            new_callable=AsyncMock,
            return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
        ) as mock_poster:
            await folder_scan_service.run_folder_scan()
            # Broad except stops the scan; poster check is skipped
            mock_poster.assert_not_called()


# ---------------------------------------------------------------------------
# 1.5 – Poster check and download
# ---------------------------------------------------------------------------

class TestPosterCheck:
    """Test check_and_download_missing_posters logic."""

    @pytest.mark.asyncio
    async def test_no_anime_directory_returns_empty_stats(self, folder_scan_service):
        """Missing anime_directory → empty stats."""
        with patch(
            "src.config.settings.settings"
        ) as mock_settings:
            mock_settings.anime_directory = None
            stats = await folder_scan_service.check_and_download_missing_posters()
        assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}

    @pytest.mark.asyncio
    async def test_nonexistent_directory_returns_empty_stats(
        self, folder_scan_service, tmp_path
    ):
        """Non-existent anime_directory → empty stats."""
        non_existent = tmp_path / "missing"
        with patch(
            "src.config.settings.settings"
        ) as mock_settings:
            mock_settings.anime_directory = str(non_existent)
            stats = await folder_scan_service.check_and_download_missing_posters()
        assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}

    @pytest.mark.asyncio
    async def test_no_series_folders_returns_empty_stats(
        self, folder_scan_service, tmp_path
    ):
        """Empty anime_directory → empty stats."""
        with patch(
            "src.config.settings.settings"
        ) as mock_settings:
            mock_settings.anime_directory = str(tmp_path)
            stats = await folder_scan_service.check_and_download_missing_posters()
        assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}

    @pytest.mark.asyncio
    async def test_skips_folders_without_nfo(self, folder_scan_service, tmp_path):
        """Folders without tvshow.nfo are ignored."""
        (tmp_path / "SomeShow").mkdir()
        with patch(
            "src.config.settings.settings"
        ) as mock_settings:
            mock_settings.anime_directory = str(tmp_path)
            stats = await folder_scan_service.check_and_download_missing_posters()
        assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}

    @pytest.mark.asyncio
    async def test_valid_poster_skipped(self, folder_scan_service, tmp_path):
        """Existing poster.jpg ≥ 1 KB is skipped."""
        series_dir = tmp_path / "Attack on Titan (2013)"
        series_dir.mkdir()
        (series_dir / "tvshow.nfo").write_text(
            "<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
        )
        # Write a 2 KB poster
        (series_dir / "poster.jpg").write_bytes(b"x" * 2048)

        with patch(
            "src.config.settings.settings"
        ) as mock_settings:
            mock_settings.anime_directory = str(tmp_path)
            stats = await folder_scan_service.check_and_download_missing_posters()

        assert stats["scanned"] == 1
        assert stats["skipped"] == 1
        assert stats["downloaded"] == 0
        assert stats["errors"] == 0

    @pytest.mark.asyncio
    async def test_missing_poster_downloaded(self, folder_scan_service, tmp_path):
        """Missing poster triggers download when thumb URL exists."""
        series_dir = tmp_path / "Attack on Titan (2013)"
        series_dir.mkdir()
        (series_dir / "tvshow.nfo").write_text(
            "<tvshow>"
            "<title>Attack on Titan</title><year>2013</year>"
            '<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
            "</tvshow>"
        )

        mock_downloader = AsyncMock()
        mock_downloader.download_poster = AsyncMock(return_value=True)
        mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
        mock_downloader.__aexit__ = AsyncMock(return_value=False)

        with patch(
            "src.config.settings.settings"
        ) as mock_settings:
            mock_settings.anime_directory = str(tmp_path)
            mock_settings.nfo_download_poster = True

            with patch(
                "src.server.services.folder_scan_service.ImageDownloader",
                return_value=mock_downloader,
            ):
                stats = await folder_scan_service.check_and_download_missing_posters()

        assert stats["scanned"] == 1
        assert stats["downloaded"] == 1
        assert stats["skipped"] == 0
        assert stats["errors"] == 0

    @pytest.mark.asyncio
    async def test_no_thumb_url_skipped(self, folder_scan_service, tmp_path):
        """NFO without thumb URL → skipped."""
        series_dir = tmp_path / "Attack on Titan (2013)"
        series_dir.mkdir()
        (series_dir / "tvshow.nfo").write_text(
            "<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
        )

        with patch(
            "src.config.settings.settings"
        ) as mock_settings:
            mock_settings.anime_directory = str(tmp_path)
            mock_settings.nfo_download_poster = True
            stats = await folder_scan_service.check_and_download_missing_posters()

        assert stats["scanned"] == 1
        assert stats["skipped"] == 1
        assert stats["downloaded"] == 0

    @pytest.mark.asyncio
    async def test_poster_download_disabled_by_setting(
        self, folder_scan_service, tmp_path
    ):
        """nfo_download_poster=False → skipped even with valid thumb URL."""
        series_dir = tmp_path / "Attack on Titan (2013)"
        series_dir.mkdir()
        (series_dir / "tvshow.nfo").write_text(
            "<tvshow>"
            "<title>Attack on Titan</title><year>2013</year>"
            '<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
            "</tvshow>"
        )

        with patch(
            "src.config.settings.settings"
        ) as mock_settings:
            mock_settings.anime_directory = str(tmp_path)
            mock_settings.nfo_download_poster = False
            stats = await folder_scan_service.check_and_download_missing_posters()

        assert stats["scanned"] == 1
        assert stats["skipped"] == 1
        assert stats["downloaded"] == 0

    @pytest.mark.asyncio
    async def test_download_failure_counts_as_error(self, folder_scan_service, tmp_path):
        """Failed download increments errors."""
        series_dir = tmp_path / "Attack on Titan (2013)"
        series_dir.mkdir()
        (series_dir / "tvshow.nfo").write_text(
            "<tvshow>"
            "<title>Attack on Titan</title><year>2013</year>"
            '<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
            "</tvshow>"
        )

        mock_downloader = AsyncMock()
        mock_downloader.download_poster = AsyncMock(return_value=False)
        mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
        mock_downloader.__aexit__ = AsyncMock(return_value=False)

        with patch(
            "src.config.settings.settings"
        ) as mock_settings:
            mock_settings.anime_directory = str(tmp_path)
            mock_settings.nfo_download_poster = True

            with patch(
                "src.server.services.folder_scan_service.ImageDownloader",
                return_value=mock_downloader,
            ):
                stats = await folder_scan_service.check_and_download_missing_posters()

        assert stats["scanned"] == 1
        assert stats["errors"] == 1
        assert stats["downloaded"] == 0

    @pytest.mark.asyncio
    async def test_download_exception_counts_as_error(self, folder_scan_service, tmp_path):
        """Exception during download increments errors."""
        series_dir = tmp_path / "Attack on Titan (2013)"
        series_dir.mkdir()
        (series_dir / "tvshow.nfo").write_text(
            "<tvshow>"
            "<title>Attack on Titan</title><year>2013</year>"
            '<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
            "</tvshow>"
        )

        mock_downloader = AsyncMock()
        mock_downloader.download_poster = AsyncMock(side_effect=RuntimeError("net"))
        mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
        mock_downloader.__aexit__ = AsyncMock(return_value=False)

        with patch(
            "src.config.settings.settings"
        ) as mock_settings:
            mock_settings.anime_directory = str(tmp_path)
            mock_settings.nfo_download_poster = True

            with patch(
                "src.server.services.folder_scan_service.ImageDownloader",
                return_value=mock_downloader,
            ):
                stats = await folder_scan_service.check_and_download_missing_posters()

        assert stats["scanned"] == 1
        assert stats["errors"] == 1
        assert stats["downloaded"] == 0

    @pytest.mark.asyncio
    async def test_too_small_poster_re_downloaded(self, folder_scan_service, tmp_path):
        """Poster < 1 KB is treated as missing and re-downloaded."""
        series_dir = tmp_path / "Attack on Titan (2013)"
        series_dir.mkdir()
        (series_dir / "tvshow.nfo").write_text(
            "<tvshow>"
            "<title>Attack on Titan</title><year>2013</year>"
            '<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
            "</tvshow>"
        )
        # Write a tiny 100-byte poster
        (series_dir / "poster.jpg").write_bytes(b"x" * 100)

        mock_downloader = AsyncMock()
        mock_downloader.download_poster = AsyncMock(return_value=True)
        mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
        mock_downloader.__aexit__ = AsyncMock(return_value=False)

        with patch(
            "src.config.settings.settings"
        ) as mock_settings:
            mock_settings.anime_directory = str(tmp_path)
            mock_settings.nfo_download_poster = True

            with patch(
                "src.server.services.folder_scan_service.ImageDownloader",
                return_value=mock_downloader,
            ):
                stats = await folder_scan_service.check_and_download_missing_posters()

        assert stats["scanned"] == 1
        assert stats["downloaded"] == 1
        assert stats["skipped"] == 0


class TestExtractPosterUrl:
    """Test _extract_poster_url_from_nfo static method."""

    def test_extract_poster_url_with_aspect(self, tmp_path):
        nfo = tmp_path / "tvshow.nfo"
        nfo.write_text(
            "<tvshow>"
            '<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
            "</tvshow>"
        )
        url = FolderScanService._extract_poster_url_from_nfo(nfo)
        assert url == "https://example.com/poster.jpg"

    def test_extract_first_thumb_fallback(self, tmp_path):
        nfo = tmp_path / "tvshow.nfo"
        nfo.write_text(
            "<tvshow>"
            '<thumb>https://example.com/fallback.jpg</thumb>'
            "</tvshow>"
        )
        url = FolderScanService._extract_poster_url_from_nfo(nfo)
        assert url == "https://example.com/fallback.jpg"

    def test_no_thumb_returns_none(self, tmp_path):
        nfo = tmp_path / "tvshow.nfo"
        nfo.write_text("<tvshow><title>Test</title></tvshow>")
        url = FolderScanService._extract_poster_url_from_nfo(nfo)
        assert url is None

    def test_missing_file_returns_none(self, tmp_path):
        nfo = tmp_path / "tvshow.nfo"
        url = FolderScanService._extract_poster_url_from_nfo(nfo)
        assert url is None

    def test_malformed_xml_returns_none(self, tmp_path):
        nfo = tmp_path / "tvshow.nfo"
        nfo.write_text("not xml")
        url = FolderScanService._extract_poster_url_from_nfo(nfo)
        assert url is None


# ---------------------------------------------------------------------------
# Semaphores
# ---------------------------------------------------------------------------

class TestSemaphores:
    """Verify module-level semaphores exist and have correct initial value."""

    def test_tmdb_semaphore_value(self):
        assert _TMDB_SEMAPHORE._value == 3

    def test_poster_download_semaphore_value(self):
        assert _POSTER_DOWNLOAD_SEMAPHORE._value == 3


# ---------------------------------------------------------------------------
# Full run_folder_scan integration
# ---------------------------------------------------------------------------

class TestRunFolderScanFull:
    """End-to-end tests for run_folder_scan with mocked sub-tasks."""

    @pytest.mark.asyncio
    async def test_full_scan_happy_path(self, folder_scan_service, tmp_path):
        """All sub-tasks succeed."""
        with patch.object(
            folder_scan_service, "_prerequisites_met", return_value=True
        ), patch(
            "src.server.services.folder_scan_service.perform_nfo_repair_scan",
            new_callable=AsyncMock,
        ) as mock_repair, patch(
            "src.server.services.folder_rename_service.validate_and_rename_series_folders",
            new_callable=AsyncMock,
            return_value={"scanned": 3, "renamed": 1, "skipped": 1, "errors": 1},
        ) as mock_rename, patch.object(
            folder_scan_service,
            "check_and_download_missing_posters",
            new_callable=AsyncMock,
            return_value={"scanned": 3, "downloaded": 2, "skipped": 1, "errors": 0},
        ) as mock_poster:
            await folder_scan_service.run_folder_scan()

            mock_repair.assert_awaited_once_with(background_loader=None)
            mock_rename.assert_awaited_once()
            mock_poster.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_full_scan_all_stats_zero(self, folder_scan_service, tmp_path):
        """Empty library → all stats zero."""
        with patch.object(
            folder_scan_service, "_prerequisites_met", return_value=True
        ), patch(
            "src.server.services.folder_scan_service.perform_nfo_repair_scan",
            new_callable=AsyncMock,
        ), patch(
            "src.server.services.folder_rename_service.validate_and_rename_series_folders",
            new_callable=AsyncMock,
            return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
        ), patch.object(
            folder_scan_service,
            "check_and_download_missing_posters",
            new_callable=AsyncMock,
            return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
        ):
            await folder_scan_service.run_folder_scan()
