from __future__ import annotations

import re
from collections.abc import Generator
from concurrent.futures import ThreadPoolExecutor
from http.cookiejar import MozillaCookieJar
from typing import Any, List, Optional, Union
from uuid import uuid4

import click
from click import Context
from pyplayready.cdm import Cdm as PlayReadyCdm
from unshackle.core.credential import Credential
from unshackle.core.manifests.dash import DASH
from unshackle.core.search_result import SearchResult
from unshackle.core.service import Service
from unshackle.core.titles import Episode, Movie, Movies, Series
from unshackle.core.tracks import Chapter, Chapters, Tracks


class SEVEN(Service):
    """
    Service code for 7Plus streaming service (https://7plus.com.au/).

    \b
    Version: 1.0.1
    Author: stabbedbybrick
    Authorization: Cookies
    Geofence: AU (API and downloads)
    Robustness:
        Widevine:
            L3: 720p
        PlayReady:
            SL2000: 720p

    \b
    Tips:
        - Use complete title URL as input:
            SERIES: https://7plus.com.au/ncis-los-angeles
            EPISODE: https://7plus.com.au/ncis-los-angeles?episode-id=NCIL01-001
        - There's no way to distinguish between series and movies, so use `--movie` to download as movie

    \b
    Examples:
        - SERIES: unshackle dl -w s01e01 7plus https://7plus.com.au/ncis-los-angeles
        - EPISODE: unshackle dl 7plus https://7plus.com.au/ncis-los-angeles?episode-id=NCIL01-001
        - MOVIE: unshackle dl 7plus --movie https://7plus.com.au/puss-in-boots-the-last-wish

    """

    GEOFENCE = ("au",)
    ALIASES = ("7plus", "sevenplus",)

    @staticmethod
    @click.command(name="SEVEN", short_help="https://7plus.com.au/", help=__doc__)
    @click.option("-m", "--movie", is_flag=True, default=False, help="Download as Movie")
    @click.argument("title", type=str)
    @click.pass_context
    def cli(ctx: Context, **kwargs: Any) -> SEVEN:
        return SEVEN(ctx, **kwargs)

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

        self.cdm = ctx.obj.cdm
        self.drm_system = "playready" if isinstance(self.cdm, PlayReadyCdm) else "widevine"
        self.key_system = "com.microsoft.playready" if isinstance(self.cdm, PlayReadyCdm) else "com.widevine.alpha"

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

        self.session.headers.update(self.config["headers"])

    def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
        super().authenticate(cookies, credential)
        if cookies is None:
            raise EnvironmentError("Service requires Cookies for Authentication.")
        self.session.cookies.update(cookies)

        api_key = next((cookie.name.replace("gig_bootstrap_", "") for cookie in cookies if "login_ver" in cookie.value), None)
        login_token = next((cookie.value for cookie in cookies if "glt_" in cookie.name), None)
        if not api_key or not login_token:
            raise ValueError("Invalid cookies. Try refreshing.")

        market = self.session.get(
            "https://market-cdn.swm.digital/v1/market/ip/",
            params={"apikey": "web"}
        ).json()

        self.market_id = market.get("_id", 4)

        cache = self.cache.get(f"tokens_{self.profile}")

        if cache and not cache.expired:
            # cached
            self.log.info(" + Using cached tokens...")
            tokens = cache.data
        elif cache and cache.expired:
            # expired, refresh
            self.log.info("+ Refreshing tokens...")
            payload = {
                "platformId": self.config["PLATFORM_ID"],
                "regSource": "7plus",
                "refreshToken": cache.data.get("refresh_token"),
            }
            r = self.session.post("https://auth2.swm.digital/connect/token", data=payload)
            if r.status_code != 200:
                raise ConnectionError(f"Failed to refresh tokens: {r.text}")
            tokens = r.json()
            cache.set(tokens, expiration=int(tokens["expires_in"]) - 60)

        else:
            # new
            self.log.info(" + Authenticating...")
            device_id = str(uuid4()) 
            payload = {
                "platformId": self.config["PLATFORM_ID"],
                "regSource": "7plus",
                "deviceId": device_id,
                "locationVerificationRequired": "false",
            }
            r = self.session.post("https://auth2.swm.digital/account/device/authorize", data=payload)
            if r.status_code != 200:
                raise ConnectionError(f"Failed to authenticate: {r.text}")
            auth = r.json()

            uri = auth.get("verification_uri_complete")
            user_code = auth.get("user_code")
            device_code = auth.get("device_code")
            if not uri or not user_code or not device_code:
                raise ValueError(f"Failed to authenticate device: {auth}")

            data = {
                "APIKey": api_key,
                "sdk": "js_next",
                "login_token": login_token,
                "authMode": "cookie",
                "pageURL": "https://7plus.com.au/connect",
                "sdkBuild": "18051",
                "format": "json",
            }

            response = self.session.post("https://login.7plus.com.au/accounts.getJWT", cookies=cookies, data=data)
            if response.status_code != 200:
                raise ConnectionError(f"Failed to fetch JWT: {response.text}")
            
            id_token = response.json().get("id_token")
            if not id_token:
                raise ValueError(f"Failed to fetch JWT: {response.text}")
            
            headers = {
                "accept": "application/json, text/plain, */*",
                "accept-language": "en-US,en;q=0.9",
                "authorization": f"Bearer {id_token}",
                "content-type": "application/json;charset=UTF-8",
                "origin": "https://7plus.com.au",
                "referer": "https://7plus.com.au/connect",
            }

            payload = {
                "platformId": "web",
                "regSource": "7plus",
                "code": user_code,
                "attemptLocationPairing": False,
            }
            r = self.session.post("https://7plus.com.au/auth/otp", headers=headers, json=payload)
            if r.status_code != 200:
                raise ConnectionError(f"Failed to verify OTP: {r.status_code}")
            
            payload = {
                "platformId": self.config["PLATFORM_ID"],
                "regSource": "7plus",
                "deviceCode": device_code,
            }
            r = self.session.post("https://auth2.swm.digital/connect/token", data=payload)
            if r.status_code != 200:
                raise ConnectionError(f"Failed to fetch device token: {r.text}")
            tokens = r.json()

            tokens["device_id"] = device_id
            cache.set(tokens, expiration=int(tokens["expires_in"]) - 60)
        
        self.device_id = tokens.get("device_id") or str(uuid4())
        self.session.headers.update({"authorization": f"Bearer {tokens['access_token']}"})

    def search(self) -> Generator[SearchResult, None, None]:
        params = {
            "searchTerm": self.title,
            "market-id": self.market_id,
            "api-version": "4.4",
            "platform-id": self.config["PLATFORM_ID"],
            "platform-version": self.config["PLATFORM_VERSION"],
        }

        r = self.session.get("https://searchapi.swm.digital/3.0/api/Search", params=params)
        r.raise_for_status()

        results = r.json()
        if isinstance(results, list):
            for result in results:
                title = result.get("image", {}).get("altTag")
                slug = result.get("contentLink", {}).get("url")

                yield SearchResult(
                    id_=f"https://7plus.com.au{slug}",
                    title=title,
                    url=f"https://7plus.com.au{slug}",
                )

    def get_titles(self) -> Movies | Series:
        if match := re.match(r"https:\/\/7plus\.com\.au\/([^?\/]+)(?:\?.*episode-id=([^&]+))?", self.title):
            slug, episode_id = match.groups()
        else:
            raise ValueError(f"Invalid title: {self.title}")
        
        params = {
            "platform-id": self.config["PLATFORM_ID"],
            "market-id": self.market_id,
            "platform-version": self.config["PLATFORM_VERSION"],
            "api-version": self.config["API_VERSION"],
        }

        r = self.session.get(f"https://component-cdn.swm.digital/content/{slug}", params=params)
        if r.status_code != 200:
            raise ConnectionError(f"Failed to fetch content: {r.text}")

        content = r.json()
        
        if episode_id:
            episodes = self._series(content, slug)
            episode = next((e for e in episodes if e.id == episode_id), None)
            return Series([episode])
        
        elif self.movie:
            movie = self._movie(content)
            return Movies([movie])
        
        else:
            episodes = self._series(content, slug)
            return Series(episodes)

    def get_tracks(self, title: Movie | Episode) -> Tracks:
        params = {
            "appId": "7plus",
            "deviceType": self.config["PLATFORM_ID"],
            "platformType": "tv",
            "deviceId": self.device_id,
            "pc": 3181,
            "advertid": "null",
            "accountId": "5303576322001",
            "referenceId": f"ref:{title.id}",
            "deliveryId": "csai",
            "marketId": self.market_id,
            "ozid": "dc6095c7-e895-41d3-6609-79f673fc7f63",
            "sdkverification": "true",
            "cp.encryptionType": "cenc",
            "cp.drmSystems": self.drm_system,
            "cp.containerFormat": "cmaf",
            "cp.supportedCodecs": "avc",
            "cp.drmAuth": "true",
        }
        resp = self.session.get("https://videoservice.swm.digital/playback", params=params)
        if resp.status_code != 200:
            raise ConnectionError(f"Failed to fetch playback data: {resp.text}")
        data = resp.json()

        drm = data.get("media", {}).get("stream_type_drm", False)
        if drm:
            source_manifest = next((
                x["src"] for x in data["media"]["sources"] 
                if x.get("key_systems").get("com.widevine.alpha")),
                None,
            )
            title.data["license_url"] = next((
                x["key_systems"][self.key_system]["license_url"]
                for x in data["media"]["sources"]
                if x.get("key_systems").get(self.key_system)),
                None,
            )
        else:
            source_manifest = next((
                x["src"] for x in data["media"]["sources"] 
                if x.get("type") == "application/dash+xml"),
                None,
            )
        if not source_manifest:
            raise ValueError("Failed to get manifest")
        
        title.data["cue_points"] = data.get("media", {}).get("cue_points")
        
        tracks = DASH.from_url(source_manifest, self.session).to_tracks(title.language)

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

        return tracks

    def get_chapters(self, title: Movie | Episode) -> Chapters:
        if not (cue_points := title.data.get("cue_points")):
            return Chapters()
        
        cue_points = sorted(cue_points, key=lambda x: x["time"])

        chapters = []
        for cue_point in cue_points:
            if cue_point.get("time", 0) > 0:
                name = "End Credits" if cue_point.get("name", "").lower() == "credits" else None
                chapters.append(Chapter(name=name, timestamp=cue_point["time"] * 1000))

        return Chapters(chapters)

    def get_widevine_service_certificate(self, **_: Any) -> str:
        return None

    def get_widevine_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> Optional[Union[bytes, str]]:
        if license_url := title.data.get("license_url"):
            r = self.session.post(url=license_url, data=challenge)
            if r.status_code != 200:
                raise ConnectionError(r.text)
            return r.content

        return None
    
    def get_playready_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> Optional[Union[bytes, str]]:
        if license_url := title.data.get("license_url"):
            r = self.session.post(url=license_url, data=challenge)
            if r.status_code != 200:
                raise ConnectionError(r.text)
            return r.content

        return None

    # Service specific functions

    def _movie(self, content: dict) -> Movie:
        title = content.get("title")
        metadata = content.get("items", [{}])[0].get("videoMetadata", {})
        if not metadata:
            raise ValueError("Failed to find metadata for this movie")

        return Movie(
            id_=metadata.get("videoBref"),
            service=self.__class__,
            name=title,
            year=metadata.get("productionYear"),
            language="en",
            data=content,
        )

    def _get_season_data(self, season_id: str, slug: str) -> List[Episode]:
        params = {
            "component-id": season_id,
            "platform-id": self.config.get("PLATFORM_ID"),
            "market-id": self.market_id,
            "platform-version": self.config.get("PLATFORM_VERSION"),
            "api-version": self.config.get("API_VERSION"),
            "signedUp": "True",
        }

        try:
            r = self.session.get(f"https://component.swm.digital/component/{slug}", params=params)
            r.raise_for_status()
            comp = r.json()
        except ConnectionError as e:
            self.log.error(f"Error fetching season {season_id}: {e}")
            return []
        except Exception as e:
            self.log.error(f"An unexpected error occurred for season {season_id}: {e}")
            return []

        episodes = []
        for episode in comp.get("items", []):
            info_panel = episode.get("infoPanelData", {})
            player_data = episode.get("playerData", {})
            card_data = episode.get("cardData", {})
            catalogue_number = episode.get("catalogueNumber", "")

            title = info_panel.get("title")
            episode_name = card_data.get("image", {}).get("altTag")
            card_name = card_data.get("title", "").lstrip("0123456789. ").split(" - ")[-1].strip()
            
            season, number, name = 0, 0, card_name
            if match := re.search(r"(?:Season|Year)\s*(\d+)\s*E(?:pisode)?\s*(\d+)", episode_name, re.IGNORECASE):
                season = int(match.group(1))
                number = int(match.group(2))

            if not season and not number:
                if match := re.compile(r"\w+(\d+)-(\d+)").search(catalogue_number):
                    season = int(match.group(1))
                    number = int(match.group(2))

            episodes.append(
                Episode(
                    id_=player_data.get("episodePlayerId"),
                    service=self.__class__,
                    title=title,
                    year=card_data.get("productionYear"),
                    season=season,
                    number=number,
                    name=name,
                    language="en",
                    data=episode,
                )
            )
        return episodes

    def _series(self, content: dict, slug: str) -> List[Episode]:
        items = next((x for x in content.get("items", []) if x.get("type") == "shelfContainer"), {})
        episodes_shelf = next((x for x in items.get("items", []) if x.get("title") == "Episodes"), {})
        seasons_container = next((x for x in episodes_shelf.get("items", []) if x.get("title") in ("Season", "Year", "Bulletin")), {})
        
        season_ids = [
            item.get("items", [{}])[0].get("id")
            for item in seasons_container.get("items", [])
            if item.get("items") and item.get("items")[0].get("id")
        ]

        if not season_ids:
            return []

        all_episodes = []
        with ThreadPoolExecutor(max_workers=len(season_ids)) as executor:
            future_to_season = {
                executor.submit(self._get_season_data, season_id, slug): season_id for season_id in season_ids
            }
            for future in future_to_season:
                try:
                    episodes_of_season = future.result()
                    all_episodes.extend(episodes_of_season)
                except Exception as exc:
                    season_id = future_to_season[future]
                    self.log.error(f"{season_id} generated an exception: {exc}")

        return all_episodes
