from abc import ABC
from decimal import Decimal
from logging import Logger
from typing import Dict, List, Set

import maya
from cached_property import cached_property
from trading_api_wrappers import Bitfinex, BitfinexV2

from trading_bots.core.storage import Store

from ...clients import *
from ...errors import *
from ...models import *

__all__ = [
    "BitfinexPublic",
    "BitfinexAuth",
    "BitfinexMarket",
    "BitfinexWallet",
    "BitfinexTrading",
]

DEFAULT_WALLET_TYPE = "exchange"


class BitfinexBase(BaseClient, ABC):
    name: str = "Bitfinex"

    common_currencies = {
        "BAB": "BCH",
        "UDC": "USDC",
        "UST": "USDT",
    }

    def _markets(self) -> Set[Market]:
        symbols = self.client.symbols()

        def generate_markets():
            for symbol in symbols:
                symbol = symbol.upper()
                if len(symbol) == 6:
                    base, quote = symbol[:3], symbol[3:]
                else:
                    base, quote = symbol.split(":")
                base = self._parse_common_currency(base)
                quote = self._parse_common_currency(quote)
                yield Market(base, quote)

        return set(generate_markets())

    def _get_market_from_pair(self, pair):
        for market in self.markets:
            base = self._parse_common_currency(market.base, reverse=True)
            quote = self._parse_common_currency(market.quote, reverse=True)
            m = base + quote
            if m == pair:
                return market
        raise KeyError


class BitfinexPublic(BitfinexBase):
    @cached_property
    def client_v1(self) -> Bitfinex.Public:
        return Bitfinex.Public(**self.client_params)

    @cached_property
    def client_v2(self) -> BitfinexV2.Public:
        return BitfinexV2.Public(**self.client_params)

    @cached_property
    def client(self) -> Bitfinex.Public:
        return self.client_v1


class BitfinexAuth(BitfinexBase):
    @cached_property
    def client(self) -> Bitfinex.Auth:
        return Bitfinex.Auth(**self.credentials, **self.client_params)


class BitfinexMarketBase(MarketClient, ABC):
    def _market_id(self) -> str:
        base = self._parse_common_currency(self.market.base, reverse=True)
        quote = self._parse_common_currency(self.market.quote, reverse=True)
        return base + quote

    @cached_property
    def market_id_v2(self) -> str:
        return "t" + self.market_id

    def _trading_fees(self) -> TradingFees:
        # TODO: Implement Bitfinex trading_fees
        #  Available at account_infos private endpoint
        #  https://docs.bitfinex.com/v1/reference#rest-auth-account-info
        raise NotImplementedError

    def _ticker(self) -> Ticker:
        ticker = self.client.ticker(symbol=self.market_id)
        return self._parse_ticker(ticker)

    def _parse_ticker(self, ticker: Dict) -> Ticker:
        currency = self.market.quote
        maya_dt = maya.MayaDT(float(ticker["timestamp"]))
        last = self.safe_money(ticker, "last_price", currency)
        return Ticker(
            market=self.market,
            bid=self.safe_money(ticker, "bid", currency),
            ask=self.safe_money(ticker, "ask", currency),
            mid=self.safe_money(ticker, "mid", currency),
            last=last,
            open=None,
            high=self.safe_money(ticker, "high", currency),
            low=self.safe_money(ticker, "low", currency),
            close=last,
            change=None,
            percentage=None,
            average=self.safe_money(ticker, "mid", currency),
            vwap=None,
            info=ticker,
            timestamp=maya_dt.epoch,
            datetime=maya_dt.datetime(),
        )

    def _order_book(self, side: Side = None) -> OrderBook:
        # Max limit is 5000 per side
        order_book = self.client.order_book(
            symbol=self.market_id, limit_asks=1000, limit_bids=1000
        )
        return self._parse_order_book(order_book)

    def _parse_order_book_entry(self, order: Dict) -> OrderBookEntry:
        return OrderBookEntry(
            price=Money(order["price"], self.market.quote),
            amount=Money(order["amount"], self.market.base),
        )

    def _trades_since(self, since: int) -> List[Trade]:
        def fetch_trades():
            data = {}
            timestamp = since * 1000  # in ms
            max_limit = 1000
            while True:
                entries = self.client_v2.trades(
                    self.market_id_v2, start=timestamp, limit=max_limit, sort=True
                )
                data.update({entry.ID: entry for entry in entries})
                if len(entries) < max_limit:
                    return data
                timestamp = entries[-1].MTS

        trades = fetch_trades()
        return self._parse_trades(list(trades.values()), since)

    def _parse_trade(self, trade: BitfinexV2.models.TradingTrade) -> Trade:
        trade_id = str(trade.ID)
        maya_dt = maya.MayaDT(trade.MTS / 1000)
        amount = abs(Money(str(trade.AMOUNT), self.market.base))
        price = Money(str(trade.PRICE), self.market.quote)
        side = Side.BUY if trade.AMOUNT > 0 else Side.SELL
        cost = price * amount.amount
        fee = None
        return Trade(
            id=trade_id,
            market=self.market,
            type=None,
            side=side,
            amount=amount,
            price=price,
            cost=cost,
            fee=fee,
            info=trade,
            timestamp=maya_dt.epoch,
            datetime=maya_dt.datetime(),
        )


