import json

import pytest
import respx

from mediathek_py.series import (
    EpisodeInfo,
    ExtractionConfig,
    SeriesEpisode,
    collect_series,
    extract_episode_info,
    parse_episode_info,
)
from mediathek_py.client import Mediathek
from mediathek_py.models import Item


BASE_URL = "https://mediathekviewweb.de"


def _make_item_dict(
    title, topic="Feuer & Flamme", channel="WDR", timestamp=1696269600, duration=2600,
    description="",
):
    """Build a realistic item dict for testing."""
    return {
        "channel": channel,
        "topic": topic,
        "title": title,
        "description": description,
        "timestamp": timestamp,
        "duration": duration,
        "size": 137363456,
        "url_website": "https://www.ardmediathek.de/video/1",
        "url_subtitle": "",
        "url_video": "https://media.example.de/video/medium.mp4",
        "url_video_low": "https://media.example.de/video/low.mp4",
        "url_video_hd": "https://media.example.de/video/hd.mp4",
        "filmlisteTimestamp": "1696339020",
        "id": f"id-{title[:10]}",
    }


def _make_api_response(items, total_results=None):
    """Wrap item dicts in a full API response envelope."""
    if total_results is None:
        total_results = len(items)
    return {
        "err": None,
        "result": {
            "queryInfo": {
                "filmlisteTimestamp": 1696361700,
                "resultCount": len(items),
                "searchEngineTime": "1.23",
                "totalResults": total_results,
            },
            "results": items,
        },
    }


class TestParseEpisodeInfo:
    def test_standard_sxx_exx_format(self):
        info = parse_episode_info("Folge 6: Explosion bei Brand (S06/E06)")
        assert info is not None
        assert info.season == 6
        assert info.episode == 6

    def test_sxx_exx_with_different_numbers(self):
        info = parse_episode_info("Folge 1: Feuer & Flamme (S01/E01)")
        assert info is not None
        assert info.season == 1
        assert info.episode == 1

    def test_sxx_exx_double_digit(self):
        info = parse_episode_info(
            "Folge 10: Seniorin stürzt auf Wanderung (S10/E10)"
        )
        assert info is not None
        assert info.season == 10
        assert info.episode == 10

    def test_folge_only_fallback(self):
        info = parse_episode_info("Folge 3: Some Episode Title")
        assert info is not None
        assert info.season == 1
        assert info.episode == 3

    def test_folge_only_high_number(self):
        info = parse_episode_info("Folge 42: Another Episode")
        assert info is not None
        assert info.season == 1
        assert info.episode == 42

    def test_trailer_returns_none(self):
        info = parse_episode_info("Trailer: Feuer & Flamme Staffel 10")
        assert info is None

    def test_unrelated_title_returns_none(self):
        info = parse_episode_info("tagesschau 20:00 Uhr")
        assert info is None

    def test_empty_title_returns_none(self):
        info = parse_episode_info("")
        assert info is None

    def test_sxx_exx_takes_precedence_over_folge(self):
        """When both patterns exist, (SXX/EXX) should win over Folge N for season."""
        info = parse_episode_info("Folge 3: Title (S02/E03)")
        assert info is not None
        assert info.season == 2
        assert info.episode == 3


class TestSeriesEpisode:
    def test_filename(self):
        item = Item.model_validate(
            _make_item_dict("Folge 6: Explosion bei Brand (S06/E06)")
        )
        ep = SeriesEpisode(item=item, info=EpisodeInfo(season=6, episode=6))
        assert ep.filename() == "s06e06.mp4"

    def test_filename_single_digit(self):
        item = Item.model_validate(
            _make_item_dict("Folge 1: Feuer & Flamme (S01/E01)")
        )
        ep = SeriesEpisode(item=item, info=EpisodeInfo(season=1, episode=1))
        assert ep.filename() == "s01e01.mp4"

    def test_filename_double_digit(self):
        item = Item.model_validate(
            _make_item_dict("Folge 10: Something (S10/E10)")
        )
        ep = SeriesEpisode(item=item, info=EpisodeInfo(season=10, episode=10))
        assert ep.filename() == "s10e10.mp4"

    def test_filename_year_season(self):
        """Year-based seasons (e.g. 2025) should not be truncated by :02d formatting."""
        item = Item.model_validate(
            _make_item_dict("15. Spieltag: Bayern vs Dortmund")
        )
        ep = SeriesEpisode(item=item, info=EpisodeInfo(season=2025, episode=15))
        assert ep.filename() == "s2025e15.mp4"


