from __future__ import annotations

import re
from dataclasses import dataclass
from datetime import datetime, timezone

from mediathek_py.client import Mediathek
from mediathek_py.models import Item, SortField, SortOrder

_SXX_EXX_RE = re.compile(r"\(S(\d+)/E(\d+)\)")
_FOLGE_RE = re.compile(r"Folge\s+(\d+)")
_MAX_PAGES = 200
_VALID_FIELDS = frozenset({"title", "topic", "description", "channel", "date"})


@dataclass
class EpisodeInfo:
    season: int
    episode: int


@dataclass(frozen=True)
class ExtractionConfig:
    """Configuration for custom season/episode extraction from Item fields.

    Fields can be any Item attribute name (title, topic, description, channel)
    or the virtual field "date" which formats the timestamp as "YYYY-MM-DD".

    Patterns must be regexes with exactly one capture group that captures a number.
    When a pattern is None for a dimension, default title-based extraction is used
    as fallback (SXX/EXX and Folge N patterns).

    Validation is performed at construction time: invalid field names raise
    ValueError, and patterns are compiled to check for valid regex syntax
    and at least one capture group.
    """
    episode_field: str = "title"
    episode_pattern: str | None = None
    season_field: str = "title"
    season_pattern: str | None = None

    def __post_init__(self) -> None:
        for attr in ("episode_field", "season_field"):
            value = getattr(self, attr)
            if value not in _VALID_FIELDS:
                raise ValueError(
                    f"Invalid {attr} {value!r}; must be one of {sorted(_VALID_FIELDS)}"
                )
        for attr, label in (("episode_pattern", "episode"), ("season_pattern", "season")):
            pattern = getattr(self, attr)
            if pattern is not None:
                try:
                    compiled = re.compile(pattern)
                except re.error as e:
                    raise ValueError(
                        f"Invalid {label} pattern {pattern!r}: {e}"
                    ) from e
                if compiled.groups < 1:
                    raise ValueError(
                        f"{label.capitalize()} pattern {pattern!r} must contain "
                        f"at least one capture group"
                    )


@dataclass
class SeriesEpisode:
    item: Item
    info: EpisodeInfo

    def filename(self) -> str:
        return f"s{self.info.season:02d}e{self.info.episode:02d}.mp4"


def _get_field_value(item: Item, field: str) -> str:
    """Resolve a field name to its string value on an Item.

    The virtual field "date" formats the item's timestamp as "YYYY-MM-DD".
    Returns empty string for None field values.
    """
    if field == "date":
        return datetime.fromtimestamp(item.timestamp, tz=timezone.utc).strftime("%Y-%m-%d")
    value = getattr(item, field)
    return value if value is not None else ""


def _extract_number(text: str, pattern: str) -> int | None:
    """Apply a regex with one capture group and return the captured integer.

    Assumes the pattern has already been validated by ExtractionConfig.
    Returns None if the pattern doesn't match or the capture isn't numeric.
    """
    m = re.search(pattern, text)
    if m is None:
        return None
    try:
        return int(m.group(1))
    except (ValueError, IndexError):
        return None


def parse_episode_info(title: str) -> EpisodeInfo | None:
    """Extract season and episode from a title string.

    Tries (SXX/EXX) format first, then falls back to 'Folge N' (season defaults to 1).
    Returns None if neither pattern matches.
    """
    m = _SXX_EXX_RE.search(title)
    if m:
        return EpisodeInfo(season=int(m.group(1)), episode=int(m.group(2)))

    m = _FOLGE_RE.search(title)
    if m:
        return EpisodeInfo(season=1, episode=int(m.group(1)))

    return None


def extract_episode_info(item: Item, config: ExtractionConfig | None = None) -> EpisodeInfo | None:
    """Extract season and episode from an Item using configurable extraction.

    When config is None, delegates to parse_episode_info on the item's title
    (backward-compatible default behavior).

    When config is provided, uses custom patterns for the dimensions that have them
    and falls back to default title-based parsing for dimensions without custom patterns.
    """
    if config is None:
        return parse_episode_info(item.title)

    # Custom extraction for each dimension
    season: int | None = None
    episode: int | None = None

    if config.season_pattern:
        text = _get_field_value(item, config.season_field)
        season = _extract_number(text, config.season_pattern)

    if config.episode_pattern:
        text = _get_field_value(item, config.episode_field)
        episode = _extract_number(text, config.episode_pattern)

    # Fall back to default title parsing for dimensions without custom patterns
    if season is None or episode is None:
        default = parse_episode_info(item.title)
        if default:
            if season is None and not config.season_pattern:
                season = default.season
            if episode is None and not config.episode_pattern:
                episode = default.episode

    if season is None or episode is None:
        return None

    return EpisodeInfo(season=season, episode=episode)


def collect_series(
    client: Mediathek, query: str, *, config: ExtractionConfig | None = None, page_size: int = 50
) -> list[SeriesEpisode]:
    """Collect all episodes matching a query, paginating through the full result set.

    The query string supports the same prefix syntax as the search command:
        !channel  #topic  +title  *description  >min_dur  <max_dur
    Unprefixed text searches topic and title. See Mediathek.build_from_string()
    for full syntax documentation.

    Returns episodes sorted by (season, episode), deduplicated by (season, episode)
    keeping the earliest-timestamp occurrence (since results are fetched in timestamp
    ascending order).  Items whose titles can't be parsed are skipped.

    Pagination is capped at _MAX_PAGES requests to prevent runaway loops if the API
    returns inconsistent total counts.
    """
    episodes: list[SeriesEpisode] = []
    seen: set[tuple[int, int]] = set()
    offset = 0

    for _ in range(_MAX_PAGES):
        result = (
            client.build_from_string(query)
            .sort_by(SortField.TIMESTAMP)
            .sort_order(SortOrder.ASCENDING)
            .size(page_size)
            .offset(offset)
            .execute()
        )

        if not result.results:
            break

        for item in result.results:
            info = extract_episode_info(item, config)
            if info is None:
                continue
            key = (info.season, info.episode)
            if key in seen:
                continue
            seen.add(key)
            episodes.append(SeriesEpisode(item=item, info=info))

        offset += page_size
        if offset >= result.query_info.total_results:
            break

    episodes.sort(key=lambda ep: (ep.info.season, ep.info.episode))
    return episodes
