from __future__ import annotations

import base64
import concurrent.futures
import hashlib
import hmac
import json
import re
import sys
import time
import uuid
from collections.abc import Generator
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from http.cookiejar import MozillaCookieJar
from typing import Any, Optional, Union

import click
import m3u8
from click import Context
from langcodes import Language
from requests import Request
from unshackle.core.config import config
from unshackle.core.credential import Credential
from unshackle.core.downloaders import requests
from unshackle.core.manifests import HLS
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, Subtitle, Tracks, Video


class TEN(Service):
    """
    \b
    Service code for 10Play streaming service (https://10.com.au/).

    \b
    Version: 1.0.2
    Author: stabbedbybrick
    Authorization: credentials
    Geofence: AU (API and downloads)
    Robustness:
      AES: 1080p, AAC2.0

    \b
    Tips:
        - Input should be complete URL:
          SHOW: https://10.com.au/australian-survivor
          EPISODE: https://10.com.au/australian-survivor/episodes/season-11-australia-v-the-world/episode-9/tpv250831fxatm
          MOVIE: https://10.com.au/a-quiet-place
        - Non-standard programmes (e.g. game shows/sports) have very inconsistent episode number labels. It's recommended to use episode URLs for those. 

    \b
    Notes:
        - 10Play uses transport streams for HLS, meaning the video and audio are a part of the same stream.
          As a result, only videos are listed as tracks. But the audio will be included as well.
        - Since 1080p streams require some manipulation of the manifest, n_m3u8dl_re downloader is required.

    """

    GEOFENCE = ("au",)
    ALIASES = (
        "10play",
        "tenplay",
    )

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

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

        if config.downloader != "n_m3u8dl_re":
            self.log.error(" - Error: n_m3u8dl_re downloader is required for this service.")
            sys.exit(1)

    def search(self) -> Generator[SearchResult, None, None]:
        query = self.endpoints["searchApiEndpoint"] + self.title

        results = self._request("GET", query)

        for result in results:
            clean_title = self._sanitize(result.get("title"))
            yield SearchResult(
                id_=f"https://10.com.au/{clean_title}",
                title=result.get("title"),
                description=result.get("abstractShowDescription"),
                label=result.get("subtitle", "").split("|")[-1].strip(),
                url=f"https://10.com.au/{clean_title}",
            )

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

        self.session.headers.update(self.config["headers"])
        self.endpoints = self._request(
            "GET", self.config["endpoints"]["config"], params={"SystemName": "tvos"}
        )

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

        if cache and not cache.expired:
            self.log.info(" + Using cached Tokens...")
            tokens = cache.data
        elif cache and cache.expired:
            self.log.info(" + Refreshing expired Tokens...")
            payload = {
                "alternativeToken": cache.data["alternativeToken"],
                "refreshToken": cache.data["refreshToken"],
            }
            tokens = self._request(
                "POST", self.endpoints["authConfig"]["refreshToken"], json=payload
            )
            cache.set(tokens, expiration=tokens["expiresIn"])
        else:
            self.log.info(" + Logging in...")
            headers = {
                "accept": "application/json, text/plain, */*",
                "content-type": "application/json",
                "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
                "origin": "https://10.com.au",
                "referer": "https://10.com.au/",
            }
            login = self._request(
                "POST",
                self.config["endpoints"]["auth"],
                headers=headers,
                json={"email": credential.username, "password": credential.password},
            )
            access_token = login.get("jwt", {}).get("accessToken")
            if not access_token:
                raise ValueError(
                    "Failed to authenticate with credentials: " + login.text
                )

            identifier = str(uuid.uuid4())

            payload = {
                "deviceIdentifier": identifier,
                "machine": "Hisense",
                "system": "vidaa",
                "systemVersion": "U6",
                "platform": "vidaa",
                "appVersion": "v1",
                "ipAddress": "string",
            }
            device = self._request(
                "POST", self.endpoints["authConfig"]["generateCode"], json=payload
            )

            code = device.get("code")
            expiry = device.get("expiry")
            if not code or not expiry:
                raise ValueError("Failed to generate device code: " + device.text)

            headers = {
                "accept": "application/json, text/plain, */*",
                "authorization": f"Bearer {access_token}",
                "content-type": "application/json",
                "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
                "origin": "https://10.com.au",
                "referer": "https://10.com.au/activate",
            }
            activate = self._request(
                "POST",
                self.endpoints["activateApiEndpoint"],
                headers=headers,
                json={"code": code},
            )
            if not activate:
                raise ValueError("Failed to activate device")

            payload = {
                "code": code,
                "deviceIdentifier": identifier,
                "expiry": expiry,
            }
            auth = self._request(
                "POST",
                self.endpoints["authConfig"]["validateCode"],
                json={
                    "code": code,
                    "deviceIdentifier": identifier,
                    "expiry": expiry,
                },
            )
            tokens = auth.get("jwt")
            tokens["identifier"] = identifier

            self.log.info(" + User successfully logged in, TV device activated")

            cache.set(tokens, expiration=tokens.get("expiresIn"))

        self.access_token = tokens.get("alternativeToken")
        self.session.headers.update({"authorization": f"Bearer {self.access_token}"})

    def get_titles(self) -> Union[Movies, Series]:
        url_pattern = re.compile(
            r"^https://10\.com\.au/(?:[a-z0-9-]+)"
            r"(?:/episodes/(?:season-)?(?P<season>[a-z0-9-]+)/(?:episode-)?(?P<episode>[a-z0-9-]+)/(?P<id>[a-z0-9]+))?$"
        )

        match = url_pattern.match(self.title)
        if not match:
            raise ValueError(f"Could not parse ID from title: {self.title}")

        matches = match.groupdict()

        if not matches.get("id"):
            show_id = self._get_html(self.title)
            content = self._shows(show_id)

            if "movie" in content.get("subtitle", "").lower():
                movies = self._movie(content)
                return Movies(movies)

            else:
                episodes = self._series(content)
                return Series(episodes)

        else:
            episodes = self._episode(matches.get("id"))
            return Series(episodes)

    def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
        playback_url = title.data.get("playbackApiEndpoint")
        if not playback_url:
            raise ValueError("Could not find playback URL for this title")

        params = {
            "device": "Tv",
            "platform": "vidaa",
            "appVersion": "v1",
        }

        r = self.session.get(playback_url, params=params)
        if not r.ok:
            raise ValueError("Failed to get playback data: " + r.text)
        
        
        dai_auth = r.headers.get("X-DAI-AUTH")
        video_id = r.headers.get("x-dai-video-id")
        if dai_auth is not None:
            payload = {"auth-token": dai_auth}
        
        playback_data = r.json()

        video_id = playback_data.get("dai", {}).get("videoId")
        source_id = playback_data.get("dai", {}).get("contentSourceId", "2690006")
        if not video_id or not source_id:
            raise ValueError("Failed to get video ID: " + r.text)
        
        dai_stream = f"https://dai.google.com/ondemand/v1/hls/content/{source_id}/vid/{video_id}/stream"

        stream_data = self._request("POST", dai_stream, data=payload)

        title.data["chapters"] = stream_data.get("time_events_url")
        # program_language = Language.find(stream_data["customFields"].get("program_language", "en"))

        manifest_url = stream_data.get("stream_manifest")
        tracks = HLS.from_url(manifest_url, self.session).to_tracks(language="en")

        tracks = self._add_tracks(tracks)

        for track in tracks:
            track.OnSegmentFilter = lambda x: re.search(r"redirector.googlevideo.com", x.uri)
            track.downloader_args = {"--ad-keyword": "redirector.googlevideo.com"}

            if isinstance(track, Subtitle):
                track.downloader = requests

        # if caption := stream_data.get("subtitles", [])[0].get("webvtt"):
        #     tracks.add(
        #         Subtitle(
        #             id_=hashlib.md5(caption.encode()).hexdigest()[0:6],
        #             url=caption,
        #             codec=Subtitle.Codec.from_mime(caption[-3:]),
        #             language=stream_data.get("subtitles", [])[0].get("language", "en"),
        #         )
        #     )

        return tracks

    def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
        if not title.data.get("chapters"):
            return Chapters()
        
        events = self._request("GET", title.data["chapters"])
        cue_points = events.get("cuepoints")
        if not cue_points:
            return Chapters()

        chapters = []
        for cue in cue_points:
            chapters.append(Chapter(timestamp=float(cue["start_float"])))
            chapters.append(Chapter(timestamp=float(cue["end_float"])))

        return Chapters(chapters)

    # Service specific

    def _head_request(self, url: str) -> int:
        try:
            return self.session.head(url, timeout=10).status_code
        except Exception:
            return 0

    def _check_and_add_track(
        self, best_track: Video, quality_info: dict, source_bitrate: int
    ) -> Video | None:
        playlist_uri = best_track.data["hls"]["playlist"].uri
        playlist_text = self.session.get(playlist_uri).text

        string_to_replace = f"-{source_bitrate}"
        replacement_string = f"-{quality_info['bitrate']}"
        
        lines = []
        for line in playlist_text.splitlines():
            if "redirector.googlevideo.com" in line:
                continue
            
            if string_to_replace in line:
                line = line.replace(string_to_replace, replacement_string)
            
            lines.append(line)

        modified_playlist_text = "\n".join(lines)
        playlist_obj = m3u8.loads(modified_playlist_text)

        if not playlist_obj.segments:
            return None

        first_segment = playlist_obj.segments[0].uri
        if self._head_request(first_segment) == 200:
            playlist_file = config.directories.cache / "TEN" / f"playlist_{quality_info['quality']}.m3u8"
            playlist_obj.dump(playlist_file)

            video = Video(
                id_=f"{best_track.id}-{quality_info['quality']}",
                url=best_track.url,
                height=quality_info["height"],
                width=quality_info["width"],
                bitrate=quality_info["bitrate"],
                language=best_track.language,
                codec=best_track.codec,
                range_=best_track.range,
                fps=best_track.fps,
                descriptor=best_track.descriptor,
                data=best_track.data.copy(),
                from_file=playlist_file,
            )
            return video
        return None

    def _add_tracks(self, tracks: Tracks) -> Tracks:
        if not tracks.videos:
            return tracks

        best_track = max(tracks.videos, key=lambda t: t.height or 0)

        source_bitrate = {
            1080: "5000000",
            720: "3000000",
            540: "1500000",
            360: "750000",
        }.get(best_track.height)

        all_qualities = [
            {"quality": "540p", "bitrate": 1500000, "height": 540, "width": 960},
            {"quality": "720p", "bitrate": 3000000, "height": 720, "width": 1280},
            {"quality": "1080p", "bitrate": 5000000, "height": 1080, "width": 1920},
        ]

        qualities_to_check = [
            q for q in all_qualities if q["height"] > best_track.height
        ]

        if not qualities_to_check:
            return tracks

        with ThreadPoolExecutor(max_workers=len(qualities_to_check)) as executor:
            future_to_track = {
                executor.submit(self._check_and_add_track, best_track, quality, source_bitrate): quality
                for quality in qualities_to_check
            }
            
            for future in concurrent.futures.as_completed(future_to_track):
                new_track = future.result()
                if new_track:
                    tracks.add(new_track)
        
        return tracks


    def _shows(self, show_id: str) -> dict:
        show = self._request("GET", f'{self.endpoints["showsApiEndpoint"]}/{show_id}')

        return show[0] if isinstance(show, list) else show

    def _fetch_episode(self, url: str) -> list:
        return self._request("GET", url)

    def _series(self, content: dict) -> Episode:
        season_list = content.get("seasons")
        if not season_list:
            raise ValueError("Could not find a season list for this title")

        seasons = [
            season.get("menuItems", [])[0].get("apiEndpoint")
            for season in season_list
            if season.get("menuItems", [])
            and season.get("menuItems", [])[0].get("menuTitle", "").lower()
            == "episodes"
        ]

        if not seasons:
            raise ValueError("Could not find a season list for this title")

        with ThreadPoolExecutor(max_workers=10) as executor:
            results = list(executor.map(self._fetch_episode, seasons))

        titles = []
        for result in results:
            for episode in result:
                ep_number = episode.get("episode")
                sea_number = episode.get("season")
                titles.append(
                    Episode(
                        id_=episode.get("id"),
                        service=self.__class__,
                        name=episode.get("vodTitle", "").split(" - ")[-1],
                        season=int(sea_number) if sea_number and sea_number.isdigit() else 0,
                        number=int(ep_number) if ep_number and ep_number.isdigit() else 0,
                        title=episode.get("tvShow"),
                        data=episode,
                    )
                )

        return titles

    def _movie(self, data: dict) -> Movie:
        endpoint = next(
            (
                season.get("menuItems", [])[0].get("apiEndpoint")
                for season in data.get("seasons", [])
                if season.get("menuItems", [])
            ),
            None,
        )
        if not endpoint:
            raise ValueError("Could not find an endpoint for this title")

        movie = self._request("GET", endpoint)[0]

        return [
            Movie(
                id_=movie.get("id"),
                service=self.__class__,
                name=movie.get("title"),
                year=movie.get("season"),
                data=movie,
            )
        ]

    def _episode(self, video_id: str) -> Episode:
        data = self._request("GET", f"{self.endpoints['videosApiEndpoint']}/{video_id}")

        ep_number = data.get("episode")
        sea_number = data.get("season")
        return [
            Episode(
                id_=data.get("id"),
                service=self.__class__,
                name=data.get("vodTitle", "").split(" - ")[-1],
                season=int(sea_number) if sea_number and sea_number.isdigit() else 0,
                number=int(ep_number) if ep_number and ep_number.isdigit() else 0,
                title=data.get("tvShow"),
                data=data,
            )
        ]

    def _get_html(self, url: str) -> Optional[str]:
        page = self.session.get(url).text
        pattern = re.compile(r"const showPageData = ({.*?});", re.DOTALL)

        match = pattern.search(page)
        if not match:
            raise ValueError(
                " - Failed to parse HTML. Page Data not found in the source code."
            )

        page_data = match.group(1)

        try:
            data = json.loads(page_data)
        except json.JSONDecodeError as e:
            raise json.JSONDecodeError(f"Failed to parse JSON: {e}")

        show_id = data.get("video", {}).get("showUrlCode")
        if not show_id:
            raise ValueError(" - showUrlCode not found in the source code.")

        return show_id

    def _signature_header(self, url: str) -> str:
        timestamp = int(time.time())
        message = f"{timestamp}:{url}".encode("utf-8")
        api_key = bytes.fromhex(self.config["api_key"])
        signature = hmac.new(api_key, message, hashlib.sha256).hexdigest()
        return f"{timestamp}_{signature}"

    def _auth_header(self) -> str:
        now_utc = datetime.now(timezone.utc)
        timestamp_str = now_utc.strftime("%Y%m%d%H%M%S")
        encoded_bytes = base64.b64encode(timestamp_str.encode("utf-8"))
        return encoded_bytes.decode("ascii")

    def _request(self, method: str, url: str, **kwargs: Any) -> Any[dict | str]:
        if method == "GET":
            self.session.headers.update(
                {
                    "X-N10-SIG": self._signature_header(url),
                    "tp-acceptfeature": "v1/fw;v1/drm;v2/live",
                    "tp-platform": "UAP",
                }
            )
        elif method == "POST":
            self.session.headers.update({"X-Network-Ten-Auth": self._auth_header()})

        prep = self.session.prepare_request(Request(method, url, **kwargs))

        response = self.session.send(prep)
        if response.status_code not in (200, 201):
            raise ConnectionError(f"{response.text}")

        try:
            return json.loads(response.content)

        except json.JSONDecodeError:
            return True if "true" in response.text else False

    @staticmethod
    def _sanitize(title: str) -> str:
        title = title.lower()
        title = title.replace("&", "and")
        title = re.sub(r"[:;/()]", "", title)
        title = re.sub(r"[ ]", "-", title)
        title = re.sub(r"[\\*!?¿,'\"<>|$#`’]", "", title)
        title = re.sub(rf"[{'.'}]{{2,}}", ".", title)
        title = re.sub(rf"[{'_'}]{{2,}}", "_", title)
        title = re.sub(rf"[{'-'}]{{2,}}", "-", title)
        title = re.sub(rf"[{' '}]{{2,}}", " ", title)
        return title
