from __future__ import annotations

import json
import re
import sys
import uuid
from collections import defaultdict
from collections.abc import Generator
from copy import deepcopy
from http.cookiejar import CookieJar
from typing import Any
from urllib.parse import urljoin
from zlib import crc32

import click
from click import Context
from langcodes import Language
from lxml import etree
from unshackle.core.credential import Credential
from unshackle.core.manifests import DASH
from unshackle.core.search_result import SearchResult
from unshackle.core.service import Service
from unshackle.core.session import session as CurlSession
from unshackle.core.titles import Episode, Movie, Movies, Series
from unshackle.core.tracks import Audio, Chapter, Chapters, Subtitle, Track, Tracks
from unshackle.core.utilities import is_close_match


class DSCP(Service):
    """
    \b
    Service code for Discovery Plus streaming service (https://www.discoveryplus.com).
    Credit to @sp4rk.y for the subtitle fix.

    \b
    Version: 1.0.1
    Author: stabbedbybrick
    Authorization: Cookies for subscription, none for freely available titles
    Robustness:
        Widevine:
            L1: 2160p, 1080p
            L3: 720p
        PlayReady:
            SL3000: 2160p
            SL2000: 1080p, 720p

    \b
    Tips:
        - Input can be either complete title URL or just the path:
            SHOW: /show/eb26e00e-9582-4790-a61c-48d785926f58
            STANDALONE: /standalone/5012ae3f-d9bd-46ec-ad42-b8116b811441
            SPORT: /sport/9cc449de-2a64-524d-bcb6-cabd4ac70340
            EPISODE: /video/watch/8685efdd-a3c4-4892-b1d1-5f9f071cacf1/de67ea8e-a90f-4609-81af-4f09906f60b2

    \b
    Notes:
        - Language tags can be mislabelled or missing on some titles. List tracks with --list to verify.
        - All qualities, codecs, and ranges are included when available. Use -v H.265, -r HDR10, -q 1080p, etc. to select.

    \b
    Bonus tip: With some minor adjustments to the code and config, you can convert this to an HMAX service.
        - Replace all instances of "DSCP" with "HMAX"
        - Replace all instances of "dplus" with "beam"
        - Replace all instances of "discoveryplus" with "hbomax"

    """
    
    ALIASES = ("discoveryplus",)
    TITLE_RE = (
        r"^(?:https?://play.discoveryplus\.com?)?/(?P<type>show|mini-series|video|movie|topical|standalone|sport)/(?P<id>[a-z0-9-/]+)"
    )

    @staticmethod
    @click.command(name="DSCP", short_help="https://www.discoveryplus.com/", help=__doc__)
    @click.argument("title", type=str)
    @click.pass_context
    def cli(ctx: Context, **kwargs: Any) -> DSCP:
        return DSCP(ctx, **kwargs)

    def __init__(self, ctx: Context, title: str):
        super().__init__(ctx)
        self.title = title

        self.profile = ctx.parent.params.get("profile")
        if not self.profile:
            self.profile = "default"

        self.cdm = ctx.obj.cdm
        if self.cdm is not None:
            self.drm_system = "playready"
            self.security_level = "SL3000"

            if self.cdm.security_level <= 3:
                self.drm_system = "widevine"
                self.security_level = "L1"

        self.base_url = self.config["endpoints"]["default_url"]

    def get_session(self) -> CurlSession:
        return CurlSession("okhttp4", status_forcelist=[429, 502, 503, 504])

    def authenticate(self, cookies: CookieJar | None = None, credential: Credential | None = None) -> None:
        super().authenticate(cookies, credential)
        tokens = {}

        if cookies is not None:
            st_token = next((c.value for c in cookies if c.name == "st"), None)
            if not st_token:
                raise ValueError("- Unable to find token in cookies, try refreshing.")

            # Only use cache if cookies are present since it's not needed for free titles
            cache = self.cache.get(f"tokens_{self.profile}")
            if cache:
                self.log.info(" + Using cached Tokens...")
                tokens = cache.data
            else:
                self.log.info(" + Setting up new profile...")
                profile = {"token": st_token, "device_id": str(uuid.uuid1())}
                cache.set(profile)
                tokens = cache.data

        self.device_id = tokens.get("device_id") or str(uuid.uuid1())
        client_id = self.config["client_id"]

        self.session.headers.update({
            "user-agent": "androidtv dplus/20.8.1.2 (android/9; en-US; SHIELD Android TV-NVIDIA; Build/1)",
            "x-disco-client": "ANDROIDTV:9:dplus:20.8.1.2",
            "x-disco-params": "realm=bolt,bid=dplus,features=ar",
            "x-device-info": f"dplus/20.8.1.2 (NVIDIA/SHIELD Android TV; android/9-mdarcy; {self.device_id}/{client_id})",
        })

        access = self._request("GET", "/token", params={"realm": "bolt", "deviceId": self.device_id})
        
        self.access_token = access["data"]["attributes"]["token"]

        config = self._request("POST", "/session-context/headwaiter/v1/bootstrap")
        self.base_url = self.config["endpoints"]["template"].format(config["routing"]["tenant"], config["routing"]["homeMarket"])

    def search(self) -> Generator[SearchResult, None, None]:
        params = {
            "include": "default",
            "decorators": "viewingHistory,isFavorite,playbackAllowed,contentAction,badges",
            "contentFilter[query]": self.title,
            "page[items.number]": "1",
            "page[items.size]": "8",
        }
        data = self._request("GET", "/cms/routes/search/result", params=params)

        results = [x.get("attributes") for x in data["included"] if x.get("type") == "show"]

        for result in results:
            yield SearchResult(
                id_=f"/show/{result.get('alternateId')}",
                title=result.get("name"),
                description=result.get("description"),
                label="show",
                url=f"/show/{result.get('alternateId')}",
            )

    def get_titles(self) -> Movies | Series:
        try:
            entity, content_id = (re.match(self.TITLE_RE, self.title).group(i) for i in ("type", "id"))
        except Exception:
            raise ValueError("Could not parse ID from title - is the URL correct?")

        if entity in ("show", "mini-series", "topical"):
            episodes = self._show(content_id)
            return Series(episodes)

        elif entity in ("movie", "standalone"):
            movie = self._movie(content_id, entity)
            return Movies(movie)
        
        elif entity == "sport":
            sport = self._sport(content_id)
            return Movies(sport)

        elif entity == "video":
            episodes = self._episode(content_id)
            return Series(episodes)

        else:
            raise ValueError(f"Unknown content: {entity}")


    def get_tracks(self, title: Movie | Episode) -> Tracks:
        payload = {
            "appBundle": "com.wbd.stream",
            "applicationSessionId": self.device_id,
            "capabilities": {
                "codecs": {
                    "audio": {
                        "decoders": [
                            {"codec": "aac", "profiles": ["lc", "he", "hev2", "xhe"]},
                            {"codec": "eac3", "profiles": ["atmos"]},
                        ]
                    },
                    "video": {
                        "decoders": [
                            {
                                "codec": "h264",
                                "levelConstraints": {
                                    "framerate": {"max": 60, "min": 0},
                                    "height": {"max": 2160, "min": 48},
                                    "width": {"max": 3840, "min": 48},
                                },
                                "maxLevel": "5.2",
                                "profiles": ["baseline", "main", "high"],
                            },
                            {
                                "codec": "h265",
                                "levelConstraints": {
                                    "framerate": {"max": 60, "min": 0},
                                    "height": {"max": 2160, "min": 144},
                                    "width": {"max": 3840, "min": 144},
                                },
                                "maxLevel": "5.1",
                                "profiles": ["main10", "main"],
                            },
                        ],
                        "hdrFormats": ["hdr10", "hdr10plus", "dolbyvision", "dolbyvision5", "dolbyvision8", "hlg"],
                    },
                },
                "contentProtection": {
                    "contentDecryptionModules": [
                        {"drmKeySystem": self.drm_system, "maxSecurityLevel": self.security_level}
                    ]
                },
                "manifests": {"formats": {"dash": {}}},
            },
            "consumptionType": "streaming",
            "deviceInfo": {
                "player": {
                    "mediaEngine": {"name": "", "version": ""},
                    "playerView": {"height": 2160, "width": 3840},
                    "sdk": {"name": "", "version": ""},
                }
            },
            "editId": title.id,
            "firstPlay": False,
            "gdpr": False,
            "playbackSessionId": str(uuid.uuid4()),
            "userPreferences": {
                #'uiLanguage': 'en'
            },
        }

        playback = self._request(
            "POST", "/playback-orchestrator/any/playback-orchestrator/v1/playbackInfo",
            headers={"Authorization": f"Bearer {self.access_token}"},
            json=payload,
        )
        
        original_language = next((
            x.get("language")
            for x in playback["videos"][0]["audioTracks"]
            if "Original" in x.get("displayName", "")
        ), "")

        manifest = (
            playback.get("fallback", {}).get("manifest", {}).get("url", "").replace("_fallback", "")
            or playback.get("manifest", {}).get("url")
        )

        license_url = (
            playback.get("fallback", {}).get("drm", {}).get("schemes", {}).get(self.drm_system, {}).get("licenseUrl")
            or playback.get("drm", {}).get("schemes", {}).get(self.drm_system, {}).get("licenseUrl")
        )

        title.data["license_url"] = license_url
        title.data["chapters"] = next((x.get("annotations") for x in playback["videos"] if x["type"] == "main"), None)

        dash = DASH.from_url(url=manifest, session=self.session)
        tracks = dash.to_tracks(language="en", period_filter=self._period_filter)

        for track in tracks:
            track.is_original_lang = str(track.language) == original_language
            track.name = "Original" if track.is_original_lang else track.name

            if isinstance(track, Audio):
                role = track.data["dash"]["representation"].find("Role")
                if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
                    track.descriptive = True

            if isinstance(track, Subtitle):
                tracks.subtitles.remove(track)

        subtitles = self._process_subtitles(dash, original_language)
        tracks.add(subtitles)

        return tracks

    def get_chapters(self, title: Movie | Episode) -> Chapters:
        if not title.data.get("chapters"):
            return Chapters()
        
        chapters = []
        for chapter in title.data["chapters"]:
            if "recap" in chapter.get("secondaryType", "").lower():
                chapters.append(Chapter(name="Recap", timestamp=chapter["start"]))
                if chapter.get("end"):
                    chapters.append(Chapter(timestamp=chapter.get("end")))
            if "intro" in chapter.get("secondaryType", "").lower():
                chapters.append(Chapter(name="Intro", timestamp=chapter["start"]))
                if chapter.get("end"):
                    chapters.append(Chapter(timestamp=chapter.get("end")))
            elif "credits" in chapter.get("type", "").lower():
                chapters.append(Chapter(name="Credits", timestamp=chapter["start"]))
        
        if not any(c.timestamp == "00:00:00.000" for c in chapters):
            chapters.append(Chapter(timestamp=0))

        return sorted(chapters, key=lambda x: x.timestamp)

    def get_widevine_service_certificate(self, challenge: bytes, title: Episode | Movie, **_: Any) -> str:
        if not (license_url := title.data.get("license_url")):
            return None
        
        return self.session.post(url=license_url, data=challenge).content
        

    def get_widevine_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> bytes | str | None:
        if not (license_url := title.data.get("license_url")):
            return None

        r = self.session.post(url=license_url, data=challenge)
        if r.status_code != 200:
            raise ConnectionError(r.status_code, r.text)

        return r.content
    
    def get_playready_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> bytes | str | None:
        if not (license_url := title.data.get("license_url")):
            return None

        r = self.session.post(url=license_url, data=challenge)
        if r.status_code != 200:
            raise ConnectionError(r.status_code, r.text)

        return r.content

    # Service specific functions

    @staticmethod
    def _process_subtitles(dash: DASH, language: str) -> list[Subtitle]:
        subtitle_groups = defaultdict(list)
        manifest = dash.manifest

        for period in manifest.findall("Period"):
            for adapt_set in period.findall("AdaptationSet"):
                if adapt_set.get("contentType") != "text" or not adapt_set.get("lang"):
                    continue

                role = adapt_set.find("Role")
                label = adapt_set.find("Label")
                key = (
                    adapt_set.get("lang"),
                    role.get("value") if role is not None else "subtitle",
                    label.text if label is not None else "",
                )
                subtitle_groups[key].append((period, adapt_set))

        final_tracks = []
        for (lang, role_value, label_text), adapt_set_group in subtitle_groups.items():
            first_period, first_adapt = adapt_set_group[0]
            if first_adapt.find("Representation") is None:
                continue

            s_elements_with_context = []
            for _, adapt_set in adapt_set_group:
                rep = adapt_set.find("Representation")
                if rep is None:
                    continue

                template = rep.find("SegmentTemplate") or adapt_set.find("SegmentTemplate")
                timeline = template.find("SegmentTimeline") if template is not None else None

                if timeline is not None:
                    start_num = int(template.get("startNumber", 1))
                    s_elements_with_context.extend((start_num, s_elem) for s_elem in timeline.findall("S"))

            s_elements_with_context.sort(key=lambda x: x[0])

            combined_adapt = deepcopy(first_adapt)
            combined_rep = combined_adapt.find("Representation")

            seg_template = combined_rep.find("SegmentTemplate")
            if seg_template is None:
                template_at_adapt = combined_adapt.find("SegmentTemplate")
                if template_at_adapt is not None:
                    seg_template = deepcopy(template_at_adapt)
                    combined_rep.append(seg_template)
                    combined_adapt.remove(template_at_adapt)
                else:
                    continue

            if seg_template.find("SegmentTimeline") is not None:
                seg_template.remove(seg_template.find("SegmentTimeline"))

            new_timeline = etree.Element("SegmentTimeline")
            new_timeline.extend(deepcopy(s) for _, s in s_elements_with_context)
            seg_template.append(new_timeline)

            seg_template.set("startNumber", "1")
            if "endNumber" in seg_template.attrib:
                del seg_template.attrib["endNumber"]

            track_id = hex(crc32(f"sub-{lang}-{role_value}-{label_text}".encode()) & 0xFFFFFFFF)[2:]
            lang_obj = Language.get(lang)
            track_name = "Original" if (language and is_close_match(lang_obj, [language])) else lang_obj.display_name()

            final_tracks.append(
                Subtitle(
                    id_=track_id,
                    url=dash.url,
                    codec=Subtitle.Codec.WebVTT,
                    language=lang_obj,
                    is_original_lang=bool(language and is_close_match(lang_obj, [language])),
                    descriptor=Track.Descriptor.DASH,
                    sdh="sdh" in label_text.lower() or role_value == "caption",
                    forced="forced" in label_text.lower() or "forced" in role_value.lower(),
                    name=track_name,
                    data={
                        "dash": {
                            "manifest": manifest,
                            "period": first_period,
                            "adaptation_set": combined_adapt,
                            "representation": combined_rep,
                        }
                    },
                )
            )

        return final_tracks

    @staticmethod
    def _period_filter(period: Any) -> bool:
        """Shouldn't be needed for fallback manifest"""
        if not (duration := period.get("duration")):
            return False
        
        return DASH.pt_to_sec(duration) < 120

    def _show(self, title: str) -> Episode:
        params = {
            "include": "default",
            "decorators": "viewingHistory,badges,isFavorite,contentAction",
        }
        data = self._request("GET", "/cms/routes/show/{}".format(title), params=params)

        info = next(x for x in data["included"] if x.get("attributes", {}).get("alternateId", "") == title)
        content = next((x for x in data["included"] if "show-page-rail-episodes-tabbed-content" in x["attributes"].get("alias", "")), None)
        if not content:
            raise ValueError("Show not found")
        
        content_id = content.get("id")
        show_id = content["attributes"]["component"].get("mandatoryParams", "")
        season_params = [x.get("parameter") for x in content["attributes"]["component"]["filters"][0]["options"]]
        page = next(x for x in data["included"] if x.get("type", "") == "page")

        seasons = [
            self._request(
                "GET", "/cms/collections/{}?{}&{}".format(content_id, season, show_id),
                params={"include": "default", "decorators": "viewingHistory,badges,isFavorite,contentAction"},
            )
            for season in season_params
        ]

        videos = [[x for x in season["included"] if x["type"] == "video"] for season in seasons]

        return [
            Episode(
                id_=ep["relationships"]["edit"]["data"]["id"],
                service=self.__class__,
                title=page["attributes"].get("title") or info["attributes"].get("originalName"),
                year=ep["attributes"]["airDate"][:4] if ep["attributes"].get("airDate") else None,
                season=ep["attributes"].get("seasonNumber"),
                number=ep["attributes"].get("episodeNumber"),
                name=ep["attributes"]["name"],
                data=ep,
            )
            for episodes in videos
            for ep in episodes
            if ep.get("attributes", {}).get("videoType", "") == "EPISODE"
        ]

    def _episode(self, title: str) -> Episode:
        video_id = title.split("/")[1]

        params = {"decorators": "isFavorite", "include": "show"}
        content = self._request("GET", "/content/videos/{}".format(video_id), params=params)

        episode = content.get("data", {}).get("attributes")
        video_type = episode.get("videoType")
        relationships = content.get("data", {}).get("relationships")
        show = next((x for x in content["included"] if x.get("type", "") == "show"), {})

        show_title = show.get("attributes", {}).get("name") or show.get("attributes", {}).get("originalName")
        episode_name = episode.get("originalName") or episode.get("secondaryTitle")
        if video_type.lower() in ("clip", "standalone_event"):
            show_title = episode.get("originalName")
            episode_name = episode.get("secondaryTitle", "")

        return [
            Episode(
                id_=relationships.get("edit", {}).get("data", {}).get("id"),
                service=self.__class__,
                title=show_title,
                year=int(episode.get("airDate")[:4]) if episode.get("airDate") else None,
                season=episode.get("seasonNumber") or 0,
                number=episode.get("episodeNumber") or 0,
                name=episode_name,
                data=episode,
            )
        ]
    
    def _sport(self, title: str) -> Movie:
        params = {
            "include": "default",
            "decorators": "viewingHistory,badges,isFavorite,contentAction",
        }
        data = self._request("GET", "/cms/routes/sport/{}".format(title), params=params)

        content = next((x for x in data["included"] if x.get("attributes", {}).get("alternateId", "") == title), None)
        if not content:
            raise ValueError(f"Content not found for title: {title}")

        movie = content.get("attributes")
        relationships = content.get("relationships")

        name = movie.get("name") or movie.get("originalName")
        year = int(movie.get("firstAvailableDate")[:4]) if movie.get("firstAvailableDate") else None

        return [
            Movie(
                id_=relationships.get("edit", {}).get("data", {}).get("id"),
                service=self.__class__,
                name=name + " - " + movie.get("secondaryTitle", ""),
                year=year,
                data=movie,
            )
        ]

    def _movie(self, title: str, entity: str) -> Movie:
        params = {
            "include": "default",
            "decorators": "isFavorite,playbackAllowed,contentAction,badges",
        }
        data = self._request("GET", "/cms/routes/movie/{}".format(title), params=params)

        movie = next((
            x for x in data["included"]if x.get("attributes", {}).get("videoType", "").lower() == entity), None
        )
        if not movie:
            raise ValueError("Movie not found")

        return [
            Movie(
                id_=movie["relationships"]["edit"]["data"]["id"],
                service=self.__class__,
                name=movie["attributes"].get("name") or movie["attributes"].get("originalName"),
                year=int(movie["attributes"]["airDate"][:4]) if movie["attributes"].get("airDate") else None,
                data=movie,
            )
        ]

    def _request(self, method: str, endpoint: str, **kwargs: Any) -> Any[dict | str]:
        url = urljoin(self.base_url, endpoint)

        response = self.session.request(method, url, **kwargs)

        try:
            data = json.loads(response.content)

            if errors := data.get("errors", []):
                code = next((x.get("code", "") for x in errors), "")
                if "missingpackage" in code.lower():
                    self.log.error("\nError: Subscription is required for this title.")
                    sys.exit(1)

            return data

        except Exception as e:
            raise ConnectionError(f"Request failed for {url}: {e}")