class BitfinexMarket(BitfinexMarketBase, BitfinexPublic):
    pass


class BitfinexWallet(WalletClient, BitfinexAuth):
    # TODO: Bitfinex withdrawal_fee is available on the API
    #  https://docs.bitfinex.com/v1/reference#rest-auth-fees
    withdrawal_fee_mapping = {
        "BCH": Fee(base=Money("0.0005", "BCH")),
        "BTC": Fee(base=Money("0.0005", "BTC")),
        "ETH": Fee(base=Money("0.01", "ETH")),
        "LTC": Fee(base=Money("0.01", "LTC")),
    }
    method_mapping = {
        "BCH": "bab",
        "BTC": "bitcoin",
        "DAI": "dai",
        "ETH": "ethereum",
        "LTC": "litecoin",
        "USDC": "udc",
        "USDT": "tetheruso",
    }

    def __init__(
        self,
        currency: str,
        client_params: Dict = None,
        dry_run: bool = False,
        logger: Logger = None,
        store: Store = None,
        name: str = None,
        wallet_type: str = DEFAULT_WALLET_TYPE,
        **kwargs,
    ):
        super().__init__(
            currency, client_params, dry_run, logger, store, name, **kwargs
        )
        self.wallet_type = wallet_type
        self.asset = self._parse_common_currency(self.currency, reverse=True)

    def _balance(self) -> Balance:
        balances = self.client.balances()
        try:
            balance = next(
                b
                for b in balances
                if b["currency"] == self.asset.lower() and b["type"] == self.wallet_type
            )
        except StopIteration:
            zero = Money(Decimal("0.0"), self.currency)
            return Balance(total=zero, free=zero, used=zero, info="balance not found")
        free = Money(balance["available"], self.currency)
        total = Money(balance["amount"], self.currency)
        return Balance(total=total, free=free, used=total - free, info=balance)

    def __transactions(self, tx_type: TxType, limit: int = None) -> List[Deposit]:
        def get_transfers():
            data = {}
            timestamp = None
            max_limit = 999
            if limit:
                if limit > max_limit:
                    raise ValueError(f"Cant return more than {max_limit}")
                self.log.warning(
                    f"Bitfinex last {limit} {tx_type.value} for the last {max_limit} transactions"
                )
            while True:
                entries = self.client.movements(
                    self.asset, until=timestamp, limit=max_limit
                )
                data.update(
                    {
                        entry["id"]: entry
                        for entry in entries
                        if entry["type"] == tx_type.name
                    }
                )
                if len(entries) < max_limit:
                    return data
                timestamp = entries[-1]["timestamp_created"]

        transfers = get_transfers()
        return self._parse_transactions_limit(list(transfers.values()), tx_type, limit)

    def __transactions_since(self, tx_type: TxType, since: int) -> List[Deposit]:
        def get_transfers():
            data = {}
            timestamp = since
            max_limit = 999
            while True:
                entries = self.client.movements(
                    self.asset, since=timestamp, limit=max_limit
                )
                data.update(
                    {
                        entry["id"]: entry
                        for entry in entries
                        if entry["type"] == tx_type.name
                    }
                )
                if len(entries) < max_limit:
                    return data
                timestamp = entries[0]["timestamp_created"]

        transfers = get_transfers()
        return self._parse_transactions_since(list(transfers.values()), tx_type, since)

    def _deposits(self, limit: int = None) -> List[Deposit]:
        return self.__transactions(TxType.DEPOSIT, limit)

    def _deposits_since(self, since: int) -> List[Deposit]:
        return self.__transactions_since(TxType.DEPOSIT, since)

    def _withdrawals(self, limit: int = None) -> List[Withdrawal]:
        return self.__transactions(TxType.WITHDRAWAL, limit)

    def _withdrawals_since(self, since: int) -> List[Withdrawal]:
        return self.__transactions_since(TxType.WITHDRAWAL, since)

    def _parse_transaction(self, tx: Dict, tx_type: TxType) -> Transaction:
        maya_dt = maya.MayaDT(float(tx["timestamp_created"]))
        timestamp = maya_dt.epoch
        datetime = maya_dt.datetime()
        status = tx.get("status")
        if status is not None:
            status_mapping = {
                "CANCELED": TxStatus.CANCELED,
                "ZEROCONFIRMED": TxStatus.FAILED,
                "COMPLETED": TxStatus.OK,
            }
            status = status_mapping.get(status)
        fee = tx.get("fee")
        if fee is not None:
            fee = abs(Money(fee, self.currency))
        return Transaction(
            id=tx.get("id"),
            type=tx_type,
            currency=self.currency,
            amount=Money(tx["amount"], self.currency),
            status=status,
            address=tx.get("address"),
            tx_hash=tx.get("txid"),
            fee=fee,
            timestamp=timestamp,
            datetime=datetime,
            info=tx,
        )

    def _withdraw(
        self, amount: Number, address: str, subtract_fee: bool = False, **params
    ) -> Withdrawal:
        method = self.method_mapping[self.currency]
        if subtract_fee:
            fee = self.withdrawal_fees[self.currency]
            amount -= fee
        withdrawal = self.client.withdraw(method, "exchange", amount, address, **params)
        return self._parse_transaction(withdrawal, TxType.WITHDRAWAL)


