# -*- coding: utf-8 -*-
#
# Copyright (C) 2023- The Tidalapi Developers
# Copyright (C) 2019-2022 morguldir
# Copyright (C) 2014 Thomas Amland
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
"""A module containing functions relating to TIDAL api requests."""

import json
import logging
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    List,
    Literal,
    Mapping,
    MutableMapping,
    Optional,
    Union,
    cast,
)
from urllib.parse import urljoin

import requests

from tidalapi.exceptions import ObjectNotFound, TooManyRequests
from tidalapi.types import JsonObj

log = logging.getLogger(__name__)

Params = Mapping[str, Union[str, int, None]]

Methods = Literal["GET", "POST", "PUT", "DELETE"]

if TYPE_CHECKING:
    from tidalapi.session import Session


class Requests(object):
    """A class for handling api requests to TIDAL."""

    user_agent: str
    # Latest error response that can be returned and parsed after request has been completed
    latest_err_response: requests.Response

    def __init__(self, session: "Session"):
        # More Android User-Agents here: https://user-agents.net/browsers/android
        self.user_agent = "Mozilla/5.0 (Linux; Android 12; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/91.0.4472.114 Safari/537.36"
        self.session = session
        self.config = session.config
        self.latest_err_response = requests.Response()

    def basic_request(
        self,
        method: Methods,
        path: str,
        params: Optional[Params] = None,
        data: Optional[JsonObj] = None,
        headers: Optional[MutableMapping[str, str]] = None,
        base_url: Optional[str] = None,
    ) -> requests.Response:
        request_params = {
            "sessionId": self.session.session_id,
            "countryCode": self.session.country_code,
            "limit": self.config.item_limit,
        }

        if params:
            # Don't update items with a none value, as we prefer a default value.
            # requests also does not support them.
            not_none = filter(lambda item: item[1] is not None, params.items())
            request_params.update(not_none)

        if not headers:
            headers = {}

        if "User-Agent" not in headers:
            headers["User-Agent"] = self.user_agent

        if self.session.token_type and self.session.access_token is not None:
            headers["authorization"] = (
                self.session.token_type + " " + self.session.access_token
            )
        if base_url is None:
            base_url = self.session.config.api_v1_location

        url = urljoin(base_url, path)
        request = self.session.request_session.request(
            method, url, params=request_params, data=data, headers=headers
        )

        refresh_token = self.session.refresh_token
        if not request.ok and refresh_token:
            json_resp = None
            try:
                json_resp = request.json()
            except json.decoder.JSONDecodeError:
                pass

            if json_resp and json_resp.get("userMessage", "").startswith(
                "The token has expired."
            ):
                log.debug("The access token has expired, trying to refresh it.")
                refreshed = self.session.token_refresh(refresh_token)
                if refreshed:
                    request = self.basic_request(method, url, params, data, headers)
            else:
                log.debug("HTTP error on %d", request.status_code)
                log.debug("Response text\n%s", request.text)

        return request

    def request(
        self,
        method: Methods,
        path: str,
        params: Optional[Params] = None,
        data: Optional[JsonObj] = None,
        headers: Optional[MutableMapping[str, str]] = None,
        base_url: Optional[str] = None,
    ) -> requests.Response:
        """Method for tidal requests.

        Not meant for use outside of this library.

        :param method: The type of request to make
        :param path: The TIDAL api endpoint you want to use.
        :param params: The parameters you want to supply with the request.
        :param data: The data you want to supply with the request.
        :param headers: The headers you want to include with the request
        :param base_url: The base url to use for the request
        :return: The json data at specified api endpoint.
        """

        request = self.basic_request(method, path, params, data, headers, base_url)
        log.debug("request: %s", request.request.url)
        try:
            request.raise_for_status()
        except Exception as e:
            log.info("Request resulted in exception {}".format(e))
            self.latest_err_response = request
            if request.content:
                resp = request.json()
                # Make sure request response contains the detailed error message
                if "errors" in resp:
                    log.debug("Request response: '%s'", resp["errors"][0]["detail"])
                elif "userMessage" in resp:
                    log.debug("Request response: '%s'", resp["userMessage"])
                else:
                    log.debug("Request response: '%s'", json.dumps(resp))

            if request.status_code and request.status_code == 404:
                raise ObjectNotFound
            elif request.status_code and request.status_code == 429:
                raise TooManyRequests
            else:
                # raise last error, usually HTTPError
                raise
        return request

    def get_latest_err_response(self) -> dict:
        """Get the latest request Response that resulted in an Exception :return: The
        request Response that resulted in the Exception, returned as a dict An empty
        dict will be returned, if no response was returned."""
        if self.latest_err_response.content:
            return self.latest_err_response.json()
        else:
            return {}

    def get_latest_err_response_str(self) -> str:
        """Get the latest request response message as a string :return: The contents of
        the (detailed) error response Response, returned as a string An empty str will
        be returned, if no response was returned."""
        if self.latest_err_response.content:
            resp = self.latest_err_response.json()
            return resp["errors"][0]["detail"]
        else:
            return ""

    def map_request(
        self,
        url: str,
        params: Optional[Params] = None,
        parse: Optional[Callable[..., Any]] = None,
    ) -> Any:
        """Returns the data about object(s) at the specified url, with the method
        specified in the parse argument.

        Not meant for use outside of this library

        :param url: TIDAL api endpoint that contains the data
        :param params: TIDAL parameters to use when getting the data
        :param parse: (Optional) The method used to parse the data at the url. If not
            set, jsonObj will be returned
        :return: The object(s) at the url, with the same type as the class of the parse
            method.
        """
        json_obj = self.request("GET", url, params).json()
        if parse:
            return self.map_json(json_obj, parse=parse)
        else:
            return json_obj

    @classmethod
    def map_json(
        cls,
        json_obj: JsonObj,
        parse: Optional[Callable[..., Any]] = None,
        session: Optional["Session"] = None,
    ) -> Any:
        items = json_obj.get("items")

        if items is None:
            # Not a collection of items, so returning a single object
            if parse is None:
                raise ValueError("A parser must be supplied")
            return parse(json_obj)

        if len(items) > 0 and "item" in items[0]:
            # Move created date into the item json data like it is done for playlists tracks.
            if "created" in items[0]:
                for item in items:
                    item["item"]["dateAdded"] = item["created"]

            lists: List[Any] = []
            for item in items:
                if session is not None:
                    parse = cast(
                        Callable[..., Any],
                        session.convert_type(
                            cast(str, item["type"]).lower() + "s", output="parse"
                        ),
                    )
                if parse is None:
                    raise ValueError("A parser must be supplied")
                lists.append(parse(item["item"]))

            return lists
        if parse is None:
            raise ValueError("A parser must be supplied")
        return list(map(parse, items))

    def get_items(self, url: str, parse: Callable[..., Any]) -> List[Any]:
        """Returns a list of items, used when there are over a 100 items, but TIDAL
        doesn't always allow more specifying a higher limit.

        Not meant for use outside of this library.

        :param url: TIDAL api endpoint where you get the objects.
        :param parse: The method that parses the data in the url
        item_List: List[Any] = []
        """

        params = {"offset": 0, "limit": 100}
        remaining = 100
        item_list: List[Any] = []
        while remaining == 100:
            items = self.map_request(url, params=params, parse=parse)
            remaining = len(items)
            params["offset"] += 100
            item_list.extend(items or [])
        return item_list