class TestCollectSeries:
    @respx.mock
    def test_collects_single_page(self):
        items = [
            _make_item_dict("Folge 1: Feuer & Flamme (S01/E01)", timestamp=100),
            _make_item_dict("Folge 2: Feuer & Flamme (S01/E02)", timestamp=200),
        ]
        respx.post(f"{BASE_URL}/api/query").respond(
            json=_make_api_response(items),
        )

        with Mediathek() as m:
            episodes = collect_series(m, "#Feuer,&,Flamme")

        assert len(episodes) == 2
        assert episodes[0].info.season == 1
        assert episodes[0].info.episode == 1
        assert episodes[1].info.season == 1
        assert episodes[1].info.episode == 2

    @respx.mock
    def test_paginates_across_pages(self):
        page1_items = [
            _make_item_dict(f"Folge {i}: Title (S01/E{i:02d})", timestamp=i * 100)
            for i in range(1, 4)
        ]
        page2_items = [
            _make_item_dict(f"Folge {i}: Title (S01/E{i:02d})", timestamp=i * 100)
            for i in range(4, 6)
        ]

        route = respx.post(f"{BASE_URL}/api/query")
        route.side_effect = [
            respx.MockResponse(json=_make_api_response(page1_items, total_results=5)),
            respx.MockResponse(json=_make_api_response(page2_items, total_results=5)),
        ]

        with Mediathek() as m:
            episodes = collect_series(m, "#Feuer,&,Flamme", page_size=3)

        assert len(episodes) == 5
        # Verify pagination happened — two API calls
        assert route.call_count == 2
        # Verify request offsets
        first_body = json.loads(route.calls[0].request.content)
        second_body = json.loads(route.calls[1].request.content)
        assert first_body["offset"] == 0
        assert second_body["offset"] == 3

    @respx.mock
    def test_skips_unparseable_items(self):
        items = [
            _make_item_dict("Folge 1: Feuer & Flamme (S01/E01)", timestamp=100),
            _make_item_dict("Trailer: Feuer & Flamme", timestamp=200),
            _make_item_dict("Folge 2: Feuer & Flamme (S01/E02)", timestamp=300),
        ]
        respx.post(f"{BASE_URL}/api/query").respond(
            json=_make_api_response(items),
        )

        with Mediathek() as m:
            episodes = collect_series(m, "#Feuer,&,Flamme")

        assert len(episodes) == 2
        titles = [ep.item.title for ep in episodes]
        assert "Trailer: Feuer & Flamme" not in titles

    @respx.mock
    def test_deduplicates_by_season_episode(self):
        items = [
            _make_item_dict("Folge 1: Feuer & Flamme (S01/E01)", timestamp=100),
            _make_item_dict("Folge 1: Feuer & Flamme (S01/E01)", timestamp=200),
            _make_item_dict("Folge 2: Feuer & Flamme (S01/E02)", timestamp=300),
        ]
        respx.post(f"{BASE_URL}/api/query").respond(
            json=_make_api_response(items),
        )

        with Mediathek() as m:
            episodes = collect_series(m, "#Feuer,&,Flamme")

        assert len(episodes) == 2

    @respx.mock
    def test_sorts_by_season_then_episode(self):
        items = [
            _make_item_dict("Folge 2: Title (S02/E02)", timestamp=300),
            _make_item_dict("Folge 1: Title (S01/E01)", timestamp=100),
            _make_item_dict("Folge 1: Title (S02/E01)", timestamp=200),
        ]
        respx.post(f"{BASE_URL}/api/query").respond(
            json=_make_api_response(items),
        )

        with Mediathek() as m:
            episodes = collect_series(m, "#Feuer,&,Flamme")

        assert [(ep.info.season, ep.info.episode) for ep in episodes] == [
            (1, 1),
            (2, 1),
            (2, 2),
        ]

    @respx.mock
    def test_uses_topic_query(self):
        items = [
            _make_item_dict("Folge 1: Title (S01/E01)"),
        ]
        route = respx.post(f"{BASE_URL}/api/query").respond(
            json=_make_api_response(items),
        )

        with Mediathek() as m:
            collect_series(m, "#Feuer,&,Flamme")

        body = json.loads(route.calls.last.request.content)
        assert body["queries"] == [{"fields": ["topic"], "query": "Feuer & Flamme"}]
        assert body["sortBy"] == "timestamp"
        assert body["sortOrder"] == "asc"

    @respx.mock
    def test_prefix_syntax_with_duration(self):
        """collect_series parses prefix syntax: #topic and >duration filter."""
        items = [
            _make_item_dict("Folge 1: Title (S01/E01)", timestamp=100, duration=5400),
        ]
        route = respx.post(f"{BASE_URL}/api/query").respond(
            json=_make_api_response(items),
        )

        with Mediathek() as m:
            episodes = collect_series(m, "#Sportschau >60")

        body = json.loads(route.calls.last.request.content)
        assert {"fields": ["topic"], "query": "Sportschau"} in body["queries"]
        assert body["duration_min"] == 3600
        assert len(episodes) == 1

    @respx.mock
    def test_unprefixed_query_searches_topic_and_title(self):
        """Plain text without prefix searches topic+title via build_from_string."""
        items = [
            _make_item_dict("Folge 1: Title (S01/E01)", timestamp=100),
        ]
        route = respx.post(f"{BASE_URL}/api/query").respond(
            json=_make_api_response(items),
        )

        with Mediathek() as m:
            collect_series(m, "Feuer & Flamme")

        body = json.loads(route.calls.last.request.content)
        assert body["queries"] == [
            {"fields": ["topic", "title"], "query": "Feuer & Flamme"}
        ]

    @respx.mock
    def test_collect_series_with_extraction_config(self):
        """collect_series passes ExtractionConfig through to extraction."""
        # Items with sport-style titles (no SXX/EXX or Folge N)
        items = [
            _make_item_dict(
                "15. Spieltag: Bayern vs Dortmund",
                topic="Sportschau Bundesliga",
                timestamp=1706745600,  # 2024-01-31
            ),
            _make_item_dict(
                "16. Spieltag: Leipzig vs Bremen",
                topic="Sportschau Bundesliga",
                timestamp=1707350400,  # 2024-02-08
            ),
        ]
        respx.post(f"{BASE_URL}/api/query").respond(
            json=_make_api_response(items),
        )

        config = ExtractionConfig(
            season_field="date",
            season_pattern=r"^(\d{4})",
            episode_pattern=r"(\d+)\. Spieltag",
        )

        with Mediathek() as m:
            episodes = collect_series(m, "#Sportschau,Bundesliga", config=config)

        assert len(episodes) == 2
        assert episodes[0].info.season == 2024
        assert episodes[0].info.episode == 15
        assert episodes[1].info.season == 2024
        assert episodes[1].info.episode == 16


