import logging
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from os.path import basename
from typing import Any, Optional, TypedDict, Tuple, Pattern, Callable

import PTT
import dateparser

from db.models import TorrentStreams, EpisodeFile, KnownFile, MediaFusionSeriesMetaData
from streaming_providers.exceptions import ProviderException
from utils.lock import acquire_redis_lock
from utils.runtime_const import DATE_STR_REGEX
from utils.telegram_bot import telegram_notifier
from utils.validation_helper import is_video_file

logger = logging.getLogger(__name__)

# Precompile all regex patterns for better performance
SEASON_EPISODE_PATTERNS: dict[
    str, Tuple[Pattern, Callable[[re.Match, int], Tuple[int, int]]]
] = {
    # Pattern 1: SxxExx format (most common and reliable)
    # Examples: "S01E04", "s01e04", "S1E04", "s1e04"
    "standard": (
        re.compile(r"[sS](\d{1,2})[eE](\d{1,2})"),
        lambda m, _: (int(m.group(1)), int(m.group(2))),
    ),
    # Pattern 2: xxXxx format
    # Examples: "1x01", "01x01", "1X01", "01X01"
    "separator_x": (
        re.compile(r"(?<!\w)(\d{1,2})[xX](\d{1,2})(?!\w)"),
        lambda m, _: (int(m.group(1)), int(m.group(2))),
    ),
    # Pattern 3: Season X Episode Y format (text-based)
    # Example: "Season 1 Episode 04", "season 01 episode 4"
    "text_based": (
        re.compile(r"[sS]eason\s+(\d{1,2}).*?[eE]pisode\s+(\d{1,2})"),
        lambda m, _: (int(m.group(1)), int(m.group(2))),
    ),
    # Pattern 4: Season/Series followed by episode number
    # Example: "Season 02 - 05", "Series.1.Ep.03"
    "season_ep": (
        re.compile(
            r"(?:[sS]eason|[sS]eries)[.\s-]*(\d{1,2}).*?(?:[eE]p|[eE]pisode)?[.\s-]*(\d{1,2})"
        ),
        lambda m, _: (int(m.group(1)), int(m.group(2))),
    ),
    # Pattern 5: SXXEXX format (no separator)
    # Examples: "S01E04", "S1E4"
    "no_separator": (
        re.compile(r"S(\d{1,2})E(\d{1,2})", re.IGNORECASE),
        lambda m, _: (int(m.group(1)), int(m.group(2))),
    ),
    # Pattern 6: Show name followed by episode number
    # Examples: "Show 01", "Show - 01", "Show.01"
    # Only apply this when it's likely an actual episode number, not part of a hash
    "simple_episode": (
        re.compile(r"(?<=\s)(\d{1,2})(?:\s|$|\.)"),
        lambda m, s: (s, int(m.group(1))),  # Default to season s
    ),
    # Pattern 7: bracketed season and episode
    # Examples: "[S01E04]", "(S1.E4)", "[1x04]"
    "bracketed": (
        re.compile(r"[\[\(](?:[sS])?(\d{1,2})[.\s]?(?:[eExX])(\d{1,2})[\]\)]"),
        lambda m, _: (int(m.group(1)), int(m.group(2))),
    ),
    # Pattern 8: season and episode separated by period
    # Examples: "1.04", "01.04"
    "period_sep": (
        re.compile(r"(?<!\d|\w)(\d{1,2})\.(\d{2})(?!\d|\w)"),
        lambda m, _: (int(m.group(1)), int(m.group(2))),
    ),
    # Pattern 9: episode identifier with underscore or dash
    # Examples: "_e01", "-e01", "_ep01", "-ep01"
    "episode_only": (
        re.compile(r"[_-][eE](?:p)?(\d{1,2})"),
        lambda m, s: (s, int(m.group(1))),  # Default to season s
    ),
    # Pattern 10: Absolute episode numbering
    # Examples: "Episode.123", "Ep 123", "EP.123"
    "absolute_ep": (
        re.compile(r"[eE]p(?:isode)?[.\s](\d{1,3})(?!\w)", re.IGNORECASE),
        lambda m, s: (s, int(m.group(1))),  # Default to season s
    ),
    # Pattern 11: Zero-padded episode numbers
    # Examples: "00022"
    "zero_padded": (
        re.compile(r"(?<!\d)(\d{2,})(?!\d)"),
        lambda m, s: (s, int(m.group(1))),  # Default to season s
    ),
}
# Additional patterns specifically for anime that should be tried after the general patterns
ANIME_PATTERNS: dict[
    str, Tuple[Pattern, Callable[[re.Match, int], Tuple[int, int]]]
] = {
    # Common pattern for standalone anime episodes, avoid matching hashes
    # This should identify patterns like " 01 ", " - 01", etc.
    "standalone_episode": (
        re.compile(r"(?:^|\s|\[|\()(\d{1,2})(?:\s|$|\]|\))"),
        lambda m, s: (s, int(m.group(1))),  # Default to season s
    ),
}


