from __future__ import annotations

import json
import re
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from html import unescape
from typing import Protocol

from yt_dlp import YoutubeDL
from yt_dlp.utils import DownloadError

from youtube_cli_agent.errors import ToolExecutionError, ValidationError
from youtube_cli_agent.models import CaptionFetchResult, CaptionProbeSummary, TranscriptResult, VideoMetadata


class CaptionMetadataBackend(Protocol):
    def fetch(self, video_url: str, mode: str) -> dict:
        ...


class CaptionDownloader(Protocol):
    def fetch(self, url: str) -> bytes:
        ...


@dataclass(slots=True)
class YtDlpCaptionMetadataBackend:
    timeout_seconds: int = 30

    def fetch(self, video_url: str, mode: str) -> dict:
        opts = {
            "quiet": True,
            "no_warnings": True,
            "skip_download": True,
            "noplaylist": True,
            "socket_timeout": self.timeout_seconds,
            "geo_bypass": True,
        }

        if mode == "geo_bypass":
            opts["geo_bypass_country"] = "US"
        elif mode == "alt_client":
            opts["extractor_args"] = {
                "youtube": {
                    "player_client": ["android", "web"],
                }
            }

        try:
            with YoutubeDL(opts) as ydl:
                payload = ydl.extract_info(video_url, download=False)
        except DownloadError as exc:
            message = str(exc)
            lowered = message.lower()
            if "sign in to confirm your age" in lowered or "private video" in lowered:
                raise ToolExecutionError(
                    code="VIDEO_RESTRICTED",
                    stage="Caption Retrieval",
                    message="Video is age-restricted or private.",
                ) from None
            if "not available in your country" in lowered or "geo" in lowered:
                raise ToolExecutionError(
                    code="REGION_RESTRICTED",
                    stage="Caption Retrieval",
                    message="Video appears region restricted.",
                ) from None
            if "timed out" in lowered:
                raise ToolExecutionError(
                    code="NETWORK_TIMEOUT",
                    stage="Caption Retrieval",
                    message="Caption metadata request timed out.",
                ) from None
            raise ToolExecutionError(
                code="CAPTION_METADATA_FAILED",
                stage="Caption Retrieval",
                message=f"Caption metadata fetch failed: {message}",
            ) from None
        except Exception as exc:  # pragma: no cover
            raise ToolExecutionError(
                code="CAPTION_METADATA_FAILED",
                stage="Caption Retrieval",
                message=f"Caption metadata fetch failed: {exc}",
            ) from None

        if not isinstance(payload, dict):
            raise ToolExecutionError(
                code="CAPTION_METADATA_FAILED",
                stage="Caption Retrieval",
                message="Caption metadata payload was not a dictionary.",
            )
        return payload


@dataclass(slots=True)
class HttpCaptionDownloader:
    timeout_seconds: int = 30

    def fetch(self, url: str) -> bytes:
        request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
        with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
            return response.read()


@dataclass(frozen=True, slots=True)
class _CaptionTrack:
    language: str
    source_kind: str
    ext: str
    url: str