class TestExtractEpisodeInfo:
    def _make_item(self, title="Test Title", topic="Test Topic",
                   description="Test description", channel="ARD",
                   timestamp=1706745600):
        """Build an Item for testing extraction."""
        return Item.model_validate(_make_item_dict(
            title, topic=topic, channel=channel, timestamp=timestamp,
            description=description,
        ))

    def test_none_config_delegates_to_default(self):
        """With no config, behaves exactly like parse_episode_info on title."""
        item = self._make_item(title="Folge 6: Explosion (S06/E06)")
        info = extract_episode_info(item, config=None)
        assert info is not None
        assert info.season == 6
        assert info.episode == 6

    def test_none_config_returns_none_for_unparseable(self):
        item = self._make_item(title="Some random title")
        info = extract_episode_info(item, config=None)
        assert info is None

    def test_custom_episode_pattern_on_title(self):
        """Custom episode regex extracts from title, season falls back to default."""
        item = self._make_item(title="Saison 2 - Spieltag 15: Bayern vs Dortmund")
        config = ExtractionConfig(episode_pattern=r"Spieltag (\d+)")
        info = extract_episode_info(item, config=config)
        # Episode from custom pattern, but season has no custom pattern
        # and default parsing won't find (SXX/EXX) or Folge N, so no season fallback
        # This should return None since season can't be determined
        assert info is None

    def test_custom_season_pattern_with_default_episode_fallback(self):
        """Custom season regex + default episode extraction from title."""
        item = self._make_item(title="Saison 3 - Folge 7: Der Brand")
        config = ExtractionConfig(season_pattern=r"Saison (\d+)")
        info = extract_episode_info(item, config=config)
        assert info is not None
        assert info.season == 3
        # Episode falls back to default: Folge 7
        assert info.episode == 7

    def test_both_custom_patterns(self):
        """Both season and episode from custom patterns."""
        item = self._make_item(title="Liga 2024 - 15. Spieltag")
        config = ExtractionConfig(
            season_pattern=r"Liga (\d{4})",
            episode_pattern=r"(\d+)\. Spieltag",
        )
        info = extract_episode_info(item, config=config)
        assert info is not None
        assert info.season == 2024
        assert info.episode == 15

    def test_date_based_season(self):
        """Extract season from virtual 'date' field (year from timestamp)."""
        # timestamp 1706745600 = 2024-01-31 UTC
        item = self._make_item(title="Folge 3: Title (S01/E03)", timestamp=1706745600)
        config = ExtractionConfig(
            season_field="date",
            season_pattern=r"^(\d{4})",
        )
        info = extract_episode_info(item, config=config)
        assert info is not None
        assert info.season == 2024
        # Episode falls back to default: (S01/E03) → episode 3
        assert info.episode == 3

    def test_date_season_with_custom_episode(self):
        """Date-based season combined with custom episode pattern (sports use case)."""
        item = self._make_item(
            title="15. Spieltag: Bayern vs Dortmund",
            timestamp=1706745600,  # 2024-01-31
        )
        config = ExtractionConfig(
            season_field="date",
            season_pattern=r"^(\d{4})",
            episode_pattern=r"(\d+)\. Spieltag",
        )
        info = extract_episode_info(item, config=config)
        assert info is not None
        assert info.season == 2024
        assert info.episode == 15

    def test_extract_from_topic_field(self):
        """Extract episode number from the topic field instead of title."""
        item = self._make_item(
            title="Some Title",
            topic="Spieltag 22 - Bundesliga",
        )
        config = ExtractionConfig(
            episode_field="topic",
            episode_pattern=r"Spieltag (\d+)",
            season_field="date",
            season_pattern=r"^(\d{4})",
        )
        info = extract_episode_info(item, config=config)
        assert info is not None
        assert info.episode == 22

    def test_extract_from_description_field(self):
        """Extract from description field."""
        item = self._make_item(
            title="Bundesliga Highlights",
            description="Matchday 8 highlights from the 2024 season",
            timestamp=1706745600,
        )
        config = ExtractionConfig(
            episode_field="description",
            episode_pattern=r"Matchday (\d+)",
            season_field="date",
            season_pattern=r"^(\d{4})",
        )
        info = extract_episode_info(item, config=config)
        assert info is not None
        assert info.episode == 8
        assert info.season == 2024

    def test_no_match_returns_none(self):
        """When custom pattern doesn't match the field, return None."""
        item = self._make_item(title="No numbers here at all")
        config = ExtractionConfig(
            episode_pattern=r"Spieltag (\d+)",
            season_field="date",
            season_pattern=r"^(\d{4})",
        )
        info = extract_episode_info(item, config=config)
        assert info is None

    def test_season_no_match_returns_none(self):
        """When season pattern doesn't match, return None even if episode matches."""
        item = self._make_item(title="15. Spieltag: Bayern vs Dortmund")
        config = ExtractionConfig(
            season_pattern=r"Saison (\d+)",
            episode_pattern=r"(\d+)\. Spieltag",
        )
        info = extract_episode_info(item, config=config)
        assert info is None

    def test_pattern_without_capture_group_raises_at_construction(self):
        """Regex without a capture group should raise ValueError at config construction."""
        with pytest.raises(ValueError, match="capture group"):
            ExtractionConfig(episode_pattern=r"Spieltag \d+")

    def test_season_pattern_without_capture_group_raises_at_construction(self):
        """Season pattern without capture group raises at construction."""
        with pytest.raises(ValueError, match="capture group"):
            ExtractionConfig(season_pattern=r"Saison \d+")

    def test_fallback_to_default_episode_when_no_episode_pattern(self):
        """Without episode_pattern, fall back to default title parsing for episode."""
        item = self._make_item(
            title="Folge 5: Something interesting",
            timestamp=1706745600,
        )
        config = ExtractionConfig(
            season_field="date",
            season_pattern=r"^(\d{4})",
            # No episode_pattern — should fall back to default Folge N parsing
        )
        info = extract_episode_info(item, config=config)
        assert info is not None
        assert info.season == 2024
        assert info.episode == 5

    def test_fallback_to_default_season_when_no_season_pattern(self):
        """Without season_pattern, fall back to default title parsing for season."""
        item = self._make_item(title="Folge 3: Title (S02/E03)")
        config = ExtractionConfig(
            episode_pattern=r"Folge (\d+)",
            # No season_pattern — should fall back to default (S02/E03) parsing
        )
        info = extract_episode_info(item, config=config)
        assert info is not None
        assert info.episode == 3
        assert info.season == 2

    def test_invalid_episode_field_raises(self):
        """Invalid episode_field should raise ValueError at construction."""
        with pytest.raises(ValueError, match="episode_field"):
            ExtractionConfig(episode_field="titel", episode_pattern=r"(\d+)")

    def test_invalid_season_field_raises(self):
        """Invalid season_field should raise ValueError at construction."""
        with pytest.raises(ValueError, match="season_field"):
            ExtractionConfig(season_field="kanal", season_pattern=r"(\d+)")

    def test_invalid_regex_raises(self):
        """Malformed regex should raise ValueError at construction."""
        with pytest.raises(ValueError, match="episode"):
            ExtractionConfig(episode_pattern="[bad")

    def test_invalid_season_regex_raises(self):
        """Malformed season regex should raise ValueError at construction."""
        with pytest.raises(ValueError, match="season"):
            ExtractionConfig(season_pattern="(unclosed")