class TorrentFile(TypedDict):
    name: str
    size: int


@dataclass
class FileInfo:
    index: int
    filename: str
    size: int
    season: Optional[int] = None
    episode: Optional[int] = None
    is_video: bool = False

    @classmethod
    def from_torrent_file(
        cls,
        index: int,
        file_data: dict[str, Any],
        name_key: str = "name",
        size_key: str = "size",
    ) -> "FileInfo":
        filename = basename(file_data[name_key])
        return cls(
            index=index,
            filename=filename,
            size=file_data[size_key],
            is_video=is_video_file(filename),
        )


class TorrentFileProcessor:
    def __init__(
        self,
        torrent_info: dict[str, Any],
        file_key: str = "files",
        name_key: str = "name",
        size_key: str = "size",
    ):
        self.torrent_info = torrent_info
        self.file_key = file_key
        self.name_key = name_key
        self.size_key = size_key
        self.files = torrent_info[file_key]
        self.file_infos = self._process_files()
        self._video_files: Optional[list[FileInfo]] = None
        self._episodes: Optional[list[EpisodeFile]] = None
        self._metadata: Optional[MediaFusionSeriesMetaData] = None

    def _process_files(self) -> list[FileInfo]:
        """Process all files in the torrent and create FileInfo objects."""
        file_infos = []
        for idx, file in enumerate(self.files):
            file_info = FileInfo.from_torrent_file(
                idx, file, self.name_key, self.size_key
            )
            if file_info.is_video:
                # Don't parse season/episode info here - do it only when needed
                file_infos.append(file_info)

        return file_infos

    def get_video_files(self) -> list[FileInfo]:
        """Get all video files from the torrent, caching the result."""
        if self._video_files is None:
            self._video_files = [f for f in self.file_infos if f.is_video]
        return self._video_files

    def get_largest_video_file(self) -> Optional[FileInfo]:
        """Get the largest video file from the torrent."""
        video_files = self.get_video_files()
        return max(video_files, key=lambda x: x.size) if video_files else None

    async def _parse_season_episode_info(
        self,
        file_info: FileInfo,
        torrent_title: str,
        meta_id: str,
        default_season: Optional[int] = 1,
    ) -> tuple[Optional[int], Optional[int]]:
        """Parse season and episode information from filename and torrent title."""
        # First try from filename with PTT
        parsed_data = PTT.parse_title(file_info.filename)
        seasons = parsed_data.get("seasons", [])
        episodes = parsed_data.get("episodes", [])

        if seasons and episodes:
            return seasons[0], episodes[0]

        # For season packs without explicit season number
        if not seasons and episodes:
            return default_season, episodes[0]

        # If no season/episode found, try from torrent title
        title_parsed = PTT.parse_title(torrent_title)
        title_seasons = title_parsed.get("seasons", [])
        title_episodes = title_parsed.get("episodes", [])

        # If only one season in title, use it
        if len(title_seasons) == 1 and len(title_episodes) == 1:
            return title_seasons[0], title_episodes[0]

        # if we have date in the title, use it to find the episode from metadata
        if not parsed_data.get("date"):
            date_str_match = DATE_STR_REGEX.search(file_info.filename)
            if date_str_match:
                parsed_data["date"] = dateparser.parse(
                    date_str_match.group(0)
                ).strftime("%Y-%m-%d")

        if parsed_data.get("date"):
            if not self._metadata:
                self._metadata = await MediaFusionSeriesMetaData.get(meta_id)

            filtered_episode = next(
                (
                    episode
                    for episode in self._metadata.episodes
                    if episode.released.strftime("%Y-%m-%d") == parsed_data["date"]
                ),
                None,
            )
            if filtered_episode:
                return filtered_episode.season_number, filtered_episode.episode_number

            # if we have date in the title but no metadata, return None to avoid false detection
            return None, None

        # If PTT failed to identify season or episode, try the fallback parser
        season, episode = fallback_parse_season_episode(
            file_info.filename, default_season
        )

        # Log when fallback parser is used
        if season is not None or episode is not None:
            logger.info(
                f"Used fallback parser for file '{file_info.filename}'. "
                f"Detected: S{season}E{episode}"
            )

        return season, episode

    async def find_specific_episode(
        self, season: int, episode: int, torrent_title: str, meta_id: str
    ) -> Optional[FileInfo]:
        """Find a specific episode in the video files."""
        if self._episodes:
            for episode_file in self._episodes:
                if (
                    episode_file.season_number == season
                    and episode_file.episode_number == episode
                    and 0 <= episode_file.file_index < len(self.file_infos)
                ):
                    return self.file_infos[episode_file.file_index]

        video_files = self.get_video_files()
        for file_info in video_files:
            parsed_season, parsed_episode = await self._parse_season_episode_info(
                file_info, torrent_title, meta_id, default_season=season
            )
            if parsed_season == season and parsed_episode == episode:
                return file_info
        if len(video_files):
            # in some cases only one video file is present and no season/episode info
            return video_files[0]
        return None

    async def parse_all_episodes(
        self, torrent_title: str, meta_id: str, default_season: Optional[int] = None
    ) -> list[EpisodeFile]:
        """Parse all episode information from video files."""
        episodes = []
        for file_info in self.get_video_files():
            season, episode = await self._parse_season_episode_info(
                file_info, torrent_title, meta_id, default_season
            )
            if season and episode:
                episodes.append(
                    EpisodeFile(
                        season_number=season,
                        episode_number=episode,
                        filename=file_info.filename,
                        size=file_info.size,
                        file_index=file_info.index,
                    )
                )
        self._episodes = episodes
        return episodes

    def find_file_by_name(self, filename: str) -> Optional[FileInfo]:
        """Find a file by its filename."""
        for file_info in self.file_infos:
            if file_info.filename == filename:
                return file_info
        return None


