# Basana
#
# Copyright 2022-2023 Gabriel Martin Becedillas Ruiz
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
.. moduleauthor:: Gabriel Martin Becedillas Ruiz <gabriel.becedillas@gmail.com>
"""

from decimal import Decimal
from typing import Any, Dict, List, Optional, Union
from urllib.parse import urlparse, urljoin
import asyncio

import aiohttp

from basana.core import token_bucket, helpers as core_helpers
from basana.core.config import get_config_value
from basana.external.bitstamp import config, helpers


class Error(Exception):
    def __init__(self, msg: str, resp: aiohttp.ClientResponse, json_response):
        super().__init__(msg)
        self.status = resp.status
        self.reason = resp.reason
        self.json_response = json_response


def raise_for_error(resp: aiohttp.ClientResponse, json_response):
    msg = None
    if isinstance(json_response, dict):
        if json_response.get("status") == "error":
            msg = json_response.get("reason", "")
        elif error := json_response.get("error"):
            msg = error
        elif (code := json_response.get("code")) and json_response.get("errors"):
            msg = code
    if msg is None and not resp.ok:
        msg = "{} {}".format(resp.status, resp.reason)
    if msg is not None:
        raise Error(msg, resp, json_response)


class APIClient:
    def __init__(
            self, api_key: Optional[str] = None, api_secret: Optional[str] = None,
            session: Optional[aiohttp.ClientSession] = None, tb: Optional[token_bucket.TokenBucketLimiter] = None,
            config_overrides: dict = {}
    ):
        assert not ((api_key is None) ^ (api_secret is None)), \
            "Both api_key and api_secret should be set, or none of them"

        self._api_key = api_key
        self._api_secret = api_secret
        self._session = session
        self._tb = tb
        self._config_overrides = config_overrides

    async def _make_request(
            self, method: str, path: str, authenticate: bool, qs_params: Dict[str, Any] = {},
            data: Dict[str, Any] = {}
    ) -> Any:
        # Throttling enabled ? go sleep if necessary.
        if self._tb and (sleep_time := self._tb.consume()):
            await asyncio.sleep(sleep_time)

        async with core_helpers.use_or_create_session(session=self._session) as session:
            headers = {}
            skip_auto_headers = ["Content-Type"]
            session_method = {
                "GET": session.get,
                "POST": session.post,
            }[method]
            base_url = get_config_value(config.DEFAULTS, "api.http.base_url", overrides=self._config_overrides)
            timeout = get_config_value(config.DEFAULTS, "api.http.timeout", overrides=self._config_overrides)
            url = urljoin(base_url, path)

            if authenticate:
                assert self._api_key is not None and self._api_secret is not None, "Missing credentials"
                hostname = urlparse(base_url).hostname
                assert hostname is not None, "hostname not set"
                nonce = helpers.generate_nonce()
                headers = helpers.get_auth_headers(
                    hostname, self._api_key, self._api_secret, nonce, method, path, qs_params=qs_params, data=data
                )
            async with session_method(
                    url, headers=headers, skip_auto_headers=skip_auto_headers, params=qs_params, data=data,
                    timeout=timeout
            ) as resp:
                json_response = None
                if (ct := resp.headers.get("Content-Type")) and ct.lower().find("application/json") == 0:
                    json_response = await resp.json()
                raise_for_error(resp, json_response)
                return json_response

    async def get_trading_pairs_info(self) -> List[Dict]:
        return await self._make_request("GET", "/api/v2/trading-pairs-info/", False)

    async def get_order_book(self, currency_pair: str, group: Optional[int] = None) -> dict:
        params = {}
        if group is not None:
            params = {"group": group}
        return await self._make_request("GET", f"/api/v2/order_book/{currency_pair}/", False, qs_params=params)

    async def get_ticker(self, currency_pair: str) -> dict:
        return await self._make_request("GET", f"/api/v2/ticker/{currency_pair}/", False)

    async def get_ohlc_data(
            self, currency_pair: str, step: int, limit: int, start: Optional[int] = None, end: Optional[int] = None
    ) -> dict:
        assert start is None or end is None, "both start and end should not be set"

        params = {
            "step": step,
            "limit": limit,
        }
        if start:
            params["start"] = start
        if end:
            params["end"] = end
        return await self._make_request("GET", f"/api/v2/ohlc/{currency_pair}/", False, qs_params=params)

    async def get_websocket_auth_token(self) -> dict:
        return await self._make_request("POST", "/api/v2/websockets_token/", True)

    async def get_account_balances(self) -> List[dict]:
        return await self._make_request("POST", "/api/v2/account_balances/", True)

    async def get_account_balance(self, currency: str) -> dict:
        return await self._make_request("POST", f"/api/v2/account_balances/{currency}/", True)

    async def get_open_orders(self, currency_pair: Optional[str] = None) -> List[dict]:
        url = "/api/v2/open_orders/{}/".format("all" if currency_pair is None else currency_pair)
        return await self._make_request("POST", url, True)

    async def get_order_status(
            self, id: Optional[Union[str, int]] = None, client_order_id: Optional[str] = None,
            omit_transactions: Optional[bool] = None
    ) -> dict:
        assert (id is not None) ^ (client_order_id is not None), "Either id or client_order_id should be set"

        data: Dict[str, Any] = {"id": id} if id else {"client_order_id": client_order_id}
        if omit_transactions:
            data["omit_transactions"] = omit_transactions
        return await self._make_request("POST", "/api/v2/order_status/", True, data=data)

    async def cancel_order(self, id: Union[str, int]) -> dict:
        return await self._make_request("POST", "/api/v2/cancel_order/", True, data={"id": id})

    async def create_market_order(
            self, action: str, currency_pair: str, amount: Decimal, client_order_id: Optional[str] = None,
            **kwargs: Dict[str, Any]
    ) -> dict:
        assert action in ["buy", "sell"], "Invalid action"

        data: Dict[str, Any] = {
            "amount": str(amount),
        }
        if client_order_id:
            data["client_order_id"] = client_order_id
        data.update(kwargs)
        return await self._make_request("POST", f"/api/v2/{action}/market/{currency_pair}/", True, data=data)

    async def create_limit_order(
            self, action: str, currency_pair: str, amount: Decimal, price: Decimal,
            client_order_id: Optional[str] = None, **kwargs: Dict[str, Any]
    ) -> dict:
        assert action in ["buy", "sell"], "Invalid action"

        data: Dict[str, Any] = {
            "amount": str(amount),
            "price": str(price),
        }
        if client_order_id:
            data["client_order_id"] = client_order_id
        data.update(kwargs)
        return await self._make_request("POST", f"/api/v2/{action}/{currency_pair}/", True, data=data)

    async def create_instant_order(
            self, action: str, currency_pair: str, amount: Decimal, amount_in_counter: bool = False,
            client_order_id: Optional[str] = None, **kwargs: Dict[str, Any]
    ) -> dict:
        assert action in ["buy", "sell"], "Invalid action"
        assert amount_in_counter is False or action == "sell", "amount_in_counter only supported for sell orders"

        data: Dict[str, Any] = {
            "amount": str(amount),
        }
        if client_order_id:
            data["client_order_id"] = client_order_id
        if amount_in_counter:
            data["amount_in_counter"] = amount_in_counter
        data.update(kwargs)
        return await self._make_request("POST", f"/api/v2/{action}/instant/{currency_pair}/", True, data=data)
