import logging
import ntpath
import os
import sys
import threading
from datetime import timedelta
from enum import Enum
from functools import partial
from http.cookies import SimpleCookie
from typing import List, Optional, Union

import requests
from cachetools.func import ttl_cache
from feeluown.library import SearchType
from requests import Response
from ytmusicapi import YTMusic as YTMusicBase
from ytmusicapi.ytmusic import OAuthCredentials

from fuo_ytmusic.headerfile import (
    update_headerfile_cookie,
)
from fuo_ytmusic.helpers import Singleton
from fuo_ytmusic.lyrics import (
    build_watch_playlist_body,
    extract_lyrics_browse_id,
    timestamped_lyrics_to_lrc,
)
from fuo_ytmusic.models import (
    AlbumInfo,
    ArtistInfo,
    Categories,
    PlaylistAddItemResponse,
    PlaylistInfo,
    PlaylistNestedResult,
    SongInfo,
    UserInfo,
    YtmusicDispatcher,
    YtmusicHistorySong,
    YtmusicLibraryArtist,
    YtmusicLibrarySong,
    YtmusicSearchAlbum,
    YtmusicSearchArtist,
    YtmusicSearchBase,
    YtmusicSearchPlaylist,
    YtmusicSearchSong,
    YtmusicSearchVideo,
)
from fuo_ytmusic.profile import YtmusicProfileManager

CACHE_TTL = timedelta(minutes=10).seconds
CACHE_SIZE = 1
GLOBAL_LIMIT = 20

logger = logging.getLogger(__name__)


class YtmusicType(Enum):
    so = "songs"
    vi = "videos"
    ar = "artists"
    al = "albums"
    pl = "playlists"

    # noinspection PyTypeChecker
    @classmethod
    def parse(cls, type_: SearchType) -> "YtmusicType":
        return cls._value2member_map_.get(type_.value + "s")


class YtmusicPrivacyStatus(Enum):
    PUBLIC = "PUBLIC"
    PRIVATE = "PRIVATE"
    UNLISTED = "UNLISTED"

    # noinspection PyTypeChecker
    @classmethod
    def parse(cls, type_: str) -> "YtmusicPrivacyStatus":
        return cls._value2member_map_.get(type_)


class YtmusicScope(Enum):
    li = "library"
    up = "uploads"