async def select_file_index_from_torrent(
    torrent_info: dict[str, Any],
    torrent_stream: TorrentStreams,
    filename: Optional[str] = None,
    season: Optional[int] = None,
    episode: Optional[int] = None,
    file_key: str = "files",
    name_key: str = "name",
    size_key: str = "size",
    file_size_callback: Optional[callable] = None,
    is_filename_trustable: bool = False,
    is_index_trustable: bool = False,
) -> int:
    """
    Select the file index from the torrent info with minimal processing.
    Only processes all files if initial filename match fails.
    """
    files = torrent_info[file_key]

    # Quick filename match without full processing
    if filename:
        for idx, file in enumerate(files):
            file_name = basename(file[name_key])
            if file_name == filename:
                return idx

    # Initialize processor for further processing
    # get file sizes if callback provided
    if file_size_callback:
        await file_size_callback(files)
    processor = TorrentFileProcessor(torrent_info, file_key, name_key, size_key)

    # Check if there are any video files
    video_files = processor.get_video_files()
    if not video_files:
        if is_filename_trustable:
            torrent_stream.is_blocked = True
            await torrent_stream.save()
            raise ProviderException(
                "No valid video files found in torrent. Torrent has been blocked.",
                "no_matching_file.mp4",
            )
        else:
            raise ProviderException(
                "No valid video files found in torrent",
                "no_matching_file.mp4",
            )

    # Update metadata if filename is trustable
    if is_filename_trustable:
        await update_torrent_streams_metadata(
            torrent_stream=torrent_stream,
            processor=processor,
            season=season,
            is_index_trustable=is_index_trustable,
        )

    # Try to find by season/episode
    if season and episode:
        selected_file = await processor.find_specific_episode(
            season, episode, torrent_stream.torrent_name, torrent_stream.meta_id
        )
        if selected_file:
            return selected_file.index
        else:
            # Found video files but couldn't match season/episode
            raise ProviderException(
                "Found video files but couldn't match season/episode. "
                "Annotation has been requested.",
                "episode_not_found.mp4",
            )

    # Default to largest video file
    selected_file = processor.get_largest_video_file()
    if selected_file:
        return selected_file.index

    raise ProviderException(
        "No valid video file found in torrent",
        "no_matching_file.mp4",
    )


