import asyncio
import logging
import xmlrpc.client
from decimal import Decimal
from operator import itemgetter
from typing import Dict, List, Optional

import pytz

from maps_adv.billing_proxy.lib.core.async_xmlrpc_client import XmlRpcClient
from maps_adv.billing_proxy.lib.db.enums import CurrencyType, PaymentType


class BalanceApiError(Exception):
    pass


class UserIsAlreadyAssociatedError(Exception):
    pass


class BalanceClient:
    """Balance API methods: https://wiki.yandex-team.ru/balance/xmlrpc/"""

    _default_service_id = None
    _service_tokens = None
    _operator_uid = None
    _client = None
    _logger = None

    _CURRENCY_MAP = {
        "RUR": CurrencyType.RUB,
        "TRY": CurrencyType.TRY,
        "KZT": CurrencyType.KZT,
        "BYN": CurrencyType.BYN,
        "EUR": CurrencyType.EUR,
        "USD": CurrencyType.USD,
    }

    _PAYMENT_TYPE_MAP = {2: PaymentType.PRE, 3: PaymentType.POST}

    def __init__(
        self,
        default_service_id: int,
        service_tokens: Dict[int, str],
        operator_uid: int,
        xmlrpc_client: XmlRpcClient = None,
        *,
        clients_fetch_chunk_size: Optional[int] = None,
    ):
        self._default_service_id = default_service_id
        self._service_tokens = service_tokens
        self._operator_uid = operator_uid
        self._client = xmlrpc_client
        self._clients_fetch_chunk_size = clients_fetch_chunk_size

        self._logger = logging.getLogger("billing_proxy.BalanceClient")

    async def _call_xmlrpc_method(self, methodname, *params):
        try:
            response = await self._client.request(methodname, *params)
        except (xmlrpc.client.Error, asyncio.TimeoutError) as e:
            self._logger.warning(
                "Balance XMLRPC API call exception",
                extra={"fields": {"methodname": methodname, "exception": str(e)}},
            )
            raise BalanceApiError from e
        else:
            self._logger.info(
                "Balance XMLRPC API call success",
                extra={"fields": {"methodname": methodname, "response": str(response)}},
            )
            return response

    async def find_client(
        self, client_id: int = None, uid: int = None
    ) -> Optional[dict]:
        if client_id is not None:
            args = ["Balance.GetClientByIdBatch", [str(client_id)]]
        elif uid is not None:
            args = ["Balance.FindClient", {"PassportID": str(uid)}]
        else:
            return None

        response = await self._call_xmlrpc_method(*args)

        if response[0] != 0:
            raise BalanceApiError(
                f"{args[0]} returned nonzero status code: {response[0]}"
            )

        index = 1 if client_id is not None else 2
        if len(response[index]) > 1:
            raise BalanceApiError(
                f"{args[0]} returned multiple clients ({len(response[index])})"
            )
        elif len(response[index]) == 0:
            return None

        client_data = response[index][0]

        return {
            "id": client_data["CLIENT_ID"],
            "name": client_data["NAME"],
            "email": client_data["EMAIL"],
            "phone": client_data["PHONE"],
            "is_agency": bool(client_data["IS_AGENCY"]),
            "partner_agency_id": client_data["AGENCY_ID"],
        }

    async def find_clients(self, client_ids: List[int]) -> List[dict]:
        results = []
        if self._clients_fetch_chunk_size is not None:
            left = 0
            client_ids_chunks = []
            while left < len(client_ids):
                right = left + self._clients_fetch_chunk_size
                client_ids_chunks.append(client_ids[left:right])
                left = right
        else:
            client_ids_chunks = [client_ids]

        for client_ids_chunk in client_ids_chunks:
            response = await self._call_xmlrpc_method(
                "Balance.GetClientByIdBatch", list(map(str, client_ids_chunk))
            )

            if response[0] != 0:
                raise BalanceApiError(
                    "Balance.GetClientByIdBatch returned "
                    "nonzero status code: {}".format(response[0])
                )

            results.extend(
                {
                    "id": int(client_data["CLIENT_ID"]),
                    "name": client_data["NAME"],
                    "email": client_data["EMAIL"],
                    "phone": client_data["PHONE"],
                    "is_agency": bool(client_data["IS_AGENCY"]),
                }
                for client_data in response[1]
            )

        return results

    async def create_client(
        self, name: str, email: str, phone: str, client_id: Optional[int] = None
    ) -> int:
        client_params = {
            "NAME": name,
            "EMAIL": email,
            "PHONE": phone,
            "IS_AGENCY": False,
            "SERVICE_ID": self._default_service_id,
        }

        if client_id is not None:
            client_params["CLIENT_ID"] = client_id

        response = await self._call_xmlrpc_method(
            "Balance.CreateClient", self._operator_uid, client_params
        )

        if response[0] != 0:
            raise BalanceApiError(
                "Balance.CreateClient returned nonzero status code: {} ({})".format(
                    response[0], response[1]
                )
            )

        client_id = int(response[2])

        return client_id

    async def list_client_contracts(self, client_id: int) -> List[dict]:
        response = await self._call_xmlrpc_method(
            "Balance.GetClientContracts", {"ClientID": client_id}
        )

        client_contracts = []
        for contract_data in response[0]:
            contract_services = contract_data.get("SERVICES") or []
            if self._default_service_id not in contract_services:
                continue

            if "FINISH_DT" in contract_data:
                date_end = contract_data["FINISH_DT"].date()
            else:
                date_end = None

            currency_id = contract_data["CURRENCY"]
            try:
                currency = self._CURRENCY_MAP[currency_id]
            except KeyError:
                continue

            payment_type_id = contract_data["PAYMENT_TYPE"]
            try:
                payment_type = self._PAYMENT_TYPE_MAP[payment_type_id]
            except KeyError:
                continue

            client_contracts.append(
                {
                    "id": contract_data["ID"],
                    "external_id": contract_data["EXTERNAL_ID"],
                    "is_active": bool(contract_data["IS_ACTIVE"]),
                    "date_start": contract_data["DT"].date(),
                    "date_end": date_end,
                    "currency": currency,
                    "payment_type": payment_type,
                }
            )

        return client_contracts

    async def create_order(
        self,
        order_id: int,
        client_id: int,
        oracle_product_id: int,
        contract_id: Optional[int] = None,
        act_text: Optional[str] = None,
        text: Optional[str] = None,
        agency_id: Optional[int] = None,
    ) -> None:
        order_payload = {
            "ClientID": str(client_id),
            "ServiceID": self._default_service_id,
            "ProductID": oracle_product_id,
            "ServiceOrderID": str(order_id),
        }
        if act_text is not None:
            order_payload["ActText"] = act_text
        if text:
            order_payload["Text"] = text
        if agency_id is not None:
            order_payload["AgencyID"] = str(agency_id)
        if contract_id is not None:
            order_payload["ContractID"] = str(contract_id)

        response = await self._call_xmlrpc_method(
            "Balance.CreateOrUpdateOrdersBatch",
            self._operator_uid,
            [order_payload],
            self._service_tokens[self._default_service_id],
        )

        code, err_text = response[0][0]
        if code != 0:
            raise BalanceApiError(
                "Balance.CreateOrUpdateOrdersBatch returned nonzero status code: "
                f"{code} ({err_text})"
            )

    async def create_deposit_request(
        self,
        client_id: int,
        order_id: int,
        amount: Decimal,
        region: Optional[str] = None,
        service_id: Optional[int] = None,
        contract_id: int = -1,
    ) -> dict:
        payload = {
            "ServiceID": (
                service_id if service_id is not None else self._default_service_id
            ),
            "ServiceOrderID": str(order_id),
            "Qty": str(amount),
        }

        additional_params = {
            "InvoiceDesireContractID": str(contract_id),
        }
        if region is not None:
            additional_params["Region"] = region

        response = await self._call_xmlrpc_method(
            "Balance.CreateRequest2",
            self._operator_uid,
            str(client_id),
            [payload],
            additional_params,
        )

        details = response[0]
        return {
            "user_url": details["UserPath"],
            "admin_url": details["AdminPath"],
            "request_id": details["RequestID"],
        }

    async def update_orders(
        self, orders_data: Dict[int, Dict], bill_for_dt
    ) -> Dict[int, bool]:
        payload = []
        # Balance expects dt in "Europe/Moscow" timezone
        moscow_bill_for_dt = bill_for_dt.astimezone(pytz.timezone("Europe/Moscow"))

        for order_id, data in orders_data.items():
            payload.append(
                {
                    "ServiceID": data.get("service_id") or self._default_service_id,
                    "ServiceOrderID": str(order_id),
                    "Bucks": str(data["consumed"]),
                    "dt": moscow_bill_for_dt.strftime("%Y%m%d%H%M%S"),
                    # Following fields' values do not matter for us
                    "Money": 0,
                    "stop": 0,
                    "Days": -1,
                }
            )
        response = await self._call_xmlrpc_method("Balance.UpdateCampaigns", payload)

        update_results = {}

        # transform {"37": {"123": 1, "234": 1}, "110": {"567": 0}}
        # into {37: {123: True, 234: True}, 110: {567: False}}
        for results in response:
            for key, value in results.items():
                if key not in update_results.keys():
                    update_results.update({int(key): {}})
                update_results.get(int(key)).update(
                    {int(order_id): bool(result) for order_id, result in value.items()}
                )

        return update_results

    async def create_user_client_association(self, client_id: int, uid: int):
        response = await self._call_xmlrpc_method(
            "Balance.CreateUserClientAssociation", self._operator_uid, client_id, uid
        )
        # Return code 4008 means user has already been associated with another client
        # See https://wiki.yandex-team.ru/balance/xmlrpc/#balance.createuserclientassociation
        if response[0] == 4008:
            raise UserIsAlreadyAssociatedError()
        elif response[0] != 0:
            raise BalanceApiError(
                "Balance.CreateUserClientAssociation returned nonzero status code: "
                f"{response[0]} ({response[1]})"
            )

    async def list_client_passports(self, client_id: int) -> List[int]:
        response = await self._call_xmlrpc_method(
            "Balance.ListClientPassports", self._operator_uid, client_id
        )

        return list(map(itemgetter("Uid"), response[0]))