class YTMusic(YTMusicBase):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.headerfile_path = None

    def set_headerfile_path(self, headerfile_path):
        self.headerfile_path = headerfile_path

    def check_auth(self):
        self._check_auth()

    def set_on_behalf_of_user(self, gaia_id):
        context = self.context.setdefault("context", {})
        user_ctx = context.setdefault("user", {})
        if gaia_id:
            user_ctx["onBehalfOfUser"] = gaia_id
        else:
            user_ctx.pop("onBehalfOfUser", None)

    def send_api_request(self, endpoint: str, body: dict):
        return self._send_request(endpoint, body)

    def request_with_auth(
        self, method: str, url: str, origin: str = None, json_body=None
    ):
        headers = dict(self.headers)
        if origin:
            headers["origin"] = origin
        return self._session.request(
            method,
            url,
            json=json_body,
            headers=headers,
            proxies=self._session.proxies,
            cookies=self.cookies,
        )

    def update_auth_cookie_from_response(self, response):
        cookie_header = self._auth_headers.get("cookie", "")
        if not response.cookies:
            return
        jar = SimpleCookie()
        if cookie_header:
            jar.load(cookie_header)
        for cookie in response.cookies:
            jar[cookie.name] = cookie.value
        new_cookie = "; ".join([f"{m.key}={m.value}" for m in jar.values()])
        if new_cookie:
            self._auth_headers["cookie"] = new_cookie
            self._persist_cookie_to_headerfile(new_cookie)

    def _persist_cookie_to_headerfile(self, cookie_value: str):
        update_headerfile_cookie(cookie_value, self.headerfile_path)

    class IterableToFileAdapter(object):
        def __init__(self, iterable):
            self.iterator = iter(iterable)
            self.length = len(iterable)

        def read(self, size=-1):  # TBD: add buffer for `len(data) > size` case
            return next(self.iterator, b"")

        def __len__(self):
            return self.length

    class ChunckedUpload(object):
        def __init__(self, filename, chunksize=1 << 6):
            self.filename = filename
            self.chunksize = chunksize
            self.totalsize = os.path.getsize(filename)
            self.readsofar = 0

        def __iter__(self):
            with open(self.filename, "rb") as file:
                while True:
                    data = file.read(self.chunksize)
                    if not data:
                        sys.stdout.write("\n")
                        break
                    self.readsofar += len(data)
                    percent = self.readsofar * 1e2 / self.totalsize
                    sys.stdout.write(
                        "\rUploading: {percent:3.0f}%".format(percent=percent)
                    )
                    yield data

        def __len__(self):
            return self.totalsize

    def upload_song_progress(self, filepath: str) -> Union[str, requests.Response]:
        """
        Uploads a song to YouTube Music

        :param filepath: Path to the music file (mp3, m4a, wma, flac or ogg)
        :return: Status String or full response
        """
        self._check_auth()
        if not os.path.isfile(filepath):
            raise Exception("The provided file does not exist.")

        supported_filetypes = ["mp3", "m4a", "wma", "flac", "ogg"]
        if os.path.splitext(filepath)[1][1:] not in supported_filetypes:
            raise Exception(
                "The provided file type is not supported by YouTube Music."
                f" Supported file types are {', '.join(supported_filetypes)}"
            )

        headers = self.headers.copy()
        upload_url = "https://upload.youtube.com/upload/usermusic/http?authuser=0"
        filesize = os.path.getsize(filepath)
        body = ("filename=" + ntpath.basename(filepath)).encode("utf-8")
        headers.pop("content-encoding", None)
        headers["content-type"] = "application/x-www-form-urlencoded;charset=utf-8"
        headers["X-Goog-Upload-Command"] = "start"
        headers["X-Goog-Upload-Header-Content-Length"] = str(filesize)
        headers["X-Goog-Upload-Protocol"] = "resumable"
        response = requests.post(
            upload_url, data=body, headers=headers, proxies=self.proxies
        )
        headers["X-Goog-Upload-Command"] = "upload, finalize"
        headers["X-Goog-Upload-Offset"] = "0"
        upload_url = response.headers["X-Goog-Upload-URL"]
        response = requests.post(
            upload_url,
            data=YTMusic.IterableToFileAdapter(YTMusic.ChunckedUpload(filepath)),
            headers=headers,
            proxies=self.proxies,
        )
        if response.status_code == 200:
            return "STATUS_SUCCEEDED"
        else:
            return response