async def update_torrent_streams_metadata(
    torrent_stream: TorrentStreams,
    processor: TorrentFileProcessor,
    season: Optional[int] = None,
    is_index_trustable: bool = False,
) -> bool:
    """
    Update torrent stream metadata using the provided processor instance.
    Returns True if update was successful, False if annotation was requested.
    """
    # Check if annotation was recently requested with redis lock for 3 days
    acquired, _ = await acquire_redis_lock(
        f"annotation_lock_{torrent_stream.id}", timeout=259200, block=False
    )
    if not acquired:
        logger.info(
            f"Skipping metadata update for {torrent_stream.id} - recent annotation request"
        )
        return False

    try:
        if season is None:
            # Movie handling
            file_info = processor.get_largest_video_file()
            if not file_info:
                torrent_stream.is_blocked = True
                await torrent_stream.save()
                logger.error(f"No video files found in torrent {torrent_stream.id}")
                return False

            torrent_stream.filename = file_info.filename
            if is_index_trustable:
                torrent_stream.file_index = file_info.index
            torrent_stream.size = file_info.size

        else:
            # Series handling
            video_files = processor.get_video_files()
            if not video_files:
                torrent_stream.is_blocked = True
                await torrent_stream.save()
                logger.error(f"No video files found in torrent {torrent_stream.id}")
                return False

            episodes = await processor.parse_all_episodes(
                torrent_stream.torrent_name,
                torrent_stream.meta_id,
                default_season=season,
            )

            if not episodes:
                if len(video_files) == 1 and len(torrent_stream.episode_files) == 1:
                    episodes = torrent_stream.episode_files
                    episodes[0].filename = video_files[0].filename
                    episodes[0].size = video_files[0].size
                    episodes[0].file_index = (
                        video_files[0].index if is_index_trustable else None
                    )
                else:
                    await _request_annotation(torrent_stream, processor.file_infos)
                    return False

            torrent_stream.episode_files = episodes
            total_size = sum(f.size for f in processor.get_video_files())
            if total_size > torrent_stream.size:
                torrent_stream.size = total_size

        torrent_stream.updated_at = datetime.now(tz=timezone.utc)
        await torrent_stream.save()
        logger.info(f"Successfully updated {torrent_stream.id} metadata")
        return True

    except Exception as e:
        logger.error(
            f"Error updating metadata for {torrent_stream.id}: {str(e)}", exc_info=True
        )
        return False


async def _request_annotation(
    torrent_stream: TorrentStreams, file_infos: list[FileInfo]
):
    """Request manual annotation for the torrent stream."""
    file_details = [KnownFile(filename=f.filename, size=f.size) for f in file_infos]
    torrent_stream.known_file_details = file_details
    await torrent_stream.save()

    # Notify contributors
    await telegram_notifier.send_file_annotation_request(
        torrent_stream.id, torrent_stream.torrent_name
    )
    logger.info(f"Requested annotation for {torrent_stream.id}")


def is_likely_hash(match_str: str, filename: str) -> bool:
    """
    Check if a matched string is likely part of a hash by looking at surrounding context.
    Returns True if it's likely a hash, False otherwise.
    """

    # If the match is surrounded by hash indicators or hex characters, it's likely a hash
    match_pos = filename.find(match_str)
    if match_pos == -1:
        return False

    # Check if it's inside brackets which often contain hashes
    bracket_match = re.search(
        r"\[[^\]]*" + re.escape(match_str) + r"[^\[]*\]", filename
    )
    if bracket_match:
        # If the bracketed content is short, it might be an episode indicator
        # If it's long, it's more likely a hash
        content = bracket_match.group(0)
        if len(content) > 10 and any(c.isalpha() for c in content):
            return True

    # If the match contains letters (like in hex), it's likely a hash
    if any(c.isalpha() for c in match_str):
        return True

    return False


def fallback_parse_season_episode(
    filename: str, default_season: int = 1
) -> tuple[Optional[int], Optional[int]]:
    """
    Fallback parser for season and episode information.
    Returns (season_number, episode_number) tuple where each can be None if not detected.

    Uses precompiled patterns stored in a global dictionary for better performance.
    Patterns are ordered from most reliable/common to least reliable/specific.
    """
    # Extract base filename without path and remove common extensions
    base_filename = re.sub(r"\.(mkv|mp4|avi|mov|wmv|flv)$", "", filename.split("/")[-1])

    # First try specific, high-confidence patterns
    for pattern_name, (regex, extractor) in SEASON_EPISODE_PATTERNS.items():
        match = regex.search(base_filename)
        if match:
            # Ensure the match isn't likely part of a hash
            if pattern_name in ["simple_episode"] and is_likely_hash(
                match.group(0), base_filename
            ):
                continue

            season, episode = extractor(match, default_season)
            return season, episode

    # Additional patterns specifically for anime titles should be matched only as a last resort
    # and avoided if the string looks like it contains a hash
    # First check if there's a standalone number that's likely an episode
    # But be very careful with anime files to avoid matching hash portions
    for pattern_name, (regex, extractor) in ANIME_PATTERNS.items():
        for match in regex.finditer(base_filename):
            # Skip if the match is inside a hash-like structure
            if is_likely_hash(match.group(0), base_filename):
                continue

            # Find position relative to the title
            # Look for numbers that appear after the anime title but before any hash
            match_pos = match.start()

            # Very simple heuristic: if it's early in the filename or has space around it
            # and is a small number (less than 100), it's likely an episode number
            if match_pos > 5 and int(match.group(1)) < 100:
                season, episode = extractor(match, default_season)
                return season, episode

    # No match found
    return None, None
