"""Folder rename service for validating and renaming series folders.

After NFO repair, this service iterates over every subfolder in
``settings.anime_directory`` that contains a ``tvshow.nfo``. For each
folder it parses the NFO to extract ``<title>`` and ``<year>``, computes
the expected folder name ``f"{title} ({year})"``, sanitises it for
filesystem safety, and renames the folder if the current name differs.

Database records (``AnimeSeries.folder``, ``Episode.file_path``,
``DownloadQueueItem.file_destination``) are updated atomically to
reflect the new paths.
"""
from __future__ import annotations

import logging
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple

from lxml import etree

from src.config.settings import settings
from src.server.database.connection import get_db_session
from src.server.database.service import (
    AnimeSeriesService,
    DownloadQueueService,
    EpisodeService,
)
from src.server.utils.dependencies import get_download_service
from src.server.utils.filesystem import sanitize_folder_name

logger = logging.getLogger(__name__)

# Characters that are invalid in filesystem paths across platforms
INVALID_PATH_CHARS = '<>:"/\\|?*\x00'


class DuplicateGroup:
    """Represents a group of duplicate folders for the same series.

    Attributes:
        key: The series key (folder name before rename).
        folders: List of folder paths that map to this series.
        nfo_paths: List of corresponding NFO file paths.
    """

    def __init__(self, key: str, folders: List[str], nfo_paths: List[Path]):
        self.key = key
        self.folders = folders
        self.nfo_paths = nfo_paths

    @property
    def count(self) -> int:
        return len(self.folders)

    def __repr__(self) -> str:
        return f"DuplicateGroup(key={self.key!r}, folders={self.folders})"


def _scan_for_pre_existing_duplicates(anime_dir: Path) -> List[DuplicateGroup]:
    """Scan anime directory for pre-existing duplicate folders.

    Groups folders by the series key extracted from their NFO files.
    Folders with the same title+year (same expected name) are flagged as duplicates.

    Args:
        anime_dir: Path to the anime directory to scan.

    Returns:
        List of DuplicateGroup objects, one per series with duplicate folders.
    """
    # Group folders by their expected name (title+year from NFO)
    groups: Dict[str, List[Tuple[str, Path]]] = defaultdict(list)

    for series_dir in anime_dir.iterdir():
        if not series_dir.is_dir():
            continue
        nfo_path = series_dir / "tvshow.nfo"
        if not nfo_path.exists():
            continue
        title, year = _parse_nfo_title_and_year(nfo_path)
        if not title or not year:
            continue
        expected_name = _compute_expected_folder_name(title, year)
        groups[expected_name].append((series_dir.name, nfo_path))

    # Filter to only groups with more than one folder
    duplicates = []
    for key, items in groups.items():
        if len(items) > 1:
            folders = [item[0] for item in items]
            nfo_paths = [item[1] for item in items]
            duplicates.append(DuplicateGroup(key=key, folders=folders, nfo_paths=nfo_paths))

    return duplicates


def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) -> bool:
    """Attempt to merge a duplicate group automatically.

    Uses the first folder as the canonical one and removes others if they are
    empty or contain only symlinks.

    Args:
        group: The DuplicateGroup to merge.
        dry_run: If True, only log actions without executing them.

    Returns:
        True if merge was successful, False otherwise.
    """
    if len(group.folders) < 2:
        return True

    # Keep first folder as canonical, mark others for removal
    canonical = group.folders[0]
    to_remove = group.folders[1:]

    for folder in to_remove:
        folder_path = group.nfo_paths[0].parent.parent / folder  # same parent dir
        if not folder_path.exists():
            continue

        # Check if folder is empty or only has symlinks
        try:
            contents = list(folder_path.iterdir())
        except PermissionError:
            logger.warning("Permission denied accessing %s, skip merge", folder_path)
            return False
        except OSError:
            return False

        if not contents:
            # Empty folder - safe to remove
            if dry_run:
                logger.info("[DRY-RUN] Would delete empty duplicate folder: %s", folder_path)
            else:
                try:
                    folder_path.rmdir()
                    logger.info("Deleted empty duplicate folder: %s", folder_path)
                except OSError:
                    return False
            continue

        # Check if all contents are symlinks pointing to canonical
        all_symlinks = all(
            item.is_symlink() and item.resolve() == (folder_path.parent / canonical).resolve()
            for item in contents
        )
        if all_symlinks:
            if dry_run:
                logger.info("[DRY-RUN] Would remove symlinks in duplicate folder: %s", folder_path)
            else:
                for item in contents:
                    item.unlink()
                try:
                    folder_path.rmdir()
                    logger.info("Removed symlink-only duplicate folder: %s", folder_path)
                except OSError:
                    return False
            continue

        # Cannot auto-merge - requires manual intervention
        logger.warning(
            "Cannot auto-merge duplicate folders for '%s': %s (manual merge required)",
            group.key,
            [canonical] + to_remove,
        )
        return False

    return True


