"""Folder scan service for daily maintenance tasks.

Encapsulates the daily folder-scan logic (orphaned-file detection,
metadata refresh, and missing-episode queuing) so that the scheduler
remains clean and the scan can be tested independently.
"""
from __future__ import annotations

import asyncio
from pathlib import Path
from typing import Optional

import structlog
from lxml import etree

from src.config.settings import settings as _settings
from src.core.utils.image_downloader import ImageDownloader

logger = structlog.get_logger(__name__)

# Module-level semaphore to limit concurrent TMDB operations to 3.
_TMDB_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)

# Semaphore to limit concurrent poster image downloads to 3.
_POSTER_DOWNLOAD_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)

# Semaphore to limit concurrent NFO repair TMDB operations to 3.
_NFO_REPAIR_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)


async def _create_missing_nfo(series_dir: Path, series_name: str) -> None:
    """Create minimal NFO for series without one.

    Creates a fresh :class:`NFOService` per invocation so concurrent
    tasks cannot interfere with each other.

    A module-level semaphore limits concurrent TMDB operations to 3.

    Args:
        series_dir: Absolute path to the series folder.
        series_name: Human-readable series name for log messages.
    """
    from src.core.services.nfo_factory import NFOServiceFactory

    async with _NFO_REPAIR_SEMAPHORE:
        try:
            factory = NFOServiceFactory()
            nfo_service = factory.create()
            await nfo_service.create_minimal_nfo(
                serie_name=series_name,
                serie_folder=series_dir.name,
            )
        except Exception as exc:  # pylint: disable=broad-except
            logger.error(
                "NFO creation failed for %s: %s",
                series_name,
                exc,
            )


async def _repair_one_series(series_dir: Path, series_name: str) -> None:
    """Repair a single series NFO in isolation.

    Creates a fresh :class:`NFOService` and :class:`NfoRepairService` per
    invocation so that each repair owns its own ``aiohttp`` session/connector
    and concurrent tasks cannot interfere with each other.

    A module-level semaphore (``_NFO_REPAIR_SEMAPHORE``) limits the number of
    simultaneous TMDB requests to avoid rate-limiting.

    Any exception is caught and logged so the asyncio task never silently
    drops an unhandled error.

    Args:
        series_dir: Absolute path to the series folder.
        series_name: Human-readable series name for log messages.
    """
    from src.core.services.nfo_factory import NFOServiceFactory
    from src.core.services.nfo_repair_service import NfoRepairService

    async with _NFO_REPAIR_SEMAPHORE:
        try:
            factory = NFOServiceFactory()
            nfo_service = factory.create()
            repair_service = NfoRepairService(nfo_service)
            await repair_service.repair_series(series_dir, series_name)
        except Exception as exc:  # pylint: disable=broad-except
            logger.error(
                "NFO repair failed for %s: %s",
                series_name,
                exc,
            )


async def perform_nfo_repair_scan(background_loader=None) -> None:
    """Scan all series folders, repair incomplete and create missing NFO files.

    Called from ``FolderScanService.run_folder_scan()`` during the scheduled
    daily folder scan (not on every startup).  Checks each subfolder of
    ``settings.anime_directory`` for a ``tvshow.nfo``:
    - Missing NFOs: creates minimal NFO via ``_create_missing_nfo``
    - Incomplete NFOs: repairs via ``_repair_one_series``

    Each repair task creates its own isolated :class:`NFOService` /
    :class:`TMDBClient` so concurrent tasks never share an ``aiohttp``
    session — this prevents "Connector is closed" errors when many repairs
    run in parallel.  A semaphore caps TMDB concurrency at 3 to stay within
    rate limits.

    The ``background_loader`` parameter is accepted for backwards-compatibility
    but is no longer used.

    Args:
        background_loader: Unused.  Kept to avoid breaking call-sites.
    """
    from src.core.services.nfo_repair_service import nfo_needs_repair

    if not _settings.tmdb_api_key:
        logger.warning("NFO repair scan skipped — TMDB API key not configured")
        return
    if not _settings.anime_directory:
        logger.warning("NFO repair scan skipped — anime directory not configured")
        return
    anime_dir = Path(_settings.anime_directory)
    if not anime_dir.is_dir():
        logger.warning("NFO repair scan skipped — anime directory not found: %s", anime_dir)
        return

    queued = 0
    total = 0
    missing_nfo_count = 0
    for series_dir in sorted(anime_dir.iterdir()):
        if not series_dir.is_dir():
            continue
        nfo_path = series_dir / "tvshow.nfo"
        series_name = series_dir.name
        if not nfo_path.exists():
            # Create minimal NFO for series without one
            missing_nfo_count += 1
            asyncio.create_task(
                _create_missing_nfo(series_dir, series_name),
                name=f"nfo_create:{series_name}",
            )
            continue
        total += 1
        if nfo_needs_repair(nfo_path):
            queued += 1
            asyncio.create_task(
                _repair_one_series(series_dir, series_name),
                name=f"nfo_repair:{series_name}",
            )

    logger.info(
        "NFO repair scan complete: %d of %d series queued for repair, %d missing NFOs queued for creation",
        queued,
        total,
        missing_nfo_count,
    )