class BitfinexTrading(TradingClient, BitfinexMarketBase, BitfinexAuth):
    _wallet_cls = BitfinexWallet
    has_batch_cancel = False
    # TODO: Bitfinex min_order_amount is available on the API
    #  https://docs.bitfinex.com/v1/reference#rest-public-symbol-details
    min_order_amount_mapping = {
        "BCH": Decimal("0.02"),
        "BTC": Decimal("0.0008"),
        "DAI": Decimal("6.0"),
        "ETH": Decimal("0.04"),
        "LTC": Decimal("0.2"),
        "USDC": Decimal("6.0"),
        "USDT": Decimal("6.0"),
    }
    order_type_mapping = {
        OrderType.MARKET: "exchange market",
        OrderType.LIMIT: "exchange limit",
    }

    def _order(self, order_id: str) -> Order:
        order = self.client.status_order(int(order_id))
        return self._parse_order(order)

    def _open_orders(self, limit: int = None) -> List[Order]:
        orders = self.client.active_orders()
        return self._parse_orders_limit(orders, limit)

    def _closed_orders(self, limit: int = None) -> List[Order]:
        self.log.warning("Bitfinex only returns orders for the last 3 days")
        orders = self._parse_orders_limit(self.client.orders_history(limit), limit)
        return [o for o in orders if o.status == OrderStatus.CLOSED]

    def _closed_orders_since(self, since: int) -> List[Order]:
        if since < maya.when("3 days ago").epoch:
            raise ExchangeError("Bitfinex only returns orders for the last 3 days")
        return self._filter_since(self._closed_orders(), since)

    def _cancel_order(self, order_id: str) -> None:
        self.client.delete_order(int(order_id))

    def _cancel_orders(self, order_ids: List[str] = None) -> None:
        # Bitfinex has 'delete_all_orders' endpoint but cancels orders on all markets
        raise NotSupported("Bitfinex has endpoint cancels orders on all markets")

    def _place_order(
        self, side: Side, o_type: OrderType, amount: Decimal, price: Decimal = None
    ) -> Order:
        order_type = self.order_type_mapping[o_type]
        order = self.client.place_order(
            float(amount), float(price), side.value, order_type, self.market_id
        )
        return self._parse_order(order, self.market)

    def _parse_order(self, order: Dict, market: Market = None) -> Order:
        if not market:
            market = self._get_market_from_pair(order["symbol"].upper())
        order_type = order["type"]
        assert DEFAULT_WALLET_TYPE in order_type
        order_type = OrderType(order_type.split()[1])
        side = Side(order["side"])
        if order["is_live"]:
            status = OrderStatus.OPEN
        elif order["is_cancelled"]:
            status = OrderStatus.CANCELED
        else:
            status = OrderStatus.CANCELED
        maya_dt = maya.MayaDT(float(order["timestamp"]))
        amount = Money(order["original_amount"], market.base)
        remaining = Money(order["remaining_amount"], market.base)
        filled = Money(order["executed_amount"], market.base)
        price = Money(order["price"], market.quote)
        if filled:
            price = Money(order["avg_execution_price"], market.quote)
        cost = None
        if price:
            cost = price * filled.amount
        return Order(
            id=str(order["id"]),
            market=market,
            type=order_type,
            side=side,
            status=status,
            amount=amount,
            remaining=remaining,
            filled=filled,
            cost=cost,
            fee=None,
            price=price,
            info=order,
            timestamp=maya_dt.epoch,
            datetime=maya_dt.datetime(),
        )