def _parse_nfo_title_and_year(nfo_path: Path) -> Tuple[Optional[str], Optional[str]]:
    """Parse a tvshow.nfo and return (title, year) text values.

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

    Returns:
        Tuple of (title, year) where either may be ``None`` if missing
        or empty.
    """
    try:
        tree = etree.parse(str(nfo_path))
        root = tree.getroot()

        title_elem = root.find("./title")
        year_elem = root.find("./year")

        title = title_elem.text.strip() if title_elem is not None and title_elem.text and title_elem.text.strip() else None
        year = year_elem.text.strip() if year_elem is not None and year_elem.text and year_elem.text.strip() else None

        return title, year
    except etree.XMLSyntaxError as exc:
        logger.warning("Malformed XML in %s: %s", nfo_path, exc)
        return None, None
    except Exception as exc:  # pylint: disable=broad-except
        logger.warning("Unexpected error parsing %s: %s", nfo_path, exc)
        return None, None


def _compute_expected_folder_name(title: str, year: str) -> str:
    """Compute the expected folder name from title and year.

    Removes any existing year suffixes (e.g., "(2021)") before adding the
    canonical one to prevent duplication across multiple folder rename runs.

    Args:
        title: Series title from NFO.
        year: Release year from NFO.

    Returns:
        Sanitised folder name in the format ``"{title} ({year})"``.
    """
    import re

    # Remove all trailing year suffixes to prevent duplication.
    # This handles cases where the title already contains one or more years.
    # Regex pattern: matches one or more " (YYYY)" at the end of the string
    clean_title = re.sub(r'(\s*\(\d{4}\))+\s*$', '', title).strip()

    year_suffix = f" ({year})"
    raw_name = f"{clean_title}{year_suffix}"
    return sanitize_folder_name(raw_name)


def _is_series_being_downloaded(series_folder: str) -> bool:
    """Check whether the given series has an active or pending download.

    Args:
        series_folder: The series folder name (as stored in the DB).

    Returns:
        ``True`` if the series appears in the active download or the
        pending queue.
    """
    try:
        download_service = get_download_service()
        active = download_service._active_download  # pylint: disable=protected-access
        if active and active.serie_folder == series_folder:
            return True
        for item in download_service._pending_queue:  # pylint: disable=protected-access
            if item.serie_folder == series_folder:
                return True
        return False
    except Exception as exc:  # pylint: disable=broad-except
        logger.warning(
            "Could not check download status for %s: %s", series_folder, exc
        )
        # Safer to skip renaming if we can't verify download status.
        return True


def _cleanup_stale_files_after_rename(new_path: Path, new_name: str) -> None:
    """Remove legacy 'key' file after successful folder rename.

    Also checks for orphaned folders with the same key that may have been
    left behind from previous rename operations.

    Args:
        new_path: The new folder path after rename.
        new_name: The new folder name.
    """
    key_file = new_path / "key"
    if key_file.exists():
        try:
            key_file.unlink()
            logger.info(
                "Removed legacy 'key' file after rename: %s", key_file
            )
        except OSError as exc:
            logger.warning(
                "Could not remove legacy 'key' file %s: %s", key_file, exc
            )


