import decimal
import typing
from functools import wraps

import retrying
from bithustler_client.client.client import BithustlerClient
from bittrade_client.client import exceptions as bittrade_exceptions, PaymentMethodFamily
from bittrade_client.client.client import BittradeClient
from bittrade_client.client.exceptions import BidExpiredException, BidIsInErrorException
from bittrade_client.generated_protobuf import bittrade_pb2
from cards_client.client.client import CardsClient
from cards_client.generated_protobuf import base_pb2
from requests import HTTPError
from vault_client.client.conio_user import ConioUser

from conio_sdk.common import exceptions
from conio_sdk.logging.factory import LOGGING_FACTORY
from conio_sdk.services.conio.trading.trading_service import TradingService, AskID, FiatCurrency, Ask, \
    AskBtcTransaction, AskBtcTransactionSignData, AskTransactionSignature, BidID, Bid, DataToFinalizeTokenizedPayment, \
    PaymentToken, DataToFinalizeUntokenizedPayment, GestpayUserID, GestpaySubmitPaymentURL, MaskedPAN, \
    WiretransferPayeeInfo
from conio_sdk.services.conio.user.user_service import ConioUserID
from conio_sdk.services.conio.wallet.bitcoin_wallet_service import SatoshiAmount, WalletID
from etc.settings import DEFAULT_CRYPTO_CURRENCY

_EXCEPTIONS_BY_BITTRADE_ERRORS = {
    bittrade_pb2.ERR_CARD_ERROR: exceptions.InvalidPaymentMethodException,
    bittrade_pb2.ERR_NO_SUCH_3DSECURE: exceptions.NoSuch3dSecureException,
    bittrade_pb2.ERR_UNSUPPORTED_CARD: exceptions.UnsupportedPaymentMethodException,
    bittrade_pb2.ERR_LIMITS_EXCEEDED: exceptions.TradingLimitsExceededException,
    bittrade_pb2.ERR_EXPIRED: exceptions.TradeExpiredException,
    bittrade_pb2.ERR_BID_ALREADY_PAID: exceptions.BidAlreadyPaidException,
    bittrade_pb2.ERR_UNRECOVERABLE_BID: exceptions.UnrecoverableBidException,
    bittrade_pb2.ERR_UNRECOVERABLE_ASK: exceptions.UnrecoverableAskException,
    bittrade_pb2.ERR_ASK_ALREADY_PAID: exceptions.AskAlreadyPaidException,
    bittrade_pb2.ERR_NOT_ENOUGH_AMOUNT: exceptions.NotEnoughBtcAmountException,
    bittrade_pb2.ERR_UNKNOWN: exceptions.TradingException,
    bittrade_pb2.ERR_DUST_ASK: exceptions.DustAskException,
    bittrade_pb2.ERR_FIAT_AMOUNT_TOO_LOW: exceptions.FiatAmountTooLowException,
    bittrade_pb2.ERR_CARD_LIMIT_EXCEEDED: exceptions.CardsLimitsExceededException
}


def _manage_exception(fun):
    @wraps(fun)
    def _inner(*a, **kw):
        try:
            return fun(*a, **kw)
        except bittrade_exceptions.BittradePaymentException as e:
            LOGGING_FACTORY.trading.exception('Error executing %s(%s, %s)', fun, a, kw)
            if e.error_code not in _EXCEPTIONS_BY_BITTRADE_ERRORS:
                LOGGING_FACTORY.trading.error('Unexpected Bittrade Error Code: %s', e.error_code)
            raise _EXCEPTIONS_BY_BITTRADE_ERRORS.get(e.error_code, ValueError)(e.arg)

    return _inner