@dataclass(slots=True)
class CaptionRetrievalTool:
    backend: CaptionMetadataBackend
    downloader: CaptionDownloader
    preferred_languages: tuple[str, ...] = ("en", "en-US", "en-GB", "es", "es-419")
    min_chars: int = 80

    def run(self, metadata: VideoMetadata) -> CaptionFetchResult:
        probe_modes = ("default", "geo_bypass", "alt_client")
        notes: list[str] = []
        all_tracks: list[_CaptionTrack] = []
        region_restricted = False

        for mode in probe_modes:
            try:
                payload = self.backend.fetch(metadata.url, mode=mode)
            except ToolExecutionError as exc:
                notes.append(f"{mode}: {exc.code}")
                if exc.code in {"REGION_RESTRICTED", "VIDEO_RESTRICTED"}:
                    region_restricted = True
                continue

            tracks = self._extract_tracks(payload)
            notes.append(f"{mode}: tracks={len(tracks)}")
            all_tracks.extend(tracks)
            region_restricted = region_restricted or self._detect_region_restriction(payload)

        deduped_tracks = self._dedupe_tracks(all_tracks)
        if deduped_tracks:
            selected = self._select_track(deduped_tracks)
            transcript_text = self._download_and_parse(selected)
            if len(transcript_text.strip()) < self.min_chars:
                raise ValidationError(
                    code="CAPTION_TOO_SHORT",
                    stage="Caption Retrieval",
                    message="Caption text was retrieved but below minimum length.",
                )

            transcript = TranscriptResult(
                source="Official Caption",
                language=selected.language,
                text=transcript_text.strip(),
                generated_at=datetime.now(timezone.utc),
            )
            return CaptionFetchResult(
                transcript=transcript,
                captions_available=True,
                verified_unavailable=False,
                probe_summary=CaptionProbeSummary(
                    checked_modes=probe_modes,
                    captions_found=True,
                    region_restricted=region_restricted,
                    notes="; ".join(notes),
                ),
            )

        verified_unavailable = not region_restricted
        return CaptionFetchResult(
            transcript=None,
            captions_available=False,
            verified_unavailable=verified_unavailable,
            probe_summary=CaptionProbeSummary(
                checked_modes=probe_modes,
                captions_found=False,
                region_restricted=region_restricted,
                notes="; ".join(notes) or "No caption tracks found.",
            ),
        )

    def _extract_tracks(self, payload: dict) -> list[_CaptionTrack]:
        collected: list[_CaptionTrack] = []

        subtitles = payload.get("subtitles") or {}
        auto = payload.get("automatic_captions") or {}

        if isinstance(subtitles, dict):
            for language, formats in subtitles.items():
                collected.extend(self._tracks_for_formats(language, formats, "manual"))

        if isinstance(auto, dict):
            for language, formats in auto.items():
                collected.extend(self._tracks_for_formats(language, formats, "auto"))

        return collected

    def _tracks_for_formats(self, language: str, formats: object, source_kind: str) -> list[_CaptionTrack]:
        if not isinstance(formats, list):
            return []

        tracks: list[_CaptionTrack] = []
        for fmt in formats:
            if not isinstance(fmt, dict):
                continue
            url = str(fmt.get("url") or "").strip()
            ext = str(fmt.get("ext") or "").strip().lower()
            if not url:
                continue
            if ext not in {"json3", "vtt", "srv3", "ttml"}:
                continue
            tracks.append(_CaptionTrack(language=language, source_kind=source_kind, ext=ext, url=url))
        return tracks

    @staticmethod
    def _dedupe_tracks(tracks: list[_CaptionTrack]) -> list[_CaptionTrack]:
        seen: set[tuple[str, str, str]] = set()
        deduped: list[_CaptionTrack] = []
        for track in tracks:
            key = (track.language, track.source_kind, track.url)
            if key in seen:
                continue
            seen.add(key)
            deduped.append(track)
        return deduped

    def _select_track(self, tracks: list[_CaptionTrack]) -> _CaptionTrack:
        language_rank = {lang: idx for idx, lang in enumerate(self.preferred_languages)}
        ext_rank = {"json3": 0, "vtt": 1, "srv3": 2, "ttml": 3}

        def key(track: _CaptionTrack) -> tuple[int, int, int, str]:
            source_rank = 0 if track.source_kind == "manual" else 1
            lang_rank = language_rank.get(track.language, len(self.preferred_languages) + 1)
            return (source_rank, lang_rank, ext_rank.get(track.ext, 99), track.language)

        return sorted(tracks, key=key)[0]

    def _download_and_parse(self, track: _CaptionTrack) -> str:
        try:
            data = self.downloader.fetch(track.url)
        except Exception as exc:
            raise ToolExecutionError(
                code="CAPTION_DOWNLOAD_FAILED",
                stage="Caption Retrieval",
                message=f"Failed to download caption track: {exc}",
            ) from None

        text = data.decode("utf-8", errors="ignore")
        if track.ext == "json3":
            parsed = self._parse_json3(text)
        elif track.ext == "vtt":
            parsed = self._parse_vtt(text)
        else:
            parsed = self._parse_xml_like(text)

        if not parsed.strip():
            raise ValidationError(
                code="EMPTY_CAPTION",
                stage="Caption Retrieval",
                message="Caption file was downloaded but transcript text was empty.",
            )
        return parsed

    @staticmethod
    def _parse_json3(raw: str) -> str:
        try:
            payload = json.loads(raw)
        except json.JSONDecodeError:
            return ""

        events = payload.get("events")
        if not isinstance(events, list):
            return ""

        chunks: list[str] = []
        for event in events:
            if not isinstance(event, dict):
                continue
            segs = event.get("segs")
            if not isinstance(segs, list):
                continue
            for seg in segs:
                if not isinstance(seg, dict):
                    continue
                text = str(seg.get("utf8") or "")
                if text:
                    chunks.append(text.replace("\n", " "))

        return CaptionRetrievalTool._compact_text(" ".join(chunks))

    @staticmethod
    def _parse_vtt(raw: str) -> str:
        lines = []
        for line in raw.splitlines():
            stripped = line.strip()
            if not stripped:
                continue
            if stripped.startswith("WEBVTT"):
                continue
            if "-->" in stripped:
                continue
            if stripped.isdigit():
                continue
            lines.append(stripped)
        return CaptionRetrievalTool._compact_text(" ".join(lines))

    @staticmethod
    def _parse_xml_like(raw: str) -> str:
        text = re.sub(r"<[^>]+>", " ", raw)
        text = unescape(text)
        return CaptionRetrievalTool._compact_text(text)

    @staticmethod
    def _compact_text(text: str) -> str:
        text = text.replace("\xa0", " ")
        text = re.sub(r"\s+", " ", text)
        return text.strip()

    @staticmethod
    def _detect_region_restriction(payload: dict) -> bool:
        availability = str(payload.get("availability") or "").lower()
        if "region" in availability or "country" in availability:
            return True
        if payload.get("is_private"):
            return True
        return False