def _cleanup_orphaned_folder(old_path: Path, new_path: Path, dry_run: bool = False) -> bool:
    """Clean up orphaned folder after successful rename.

    After a folder is successfully renamed to new_path, this function checks
    if the old_path still exists (orphaned folder) and removes it. If the
    old folder contains files, they are moved to new_path before deletion.

    Args:
        old_path: The original folder path before rename.
        new_path: The new folder path after rename.
        dry_run: If True, only log actions without executing them.

    Returns:
        True if old folder was cleaned up (or would be in dry-run mode),
        False if old folder does not exist or cleanup failed.
    """
    if not old_path.exists():
        logger.debug(
            "Old folder does not exist, no cleanup needed: %s", old_path
        )
        return False

    # Check if folder is empty
    try:
        contents = list(old_path.iterdir())
    except PermissionError as exc:
        logger.warning(
            "Permission denied accessing old folder %s: %s", old_path, exc
        )
        return False
    except OSError as exc:
        logger.warning(
            "OS error accessing old folder %s: %s", old_path, exc
        )
        return False

    if not contents:
        # Empty folder — delete it
        if dry_run:
            logger.info(
                "[DRY-RUN] Would delete empty orphaned folder: %s", old_path
            )
            return True
        try:
            old_path.rmdir()
            logger.info("Deleted empty orphaned folder: %s", old_path)
            return True
        except PermissionError as exc:
            logger.warning(
                "Permission denied deleting folder %s: %s", old_path, exc
            )
            return False
        except OSError as exc:
            logger.warning(
                "OS error deleting folder %s: %s", old_path, exc
            )
            return False

    # Folder has contents — move files to new_path then delete
    if dry_run:
        logger.info(
            "[DRY-RUN] Would move %d files from orphaned folder %s to %s",
            len(contents), old_path, new_path
        )
        for item in contents:
            logger.info("[DRY-RUN] Would move: %s → %s", item, new_path / item.name)
        logger.info("[DRY-RUN] Would then delete orphaned folder: %s", old_path)
        return True

    files_moved = 0
    errors = 0
    for item in contents:
        try:
            dest = new_path / item.name
            item.rename(dest)
            logger.debug("Moved %s → %s", item, dest)
            files_moved += 1
        except PermissionError as exc:
            logger.warning(
                "Permission denied moving %s: %s", item, exc
            )
            errors += 1
        except OSError as exc:
            logger.warning(
                "OS error moving %s: %s", item, exc
            )
            errors += 1

    if files_moved > 0:
        logger.info(
            "Moved %d files from orphaned folder to %s",
            files_moved, new_path
        )

    # Delete the now-empty old folder
    try:
        old_path.rmdir()
        logger.info("Deleted orphaned folder after moving contents: %s", old_path)
        return errors == 0
    except OSError as exc:
        logger.warning(
            "Could not delete orphaned folder %s (may not be empty): %s",
            old_path, exc
        )
        return False