class FolderScanServiceError(Exception):
    """Service-level exception for folder-scan operations."""


class FolderScanService:
    """Performs daily maintenance scans over the anime library folder.

    The service is intentionally stateless; a new instance can be created
    for every scheduled invocation or test case.
    """

    async def run_folder_scan(self) -> None:
        """Execute the daily folder scan.

        Checks prerequisites, logs progress, and delegates to sub-task
        helpers.  Any unhandled exception is caught and logged so the
        scheduler task never crashes.
        """
        logger.info("Folder scan started")

        try:
            if not self._prerequisites_met():
                return

            # 1.3 — Repair incomplete NFO files in the background.
            logger.info("Starting NFO repair scan as part of folder scan")
            await perform_nfo_repair_scan(background_loader=None)
            logger.info("NFO repair scan queued; repairs will continue in background")

            # 1.4 — Validate and rename series folders after NFO repair.
            logger.info("Starting folder rename validation")
            from src.server.services.folder_rename_service import (
                validate_and_rename_series_folders,
            )

            rename_stats = await validate_and_rename_series_folders()
            logger.info(
                "Folder rename validation complete",
                scanned=rename_stats["scanned"],
                renamed=rename_stats["renamed"],
                skipped=rename_stats["skipped"],
                errors=rename_stats["errors"],
            )

            # 1.5 — Check and download missing poster.jpg files.
            logger.info("Starting poster check")
            poster_stats = await self.check_and_download_missing_posters()
            logger.info(
                "Poster check complete",
                scanned=poster_stats["scanned"],
                downloaded=poster_stats["downloaded"],
                skipped=poster_stats["skipped"],
                errors=poster_stats["errors"],
            )

            logger.info("Folder scan completed")
        except Exception as exc:  # pylint: disable=broad-exception-caught
            logger.error("Folder scan failed", error=str(exc), exc_info=True)

    # ------------------------------------------------------------------
    # Poster check helpers
    # ------------------------------------------------------------------

    async def check_and_download_missing_posters(self) -> dict[str, int]:
        """Iterate over series folders and download missing poster.jpg files.

        For each folder containing a ``tvshow.nfo``:
        1. Check if ``poster.jpg`` exists and is at least
           :attr:`ImageDownloader.min_file_size` bytes.
        2. If missing or too small, parse ``tvshow.nfo`` for a ``<thumb>``
           URL (preferring ``aspect="poster"``).
        3. Download the image via :class:`ImageDownloader` under a
           semaphore that limits concurrency to 3.

        Returns:
            Dictionary with counts:
            - ``"scanned"``: total folders scanned
            - ``"downloaded"``: posters successfully downloaded
            - ``"skipped"``: folders skipped (no NFO, no thumb URL,
              or poster already valid)
            - ``"errors"``: folders that caused a download error
        """
        from src.config.settings import settings  # noqa: PLC0415

        stats = {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}

        if not settings.anime_directory:
            logger.warning("Poster check skipped — anime directory not configured")
            return stats

        anime_dir = Path(settings.anime_directory)
        if not anime_dir.is_dir():
            logger.warning(
                "Poster check skipped — anime directory not found: %s", anime_dir
            )
            return stats

        # Gather all series directories that contain a tvshow.nfo
        series_dirs = [
            d for d in anime_dir.iterdir()
            if d.is_dir() and (d / "tvshow.nfo").exists()
        ]

        if not series_dirs:
            logger.debug("No series folders found for poster check")
            return stats

        # Process each series folder concurrently with semaphore
        tasks = [
            self._check_and_download_poster(series_dir, stats)
            for series_dir in series_dirs
        ]
        await asyncio.gather(*tasks, return_exceptions=True)

        return stats

    async def _check_and_download_poster(
        self, series_dir: Path, stats: dict[str, int]
    ) -> None:
        """Check and download poster for a single series folder.

        Args:
            series_dir: Path to the series folder.
            stats: Mutable stats dictionary to update.
        """
        stats["scanned"] += 1
        poster_path = series_dir / "poster.jpg"

        # Check if poster already exists and is large enough
        if poster_path.exists():
            try:
                # Default min_file_size from ImageDownloader is 1024 bytes (1 KB)
                if poster_path.stat().st_size >= 1024:
                    logger.debug(
                        "Poster already valid for '%s'", series_dir.name
                    )
                    stats["skipped"] += 1
                    return
            except OSError:
                pass  # Fall through to re-download

        # Parse NFO for thumb URL
        nfo_path = series_dir / "tvshow.nfo"
        poster_url = self._extract_poster_url_from_nfo(nfo_path)

        if not poster_url:
            logger.info(
                "No poster URL found in NFO for '%s', skipping",
                series_dir.name,
            )
            stats["skipped"] += 1
            return

        # Respect the nfo_download_poster setting
        from src.config.settings import settings as app_settings  # noqa: PLC0415

        if not app_settings.nfo_download_poster:
            logger.debug(
                "Poster download disabled by nfo_download_poster setting for '%s'",
                series_dir.name,
            )
            stats["skipped"] += 1
            return

        # Download poster with semaphore
        async with _POSTER_DOWNLOAD_SEMAPHORE:
            try:
                async with ImageDownloader() as downloader:
                    success = await downloader.download_poster(
                        poster_url, series_dir, skip_existing=False
                    )
                if success:
                    logger.info(
                        "Downloaded poster for '%s'", series_dir.name
                    )
                    stats["downloaded"] += 1
                else:
                    logger.warning(
                        "Failed to download poster for '%s'", series_dir.name
                    )
                    stats["errors"] += 1
            except Exception as exc:  # pylint: disable=broad-except
                logger.error(
                    "Error downloading poster for '%s': %s",
                    series_dir.name,
                    exc,
                )
                stats["errors"] += 1

    @staticmethod
    def _extract_poster_url_from_nfo(nfo_path: Path) -> Optional[str]:
        """Parse tvshow.nfo and extract the poster thumb URL.

        Prefers ``<thumb aspect="poster">``; falls back to the first
        ``<thumb>`` element if no aspect attribute is present.

        Args:
            nfo_path: Absolute path to the ``tvshow.nfo`` file.

        Returns:
            The poster URL string, or ``None`` if not found.
        """
        if not nfo_path.exists():
            return None

        try:
            tree = etree.parse(str(nfo_path))
            root = tree.getroot()

            # Prefer thumb with aspect="poster"
            for thumb in root.findall(".//thumb"):
                if thumb.get("aspect") == "poster" and thumb.text:
                    return thumb.text.strip()

            # Fallback to first thumb with text
            for thumb in root.findall(".//thumb"):
                if thumb.text:
                    return thumb.text.strip()

            return None
        except etree.XMLSyntaxError:
            logger.warning("Malformed XML in %s", nfo_path)
            return None
        except Exception:  # pylint: disable=broad-except
            return None

    # ------------------------------------------------------------------
    # Private helpers
    # ------------------------------------------------------------------

    def _prerequisites_met(self) -> bool:
        """Verify that the environment is ready for a folder scan.

        Returns:
            True when ``settings.anime_directory`` exists and
            ``settings.tmdb_api_key`` is configured.
        """
        from src.config.settings import settings  # noqa: PLC0415

        if not settings.tmdb_api_key:
            logger.warning("Folder scan skipped — TMDB API key not configured")
            return False

        if not settings.anime_directory:
            logger.warning("Folder scan skipped — anime directory not configured")
            return False

        anime_dir = Path(settings.anime_directory)
        if not anime_dir.is_dir():
            logger.warning(
                "Folder scan skipped — anime directory not found: %s", anime_dir
            )
            return False

        return True