class YtmusicService(metaclass=Singleton):
    def __init__(self):
        self._session = requests.Session()
        self._api: Optional[YTMusic] = None
        self._anonymous_lyrics_api: Optional[YTMusic] = None
        self._timeout: Optional[int] = None
        self._session.hooks["response"].append(self._do_logging)

        self._signature_timestamp = 0
        self._api_lock = threading.Lock()
        self._anonymous_lyrics_api_lock = threading.Lock()
        self._profile_manager = YtmusicProfileManager(self)
        self._language: Optional[str] = None

    @staticmethod
    def _do_logging(r: Response, *_, **__):
        logger.debug(
            f"[ytmusic] Requesting: [{r.request.method.upper()}] {r.url}; "
            f"Response: [{r.status_code}] {len(r.content)} bytes."
        )

    @property
    def api(self) -> YTMusic:
        if self._api is None:
            with self._api_lock:
                if self._api is None:
                    self.reinitialize_by_headerfile()
        return self._api

    def _log_thread(self):
        return f"Thread({threading.get_ident()})"

    def get_signature_timestamp(self):
        return 0

    def reinitialize_by_headerfile(self, headerfile=None):
        self._api = self._create_api(headerfile, self._session)

    def _create_api(self, headerfile, session: requests.Session) -> YTMusic:
        language = self._language or "zh_CN"
        options = dict(
            requests_session=session,
            language=language,
            oauth_credentials=OAuthCredentials(
                # In the new version of ytmusicapi, client_id and client_secret
                # are required args.
                #   https://github.com/sigma67/ytmusicapi/pull/688
                # Hardcode the client_id and client_secret to workaround.
                client_id=(
                    "861556708454-d6dlm3lh05idd8npek18k6be8ba3oc68"
                    ".apps.googleusercontent.com"
                ),
                client_secret="SboVhoG9s0rNafixCSGGKXAT",
                session=session,
            ),
        )
        # Due to https://github.com/sigma67/ytmusicapi/issues/676,
        # YTMusic does not work in specific cases when auth file is provided.
        # So initialize without auth file when 400 is returned.
        if headerfile is not None and headerfile.exists():
            logger.info("Initializing ytmusic api with headerfile.")
            api = YTMusic(str(headerfile), **options)
            api.set_headerfile_path(headerfile)
        else:
            logger.info("Initializing ytmusic api with no headerfile.")
            api = YTMusic(**options)
        return api

    def _get_anonymous_lyrics_api(self) -> YTMusic:
        if self._anonymous_lyrics_api is None:
            with self._anonymous_lyrics_api_lock:
                if self._anonymous_lyrics_api is None:
                    # Headerfile-authenticated clients can get HTTP 400 when
                    # requesting timestamped lyrics, while anonymous requests
                    # for the same lyrics browse id succeed.
                    session = requests.Session()
                    session.hooks["response"].append(self._do_logging)
                    session.proxies = dict(self._session.proxies)
                    if self._timeout is not None:
                        session.request = partial(
                            session.request,
                            timeout=self._timeout,
                        )
                    self._anonymous_lyrics_api = self._create_api(None, session)
        return self._anonymous_lyrics_api

    def _clear_anonymous_lyrics_api(self):
        with self._anonymous_lyrics_api_lock:
            self._anonymous_lyrics_api = None

    def setup_language(self, language: str):
        self._language = language
        self._clear_anonymous_lyrics_api()

    def setup_http_proxy(self, http_proxy):
        self._session.proxies = {
            "http": http_proxy,
            "https": http_proxy,
        }
        self._clear_anonymous_lyrics_api()

    def setup_timeout(self, timeout):
        self._timeout = timeout
        if isinstance(self._session.request, partial):
            request = self._session.request.func
        else:
            request = self._session.request
        self._session.request = partial(request, timeout=timeout)
        self._clear_anonymous_lyrics_api()

    def search(
        self,
        keywords: str,
        t: Optional[YtmusicType],
        scope: YtmusicScope = None,
        page_size: int = GLOBAL_LIMIT,
    ) -> List[
        Union[
            YtmusicSearchSong,
            YtmusicSearchAlbum,
            YtmusicSearchArtist,
            YtmusicSearchVideo,
            YtmusicSearchPlaylist,
            YtmusicSearchBase,
        ]
    ]:
        response = self.api.search(
            keywords,
            None if t is None else t.value,
            None if scope is None else scope.value,
            page_size,
        )
        return [YtmusicDispatcher.search_result_dispatcher(**data) for data in response]

    def _daily_home_cache_key(self) -> str:
        context = getattr(self.api, "context", {})
        if not isinstance(context, dict):
            return ""
        user_ctx = context.get("context", {}).get("user", {})
        if not isinstance(user_ctx, dict):
            return ""
        return str(user_ctx.get("onBehalfOfUser") or "")

    @ttl_cache(maxsize=8, ttl=30)
    def _home_sections_cached(self, limit: int, account_key: str):
        return self.api.get_home(limit)

    def home_sections(self, limit: int = 12):
        return self._home_sections_cached(limit, self._daily_home_cache_key())

    def get_current_account_info(self) -> dict:
        return self._profile_manager.get_current_account_info()

    def list_profiles(self) -> List[dict]:
        return self._profile_manager.list_profiles()

    def switch_profile(self, account_name: str = None, gaia_id: str = None) -> dict:
        return self._profile_manager.switch_profile(
            account_name=account_name, gaia_id=gaia_id
        )

    @ttl_cache(maxsize=CACHE_SIZE, ttl=CACHE_TTL)
    def artist_info(self, channel_id: str) -> ArtistInfo:
        data = self.api.get_artist(channel_id)
        return ArtistInfo(**data)

    @ttl_cache(maxsize=CACHE_SIZE, ttl=CACHE_TTL)
    def artist_albums(self, channel_id: str, params: str) -> List[YtmusicSearchAlbum]:
        response = self.api.get_artist_albums(channel_id, params)
        return [YtmusicSearchAlbum(**data) for data in response]

    @ttl_cache(maxsize=CACHE_SIZE, ttl=CACHE_TTL)
    def user_info(self, channel_id: str) -> UserInfo:
        return UserInfo(**self.api.get_user(channel_id))

    def user_playlists(self, channel_id: str, params: str):
        return self.api.get_user_playlists(channel_id, params)

    @ttl_cache(maxsize=CACHE_SIZE, ttl=CACHE_TTL)
    def album_info(self, browse_id: str) -> AlbumInfo:
        data = self.api.get_album(browse_id)
        return AlbumInfo(**data)

    def song_info(self, video_id: str) -> SongInfo:
        return SongInfo(**self.api.get_song(video_id, self.get_signature_timestamp()))

    def _song_lyrics_browse_id(self, video_id: str, api=None) -> Optional[str]:
        if api is None:
            api = self.api
        response = api.send_api_request("next", build_watch_playlist_body(video_id))
        return extract_lyrics_browse_id(response)

    def song_lyrics(self, video_id: str) -> Optional[str]:
        lyrics_api = self._get_anonymous_lyrics_api()
        lyrics_browse_id = self._song_lyrics_browse_id(video_id, api=lyrics_api)
        if lyrics_browse_id is None:
            return None
        lyrics_payload = lyrics_api.get_lyrics(lyrics_browse_id, timestamps=True)
        return timestamped_lyrics_to_lrc(lyrics_payload)

    @ttl_cache(maxsize=CACHE_SIZE, ttl=CACHE_TTL)
    def categories(self) -> List[Categories]:
        return [
            Categories(key=k, value=v)
            for k, v in self.api.get_mood_categories().items()
        ]

    @ttl_cache(maxsize=CACHE_SIZE, ttl=CACHE_TTL)
    def category_playlists(self, params: str) -> List[PlaylistNestedResult]:
        response = self.api.get_mood_playlists(params)
        return [PlaylistNestedResult(**data) for data in response]

    @ttl_cache(maxsize=CACHE_SIZE, ttl=CACHE_TTL)
    def get_charts(self, country: str = "ZZ") -> dict:
        response = self.api.get_charts(country)
        return response if isinstance(response, dict) else {}

    def library_playlists(
        self, limit: int = GLOBAL_LIMIT
    ) -> List[PlaylistNestedResult]:
        response = self.api.get_library_playlists(limit)
        return [PlaylistNestedResult(**data) for data in response]

    def library_songs(self, limit: int = GLOBAL_LIMIT) -> List[YtmusicLibrarySong]:
        response = self.api.get_library_songs(limit)
        return [YtmusicLibrarySong(**data) for data in response]

    def library_albums(self, limit: int = GLOBAL_LIMIT) -> List[YtmusicSearchAlbum]:
        response = self.api.get_library_albums(limit)
        return [YtmusicSearchAlbum(**data) for data in response]

    def library_artists(self, limit: int = GLOBAL_LIMIT) -> List[YtmusicLibraryArtist]:
        response = self.api.get_library_artists(limit)
        return [YtmusicLibraryArtist(**data) for data in response]

    def library_subscription_artists(
        self, limit: int = GLOBAL_LIMIT
    ) -> List[YtmusicLibraryArtist]:
        response = self.api.get_library_subscriptions(limit)
        return [YtmusicLibraryArtist(**data) for data in response]

    @ttl_cache(maxsize=CACHE_SIZE, ttl=CACHE_TTL)
    def playlist_info(
        self, playlist_id: str, limit: int = GLOBAL_LIMIT
    ) -> PlaylistInfo:
        return PlaylistInfo(**self.api.get_playlist(playlist_id, limit))

    def liked_songs(self, limit: int = GLOBAL_LIMIT) -> PlaylistInfo:
        return PlaylistInfo(**self.api.get_liked_songs(limit))

    def history(self) -> List[YtmusicHistorySong]:
        response = self.api.get_history()
        return [YtmusicHistorySong(**data) for data in response]

    def create_playlist(
        self,
        title: str,
        description: str,
        privacy_status: YtmusicPrivacyStatus,
        video_ids: List[str] = None,
        source_playlist: str = None,
    ) -> bool:
        response = self.api.create_playlist(
            title, description, privacy_status.value, video_ids, source_playlist
        )
        if not isinstance(response, str):
            return False
        return True

    def add_playlist_items(
        self,
        playlist_id: str,
        video_ids: List[str] = None,
        source_playlist_id: str = None,
    ) -> PlaylistAddItemResponse:
        return PlaylistAddItemResponse(
            **self.api.add_playlist_items(playlist_id, video_ids, source_playlist_id)
        )

    def remove_playlist_items(
        self, playlist_id: str, video_ids: List[dict]
    ) -> Optional[str]:
        # STATUS_SUCCEEDED STATUS_FAILED
        return self.api.remove_playlist_items(playlist_id, video_ids)

    def library_upload_songs(
        self, limit: int = GLOBAL_LIMIT
    ) -> List[YtmusicLibrarySong]:
        response = self.api.get_library_upload_songs(limit)
        return [YtmusicLibrarySong(**data) for data in response]

    def library_upload_artists(
        self, limit: int = GLOBAL_LIMIT
    ) -> List[YtmusicLibraryArtist]:
        response = self.api.get_library_upload_artists(limit)
        return [YtmusicLibraryArtist(**data) for data in response]

    def library_upload_albums(
        self, limit: int = GLOBAL_LIMIT
    ) -> List[YtmusicSearchAlbum]:
        response = self.api.get_library_upload_albums(limit)
        return [YtmusicSearchAlbum(**data) for data in response]

    def upload_song(self, file: str) -> str:
        # STATUS_SUCCEEDED
        return self.api.upload_song_progress(file)

    def delete_upload_song(self, entity_id: str) -> str:
        # STATUS_SUCCEEDED
        return self.api.delete_upload_entity(entity_id)

    def stream_url(
        self, song_info: SongInfo, video_id: str, format_code: int
    ) -> Optional[str]:
        formats = song_info.streamingData.adaptiveFormats
        for f in formats:
            if int(f.itag) == format_code:
                return self._get_stream_url(f, video_id)
        return None

    def check_stream_url(self, url):
        resp = self._session.head(url)
        return resp.status_code != 403


if __name__ == "__main__":
    # noinspection PyUnresolvedReferences

    service = YtmusicService()
    # print(service.upload_song('/home/bruce/Music/阿梓 - 呼吸决定.mp3'))
    print(service.api.delete_upload_entity("t_po_CJT896eTt_a5swEQwrmdzf7_____AQ"))