async def _update_database_paths(
    old_folder: str,
    new_folder: str,
    anime_dir: Path,
) -> None:
    """Update all database records that reference the old folder path.

    Updates:
    - ``AnimeSeries.folder`` → ``new_folder``
    - ``Episode.file_path`` → adjusted to new folder
    - ``DownloadQueueItem.file_destination`` → adjusted to new folder

    Args:
        old_folder: Previous folder name.
        new_folder: New folder name.
        anime_dir: Root anime directory path.
    """
    old_series_path = anime_dir / old_folder
    new_series_path = anime_dir / new_folder

    async with get_db_session() as db:
        # 1. Update AnimeSeries.folder
        series = await AnimeSeriesService.get_by_key(db, old_folder)
        if series is None:
            # Fallback: try to find by folder name
            all_series = await AnimeSeriesService.get_all(db)
            for s in all_series:
                if s.folder == old_folder:
                    series = s
                    break

        if series is None:
            logger.warning(
                "No database record found for folder '%s', skipping DB update",
                old_folder,
            )
            return

        await AnimeSeriesService.update(db, series.id, folder=new_folder)
        logger.info(
            "Updated AnimeSeries.folder: %s → %s (id=%s)",
            old_folder,
            new_folder,
            series.id,
        )

        # 2. Update Episode.file_path for all episodes of this series
        episodes = await EpisodeService.get_by_series(db, series.id)
        for episode in episodes:
            if episode.file_path:
                old_file_path = Path(episode.file_path)
                # Only update if the path is under the old series folder
                try:
                    old_file_path.relative_to(old_series_path)
                    new_file_path = new_series_path / old_file_path.relative_to(
                        old_series_path
                    )
                    episode.file_path = str(new_file_path)
                    logger.debug(
                        "Updated Episode.file_path: %s → %s",
                        old_file_path,
                        new_file_path,
                    )
                except ValueError:
                    # Path is not under old_series_path, skip
                    pass

        await db.flush()

        # 3. Update DownloadQueueItem.file_destination for pending items
        queue_items = await DownloadQueueService.get_all(db, with_series=True)
        for item in queue_items:
            if item.series_id == series.id and item.file_destination:
                old_dest = Path(item.file_destination)
                try:
                    old_dest.relative_to(old_series_path)
                    new_dest = new_series_path / old_dest.relative_to(
                        old_series_path
                    )
                    item.file_destination = str(new_dest)
                    logger.debug(
                        "Updated DownloadQueueItem.file_destination: %s → %s",
                        old_dest,
                        new_dest,
                    )
                except ValueError:
                    pass

        await db.flush()
        logger.info(
            "Database paths updated for series '%s' → '%s'",
            old_folder,
            new_folder,
        )