class BittradeService(TradingService):
    def __init__(
            self, bittrade_client: BittradeClient, bithustler_client: BithustlerClient,
            cards_client: CardsClient, default_explicit_fees: typing.Optional[str]):
        self._bittrade_client = bittrade_client
        self._bithustler_client = bithustler_client
        self._cards_client = cards_client
        self._default_explicit_fees = default_explicit_fees

    def trigger_charge_btc(self, user_id: ConioUserID) -> None:
        self._bittrade_client.trigger_charge_btc(user_id)

    def get_processing_bids_bits(self, user_id: ConioUserID) -> SatoshiAmount:
        return self._bittrade_client.get_processing_bids_bits(user_id)['bits']

    def get_processing_bids_addresses(self, user_id: ConioUserID) -> typing.Sequence[str]:
        return self._bittrade_client.get_processing_bids_bits(user_id)['addresses']

    @_manage_exception
    def refresh_ask_price_by_satoshi(
            self, user: ConioUser,
            ask_id: AskID, satoshi_amount: SatoshiAmount,
            fiat_currency: FiatCurrency) -> Ask:
        ask = self._bittrade_client.refresh_ask_price_by_satoshi(
            user.reference_key_id, self._get_wallet(user),
            user.seller_id, str(ask_id), satoshi_amount,
            fiat_currency.name
        )
        return Ask(
            ask.data.ask_id,
            ask.data.satoshi,
            self._fiat_amount_to_decimal(ask.data.fiat_amount),
            self._pb2_to_enum_currency(ask.data.currency),
            ask.data.expiration_timestamp,
            self._fiat_amount_to_decimal(ask.data.explicit_fees)
        )

    @_manage_exception
    def create_ask_by_satoshi(
            self, user: ConioUser,
            satoshi_amount: SatoshiAmount,
            fiat_currency: FiatCurrency) -> Ask:
        ask = self._bittrade_client.create_ask_by_satoshi(
            user.reference_key_id, self._get_wallet(user), user.seller_id,
            satoshi_amount, fiat_currency.name
        )
        return Ask(
            ask.data.ask_id,
            ask.data.satoshi,
            self._fiat_amount_to_decimal(ask.data.fiat_amount),
            self._pb2_to_enum_currency(ask.data.currency),
            ask.data.expiration_timestamp,
            self._fiat_amount_to_decimal(ask.data.explicit_fees)
        )

    @_manage_exception
    def refresh_ask_satoshi_by_fiat(
            self, user: ConioUser,
            ask_id: AskID,
            fiat_amount: decimal.Decimal,
            fiat_currency: FiatCurrency) -> Ask:
        ask = self._bittrade_client.refresh_ask_satoshi_by_fiat(
            user.reference_key_id, self._get_wallet(user),
            user.seller_id, str(ask_id), fiat_amount,
            fiat_currency.name
        )
        return Ask(
            ask.data.ask_id,
            ask.data.satoshi,
            self._fiat_amount_to_decimal(ask.data.fiat_amount),
            self._pb2_to_enum_currency(ask.data.currency),
            ask.data.expiration_timestamp,
            self._fiat_amount_to_decimal(ask.data.explicit_fees)
        )

    @_manage_exception
    def create_ask_satoshi_by_fiat(
            self, user: ConioUser,
            fiat_amount: decimal.Decimal,
            fiat_currency: FiatCurrency) -> Ask:
        ask = self._bittrade_client.create_ask_satoshi_by_fiat(
            user.reference_key_id, self._get_wallet(user), user.seller_id,
            fiat_amount, fiat_currency.name
        )
        return Ask(
            ask.data.ask_id,
            ask.data.satoshi,
            self._fiat_amount_to_decimal(ask.data.fiat_amount),
            self._pb2_to_enum_currency(ask.data.currency),
            ask.data.expiration_timestamp,
            self._fiat_amount_to_decimal(ask.data.explicit_fees)
        )

    @_manage_exception
    def pay_for_ask(self, user: ConioUser, ask_id: AskID) -> AskBtcTransaction:
        sell_methods = self._bithustler_client.get_sell_methods(user.seller_id)
        if len(sell_methods) == 0:
            raise exceptions.NoSuchSellMethodException
        if len(sell_methods) > 1:
            raise exceptions.MultipleSellMethodsException
        ask_btc_transaction = self._bittrade_client.pay_for_ask(str(ask_id), sell_methods[0].id)
        return AskBtcTransaction(
            ask_btc_transaction.data.tx,
            [AskBtcTransactionSignData(t.path, t.data_to_sign) for t in ask_btc_transaction.data.sign_data],
            ask_btc_transaction.data.fees
        )

    @_manage_exception
    def finalize_payment_for_ask(self, ask_id: AskID, tx: bytes, *signatures: AskTransactionSignature) -> None:
        self._bittrade_client.finalize_payment_for_ask(str(ask_id), tx, *signatures)

    @_manage_exception
    def get_ask_tx_inclusion_blocks(self) -> int:
        return self._bittrade_client.get_trading_settings().ask_tx_inclusion_blocks

    @_manage_exception
    def get_paid_bids(self, user: ConioUser) -> typing.Sequence[bittrade_pb2.Bid]:
        return self._bittrade_client.get_paid_bids(user.reference_key_id)

    @_manage_exception
    def refresh_bid_price_by_satoshi(
            self, user: ConioUser, bid_id: BidID,
            satoshi_amount: SatoshiAmount, fiat_currency: FiatCurrency,
            use_iban: bool) -> Bid:
        return self._msg_create_bid_to_bid(
                self._bittrade_client.refresh_bid_price_by_satoshi(
                    user.reference_key_id, self._get_wallet(user),
                    user.cards_integration_id, bid_id, satoshi_amount, fiat_currency.name,
                    payment_method_family=self._payment_method_family(use_iban)
                )
        )

    @_manage_exception
    def create_bid_by_satoshi(
            self, user: ConioUser,
            satoshi_amount: SatoshiAmount,
            fiat_currency: FiatCurrency,
            use_iban: bool) -> Bid:
        return self._msg_create_bid_to_bid(
            self._bittrade_client.create_bid_by_satoshi(
                user.reference_key_id, self._get_wallet(user),
                user.cards_integration_id, satoshi_amount, fiat_currency.name,
                payment_method_family=self._payment_method_family(use_iban)
            )
        )

    @_manage_exception
    def refresh_bid_satoshi_by_fiat(
            self,
            user: ConioUser,
            bid_id: BidID,
            fiat_amount: decimal.Decimal,
            fiat_currency: FiatCurrency,
            use_iban: bool) -> Bid:
        return self._msg_create_bid_to_bid(
            self._bittrade_client.refresh_bid_satoshi_by_fiat(
                user.reference_key_id, self._get_wallet(user), user.cards_integration_id,
                bid_id, fiat_amount, fiat_currency.name,
                payment_method_family=self._payment_method_family(use_iban)
            )
        )

    @_manage_exception
    def create_bid_satoshi_by_fiat(
            self,
            user: ConioUser,
            fiat_amount: decimal.Decimal,
            fiat_currency: FiatCurrency,
            use_iban: bool) -> Bid:
        return self._msg_create_bid_to_bid(
            self._bittrade_client.create_bid_satoshi_by_fiat(
                user.reference_key_id, self._get_wallet(user),
                user.cards_integration_id, fiat_amount, fiat_currency.name,
                payment_method_family=self._payment_method_family(use_iban)
            )
        )

    @_manage_exception
    def pay_for_bid_with_existing_payment_method(self, user: ConioUser, bid_id: BidID, payment_method_id: str) \
            -> DataToFinalizeTokenizedPayment:
        result = self._bittrade_client.pay_for_bid_rest_gestpay(
            user.cards_integration_id, bid_id, payment_method_id
        )
        return DataToFinalizeTokenizedPayment(
            payment_token=PaymentToken(result.data.gespay_rest_payload.payment_token),
            maskedpan=MaskedPAN(result.data.gespay_rest_payload.maskedpan),
            gp_id=GestpayUserID(result.data.gespay_rest_payload.gp_id),
            gp_url=GestpaySubmitPaymentURL(result.data.gespay_rest_payload.gp_url)
        )

    @_manage_exception
    def pay_for_bid_with_new_payment_method(self, user: ConioUser, bid_id: BidID) \
            -> DataToFinalizeUntokenizedPayment:
        result = self._bittrade_client.pay_for_bid_rest_gestpay_with_new_card(
            user.cards_integration_id, bid_id
        )
        return DataToFinalizeUntokenizedPayment(
            payment_token=PaymentToken(result.data.newcard_gespay_rest_payload.payment_token),
            gp_id=GestpayUserID(result.data.newcard_gespay_rest_payload.gp_id),
            gp_url=GestpaySubmitPaymentURL(result.data.newcard_gespay_rest_payload.gp_url)
        )

    def get_payment_method_id_by_external_reference(self, user: ConioUser, external_reference_id: str) \
            -> typing.Optional[str]:
        resp = self._cards_client.get_payment_methods(base_pb2.MsgGetPaymentMethods(payer_id=user.cards_integration_id))
        if resp.error.code != base_pb2.ERR_OK:
            raise ValueError('Error fetchin payment methods for user {}: {}'.format(
                user.reference_key_id, str(resp)
            ))
        try:
            return next(iter(filter(
                lambda d: external_reference_id in d.external_references,
                resp.data.payment_methods
            ))).id
        except StopIteration:
            return None

    def finalize_bid_with_new_payment_method(
            self, user: ConioUser, bid_id: BidID,
            maskedpan: str, external_reference_id: str) -> None:
        self._bittrade_client.finalize_rest_gestpay_non_tokenized_payment_for_bid(
            user.cards_integration_id, bid_id, maskedpan, external_reference_id
        )

    def finalize_bid_with_existing_payment_method(self, user: ConioUser, bid_id: BidID) -> None:
        self._bittrade_client.finalize_rest_gestpay_payment_for_bid(user.cards_integration_id, bid_id)

    def trigger_wiretransfer_bid(self, bid_id: str) -> None:
        try:
            self._bittrade_client.trigger_wiretransfer(bid_id)
        except BidExpiredException:
            raise exceptions.BidExpiredException
        except BidIsInErrorException:
            raise exceptions.BidIsInErrorException

    def wait_for_bid_finalization(self, bid_id: str) -> int:
        try:
            return self._retry_wait_for_bid_finalization(bid_id)
        except retrying.RetryError:
            raise exceptions.BidNotYetPaidException

    def get_default_explicit_fees(self) -> typing.Sequence[str]:
        if not self._default_explicit_fees:
            return []
        result = list(
            map(
                lambda d: d.explicit_fees_id,
                filter(
                    lambda f: f.description == self._default_explicit_fees,
                    self._bittrade_client.get_explicit_fees()
                )
            )
        )
        if not result:
            raise ValueError(f'Could not find default explicit fees for {self._default_explicit_fees}')
        return result

    @retrying.retry(
        wait_exponential_multiplier=100,
        wait_exponential_max=1000,
        stop_max_delay=20000,
        retry_on_result=lambda d: d not in (bittrade_pb2.BID_PAID, bittrade_pb2.BID_CHARGED),
        retry_on_exception=lambda e: True,
        wrap_exception=False
    )
    def _retry_wait_for_bid_finalization(self, bid_id: str) -> int:
        try:
            status = self._bittrade_client.get_bid_status(bid_id)
            if status in (bittrade_pb2.BID_ERROR, bittrade_pb2.BID_TERMINATED, bittrade_pb2.BID_UNKNOWN):
                raise exceptions.BidIsInErrorException
            if status == bittrade_pb2.BID_CANCELLED:
                raise exceptions.BidExpiredException
            return status
        except HTTPError as e:
            if e.response.error_code == 404:
                LOGGING_FACTORY.trading.warning('No such bid %s, cannot finalize', bid_id)
                raise exceptions.BidIsInErrorException
            raise e

    @classmethod
    def _fiat_amount_to_decimal(cls, fiat_amount: bittrade_pb2.FiatAmount) -> decimal.Decimal:
        return decimal.Decimal(fiat_amount.fiat_value) / decimal.Decimal(10**fiat_amount.decimal_digits)

    @classmethod
    def _pb2_to_enum_currency(cls, currency: int) -> FiatCurrency:
        if currency == bittrade_pb2.EUR:
            return FiatCurrency.EUR
        if currency == bittrade_pb2.USD:
            return FiatCurrency.USD
        raise ValueError('Unexpected {}'.format(currency))

    @classmethod
    def _get_wallet(cls, user: ConioUser) -> WalletID:
        return user.get_wallet(DEFAULT_CRYPTO_CURRENCY)['reference_id']

    @classmethod
    def _msg_create_bid_to_bid(
            cls, msg: typing.Union[bittrade_pb2.MsgCreateBidResponse, bittrade_pb2.MsgRefreshBidResponse]) -> Bid:
        return Bid(
            bid_id=msg.data.bid_id,
            fiat_amount=cls._fiat_amount_to_decimal(msg.data.fiat_amount),
            currency=cls._pb2_to_enum_currency(msg.data.currency),
            satoshi=msg.data.satoshi,
            expiration_timestamp=msg.data.expiration_timestamp,
            explicit_fees=cls._fiat_amount_to_decimal(msg.data.explicit_fees),
            wiretransfer_payee_info=WiretransferPayeeInfo(
                description=msg.data.wiretransfer_payee_info.description,
                holder=msg.data.wiretransfer_payee_info.holder,
                holder_iban=msg.data.wiretransfer_payee_info.holder_iban,
            )

        )

    @classmethod
    def _payment_method_family(cls, use_iban: bool) -> PaymentMethodFamily:
        return PaymentMethodFamily.PAYMENT_METHOD_FAMILY_WIRETRANSFER_WITH_EXISTING_BID if use_iban\
            else PaymentMethodFamily.PAYMENT_METHOD_FAMILY_CREDIT_CARD