async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str, int]:
    """Validate and rename series folders to match NFO metadata.

    Iterates over every subfolder in ``settings.anime_directory`` that
    contains a ``tvshow.nfo``. For each folder:

    1. Parse the NFO to extract ``<title>`` and ``<year>``.
    2. Compute the expected folder name: ``f"{title} ({year})"``.
    3. Sanitise the expected name for filesystem safety.
    4. Compare with the current folder name.
    5. If different, rename the folder and update the database.

    Skips folders where title or year is missing/empty. Logs every
    rename action.

    Args:
        dry_run: If True, simulate rename operations without actually
            moving folders or updating the database.

    Returns:
        Dictionary with counts:
        - ``"scanned"``: total folders scanned
        - ``"renamed"``: folders renamed
        - ``"skipped"``: folders skipped (missing title/year)
        - ``"errors"``: folders that caused an error
    """
    if not settings.anime_directory:
        logger.warning("Folder rename skipped — anime directory not configured")
        return {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}

    anime_dir = Path(settings.anime_directory)
    if not anime_dir.is_dir():
        logger.warning(
            "Folder rename skipped — anime directory not found: %s", anime_dir
        )
        return {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}

    if dry_run:
        logger.info("Running in DRY-RUN mode — no changes will be made")

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

    # Detect pre-existing duplicates before rename loop
    pre_existing_duplicates: Set[str] = set()
    duplicates = _scan_for_pre_existing_duplicates(anime_dir)
    for dup_group in duplicates:
        # Try automatic merge first
        if _try_merge_duplicate_group(dup_group, dry_run=dry_run):
            logger.info(
                "Auto-merged duplicate group for '%s' (%d folders)",
                dup_group.key,
                dup_group.count,
            )
        else:
            # Flag all folders in this group as pre-existing duplicates
            for folder in dup_group.folders:
                pre_existing_duplicates.add(folder)
            logger.warning(
                "Duplicate folders detected for series '%s': %s — "
                "manual cleanup required (different releases or non-empty duplicates)",
                dup_group.key,
                dup_group.folders,
            )

    for series_dir in sorted(anime_dir.iterdir()):
        if not series_dir.is_dir():
            continue

        nfo_path = series_dir / "tvshow.nfo"
        if not nfo_path.exists():
            continue

        stats["scanned"] += 1

        title, year = _parse_nfo_title_and_year(nfo_path)
        if not title or not year:
            logger.info(
                "Skipping rename for '%s' — missing title or year in NFO",
                series_dir.name,
            )
            stats["skipped"] += 1
            continue

        expected_name = _compute_expected_folder_name(title, year)
        current_name = series_dir.name

        if expected_name == current_name:
            logger.debug(
                "Folder name already correct: '%s'", current_name
            )
            continue

        # Check for active downloads
        if _is_series_being_downloaded(current_name):
            logger.info(
                "Skipping rename for '%s' — series has active or pending downloads",
                current_name,
            )
            stats["skipped"] += 1
            continue

        expected_path = anime_dir / expected_name

        # Check for pre-existing duplicate
        if current_name in pre_existing_duplicates:
            logger.warning(
                "Skipping rename for '%s' — pre-existing duplicate folder detected",
                current_name,
            )
            stats["errors"] += 1
            continue

        # Check for duplicate target
        if expected_path.exists():
            logger.warning(
                "Cannot rename '%s' → '%s' — target already exists",
                current_name,
                expected_name,
            )
            # Target folder exists — remove source folder and delete its DB record
            # (target folder's DB record survives, source folder's record must be removed
            #  to avoid orphaning episodes/downloads)
            try:
                import shutil

                logger.warning(
                    "Removing source duplicate folder '%s' — target '%s' already exists",
                    current_name,
                    expected_name,
                )
                shutil.rmtree(series_dir)
                logger.info(
                    "Removed source folder '%s' — series already exists at target",
                    current_name,
                )

                # Delete source DB record (cascades to episodes and download items)
                async with get_db_session() as db:
                    source_series = await AnimeSeriesService.get_by_key(db, current_name)
                    if source_series is None:
                        # Fallback: find by folder name
                        all_series = await AnimeSeriesService.get_all(db)
                        for s in all_series:
                            if s.folder == current_name:
                                source_series = s
                                break
                    if source_series is not None:
                        await AnimeSeriesService.delete(db, source_series.id)
                        logger.info(
                            "Deleted source DB record for '%s' (id=%s) — target folder '%s' retains DB record",
                            current_name,
                            source_series.id,
                            expected_name,
                        )
                    else:
                        logger.info(
                            "No DB record found for source folder '%s' — folder removed only",
                            current_name,
                        )

                stats["renamed"] += 1
            except OSError as exc:
                logger.error(
                    "Failed to remove source folder '%s': %s",
                    current_name,
                    exc,
                )
                stats["errors"] += 1
            continue

        # Check path length limits
        if len(str(expected_path)) > 4096:
            logger.warning(
                "Cannot rename '%s' → '%s' — path exceeds OS limit",
                current_name,
                expected_name,
            )
            stats["errors"] += 1
            continue

        if dry_run:
            logger.info(
                "[DRY-RUN] Would rename folder: '%s' → '%s'",
                current_name,
                expected_name,
            )
            stats["renamed"] += 1
            continue

        try:
            old_path = series_dir
            series_dir.rename(expected_path)
            logger.info(
                "Renamed folder: '%s' → '%s'", current_name, expected_name
            )
            stats["renamed"] += 1

            # Update database records
            await _update_database_paths(current_name, expected_name, anime_dir)

            # Clean up stale/legacy files after successful rename
            _cleanup_stale_files_after_rename(expected_path, expected_name)

            # Clean up orphaned folder if old path still exists
            _cleanup_orphaned_folder(old_path, expected_path, dry_run=False)

        except PermissionError as exc:
            logger.error(
                "Permission denied renaming '%s' → '%s': %s",
                current_name,
                expected_name,
                exc,
            )
            stats["errors"] += 1
        except OSError as exc:
            logger.error(
                "OS error renaming '%s' → '%s': %s",
                current_name,
                expected_name,
                exc,
            )
            stats["errors"] += 1

    logger.info(
        "Folder rename scan complete: scanned=%d, renamed=%d, skipped=%d, errors=%d",
        stats["scanned"],
        stats["renamed"],
        stats["skipped"],
        stats["errors"],
    )
    return stats
