import logging
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from typing import Dict, List, Optional, Set, Tuple

import pytz

from maps_adv.billing_proxy.lib.core.balance_client import BalanceClient
from maps_adv.billing_proxy.lib.data_manager import (
    exceptions as data_manager_exceptions,
)
from maps_adv.billing_proxy.lib.data_manager.clients import AbstractClientsDataManager
from maps_adv.billing_proxy.lib.data_manager.orders import AbstractOrdersDataManager
from maps_adv.billing_proxy.lib.data_manager.products import AbstractProductsDataManager
from maps_adv.billing_proxy.lib.domain.products import (
    seasonal_coefs_apply,
    MONTHLY_DISCOUNTS,
)

from .exceptions import (
    AgencyDoesNotExist,
    BadDuplicatedCharge,
    ClientContractMismatch,
    ClientDoesNotExist,
    ClientIsAgency,
    ClientIsNotAgency,
    ClientNotInAgency,
    ContractDoesNotExist,
    ExternalOrdersDoNotExist,
    OrderDoesNotExist,
    OrdersBillInFuture,
    OrdersDoNotExist,
    ProductAgencyMismatch,
    ProductClientMismatch,
    ProductDoesNotExist,
    ProductIsInactive,
    ProductServiceMismatch,
    WrongBalanceServiceID,
)


class OrdersDomain:
    def __init__(
        self,
        orders_dm: AbstractOrdersDataManager,
        clients_dm: AbstractClientsDataManager,
        products_dm: AbstractProductsDataManager,
        balance_client: BalanceClient,
        balance_service_id: int,
        geoprod_service_id: int,
        seasonal_coefs_since: datetime,
    ):
        self._orders_dm = orders_dm
        self._clients_dm = clients_dm
        self._products_dm = products_dm

        self._balance_client = balance_client

        self._balance_service_id = balance_service_id
        self._geoprod_service_id = geoprod_service_id

        self._moscow_tz = pytz.timezone("Europe/Moscow")
        self._seasonal_coefs_since = seasonal_coefs_since

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

    async def create_order(
        self,
        title: str,
        text: str,
        comment: str,
        client_id: int,
        agency_id: Optional[int],
        contract_id: Optional[int],
        product_id: int,
        service_id: int,
    ) -> dict:
        now = datetime.now(tz=pytz.utc)

        # Check client and agency exist and client belongs to agency
        client = await self._clients_dm.find_client_locally(client_id)
        if client is None:
            raise ClientDoesNotExist(client_id=client_id)
        if client["is_agency"]:
            raise ClientIsAgency(client_id=client_id)

        if agency_id is not None:
            agency = await self._clients_dm.find_client_locally(agency_id)
            if agency is None:
                raise AgencyDoesNotExist(agency_id=agency_id)
            if not agency["is_agency"]:
                raise ClientIsNotAgency(client_id=agency_id)

            if not await self._clients_dm.client_is_in_agency(client_id, agency_id):
                raise ClientNotInAgency(client_id=client_id, agency_id=agency_id)

        if contract_id is not None:
            contract = await self._clients_dm.find_contract(contract_id)
            if contract is None:
                raise ContractDoesNotExist(contract_id=contract_id)
            expected_contract_client = agency_id if agency_id is not None else client_id
            if contract["client_id"] != expected_contract_client:
                raise ClientContractMismatch(
                    client_id=expected_contract_client, contract_id=contract_id
                )

        # Check product exists, is active and is available for this client
        product = await self._products_dm.find_product(product_id)
        if product is None:
            raise ProductDoesNotExist(product_id=product_id)

        if product["active_from"] > now:
            raise ProductIsInactive(product_id=product_id)
        if product["active_to"] is not None and product["active_to"] < now:
            raise ProductIsInactive(product_id=product_id)

        dedicated_client_ids = product["dedicated_client_ids"]
        if dedicated_client_ids and client_id not in dedicated_client_ids:
            raise ProductClientMismatch(client_ids=[client_id], product_id=product_id)

        # Check product is available for this agency
        if (
            agency_id is not None
            and not product["available_for_agencies"]
            or agency_id is None
            and not product["available_for_internal"]
        ):
            raise ProductAgencyMismatch(agency_id=agency_id, product_id=product_id)

        if service_id != product["service_id"]:
            raise ProductServiceMismatch(
                service_id=service_id,
                product_id=product_id,
                product_service_id=product["service_id"],
            )

        return await self._orders_dm.create_order(
            service_id=service_id,
            title=title,
            text=text,
            comment=comment,
            client_id=client_id,
            agency_id=agency_id,
            contract_id=contract_id,
            product_id=product["id"],
        )

    async def update_order(
        self, order_id: int, title: str, text: str, comment: str
    ) -> dict:
        if await self._orders_dm.find_order(order_id) is None:
            raise OrderDoesNotExist(order_id=order_id)

        await self._orders_dm.update_order(
            order_id=order_id, title=title, text=text, comment=comment
        )

        return await self._orders_dm.find_order(order_id)

    async def retrieve_order(self, order_id: int) -> dict:
        order = await self._orders_dm.find_order(order_id)
        if order is None:
            raise OrderDoesNotExist(order_id=order_id)

        return order

    async def retrieve_order_by_external_id(self, external_id: int) -> dict:
        order = await self._orders_dm.find_order_by_external_id(external_id)
        if order is None:
            raise OrderDoesNotExist(order_id=None)

        return order

    async def retrieve_orders(self, order_ids: List[int]) -> List[dict]:
        inexistent_ids = await self._orders_dm.list_inexistent_order_ids(order_ids)
        if inexistent_ids:
            raise OrdersDoNotExist(order_ids=inexistent_ids)
        return await self._orders_dm.find_orders(order_ids)

    async def retrieve_order_ids_for_account(
        self, account_manager_id: int
    ) -> List[int]:
        return await self._orders_dm.retrieve_order_ids_for_account(account_manager_id)

    async def list_agency_orders(
        self, agency_id: Optional[int], client_id: Optional[int] = None
    ) -> List[dict]:
        if agency_id is not None and not await self._clients_dm.agency_exists(
            agency_id
        ):
            raise AgencyDoesNotExist(agency_id=agency_id)

        if client_id is not None and not await self._clients_dm.client_exists(
            client_id
        ):
            raise ClientDoesNotExist(client_id=client_id)

        return await self._orders_dm.list_agency_orders(agency_id, client_id)

    async def list_client_orders(self, client_id: int) -> List[dict]:
        if not await self._clients_dm.client_exists(client_id):
            raise ClientDoesNotExist(client_id=client_id)

        return await self._orders_dm.list_client_orders(client_id)

    async def create_deposit_request(
        self, order_id: int, amount: Decimal, region: Optional[str] = None
    ):
        order = await self._orders_dm.find_order(order_id)
        if order is None:
            raise OrderDoesNotExist(order_id=order_id)

        return await self._balance_client.create_deposit_request(
            client_id=order["agency_id"]
            if order["agency_id"] is not None
            else order["client_id"],
            service_id=order["service_id"],
            order_id=order["external_id"],
            amount=amount,
            region=region,
            contract_id=order["contract_id"] or -1,
        )

    async def list_orders_stats(self, order_ids: List[int]) -> Dict[int, dict]:
        inexistent_ids = await self._orders_dm.list_inexistent_order_ids(order_ids)
        if inexistent_ids:
            raise OrdersDoNotExist(order_ids=inexistent_ids)

        return await self._orders_dm.list_orders_stats(order_ids)

    async def list_orders_discounts(
        self, order_ids: List[int], billed_at: datetime
    ) -> Dict[int, Dict]:
        inexistent_ids = await self._orders_dm.list_inexistent_order_ids(order_ids)
        if inexistent_ids:
            raise OrdersDoNotExist(order_ids=inexistent_ids)

        orders_data = await self._orders_dm.find_orders(order_ids)

        discount = MONTHLY_DISCOUNTS[billed_at.astimezone(self._moscow_tz).month]

        return {
            order["id"]: {
                "discount": discount
                if seasonal_coefs_apply(order)
                and billed_at >= self._seasonal_coefs_since
                else Decimal("1.0")
            }
            for order in orders_data
        }

    async def charge_orders(
        self, charge_info: Dict[int, Decimal], bill_for_dt: datetime
    ) -> Tuple[Dict[int, bool], bool]:
        results = {}
        to_charge = charge_info.copy()

        now = datetime.now(tz=pytz.utc)
        if bill_for_dt > now:
            raise OrdersBillInFuture(bill_timestamp=int(bill_for_dt.timestamp()))

        existing_debits = await self._orders_dm.list_orders_debits_for_billed_due_to(
            bill_for_dt
        )
        if existing_debits:
            request_missing_order_ids = set(existing_debits.keys()).difference(
                set(to_charge.keys())
            )
            if request_missing_order_ids:
                raise BadDuplicatedCharge(order_ids=sorted(request_missing_order_ids))

            bad_amount_order_ids = []
            for order_id, amount in to_charge.items():
                if order_id not in existing_debits:
                    results[order_id] = False
                elif existing_debits[order_id] != amount:
                    bad_amount_order_ids.append(order_id)
                else:
                    results[order_id] = True

            if bad_amount_order_ids:
                raise BadDuplicatedCharge(order_ids=sorted(bad_amount_order_ids))
            else:
                return results, False

        if not self._check_can_send_to_balance(bill_for_dt):
            return {order_id: False for order_id in charge_info}, True

        async with self._orders_dm.lock_and_return_orders_balance(
            list(charge_info.keys())
        ) as (orders_balance, con):
            for order_id, amount_to_charge in charge_info.items():
                if amount_to_charge > orders_balance[order_id]:
                    results[order_id] = False
                    del to_charge[order_id]

            charge_results = await self._orders_dm.charge_orders(
                to_charge, bill_for_dt, con=con
            )

        results.update(charge_results)
        return results, True

    def _check_can_send_to_balance(self, dt: datetime) -> bool:
        # Balance works in Moscow timezone
        now_msk = datetime.now(tz=self._moscow_tz)
        dt_msk = dt.astimezone(self._moscow_tz)

        diff_months = (now_msk.year - dt_msk.year) * 12 + now_msk.month - dt_msk.month

        if diff_months == 0:
            return True
        elif diff_months > 1:
            return False
        else:
            # Here we can be sure balance didn't start to generate docs yet
            return now_msk.time() < time(0, 50, 0)

    async def update_orders_limits(
        self, orders_new_limits: Dict[int, Dict], service_ids: Set[int]
    ) -> None:
        if not service_ids.issubset({self._balance_service_id}):
            raise WrongBalanceServiceID

        try:
            await self._orders_dm.update_orders_limits(orders_new_limits)
        except data_manager_exceptions.OrdersDoNotExist as e:
            raise OrdersDoNotExist(order_ids=e.order_ids)

    async def update_orders_limits_from_geoprod(
        self, external_orders_new_limits: Dict[int, Dict], service_ids: Set[int]
    ) -> None:
        if not service_ids.issubset({self._geoprod_service_id}):
            raise WrongBalanceServiceID

        not_found_orders = []
        orders_new_limits = {}
        for external_order_id, values in external_orders_new_limits.items():
            order_id = await self._orders_dm.retrieve_order_id_by_external_id(
                external_order_id
            )
            if order_id:
                orders_new_limits.update({order_id: values})
            else:
                not_found_orders.append(external_order_id)

        if not_found_orders:
            raise ExternalOrdersDoNotExist(external_order_ids=not_found_orders)

        try:
            await self._orders_dm.update_orders_limits(orders_new_limits)
        except data_manager_exceptions.OrdersDoNotExist as e:
            raise OrdersDoNotExist(order_ids=e.order_ids)

    async def list_active_orders(
        self, order_ids: Optional[List[int]] = None
    ) -> List[int]:
        if order_ids is not None:
            inexistent_ids = await self._orders_dm.list_inexistent_order_ids(order_ids)
            if inexistent_ids:
                raise OrdersDoNotExist(order_ids=inexistent_ids)

        return await self._orders_dm.list_positive_balance_orders(order_ids)

    async def build_reconciliation_report(self, due_to: date) -> Dict[int, dict]:
        due_to = self._moscow_tz.localize(
            datetime.combine(due_to + timedelta(days=1), datetime.min.time())
        )
        return await self._orders_dm.build_reconciliation_report(
            due_to=due_to, service_id=self._balance_service_id
        )

    # generates reconciliation report for geoprod orders and loads to YT
    async def load_geoprod_reconciliation_report(
        self, for_date: Optional[datetime] = None
    ):
        # generate report for yesterday by default
        for_date = for_date or datetime.now() - timedelta(days=1)

        # from min time of date to max time - whole day
        from_datetime = self._moscow_tz.localize(
            datetime.combine(for_date, datetime.min.time())
        )
        to_datetime = self._moscow_tz.localize(
            datetime.combine(for_date, datetime.max.time())
        )
        self._logger.info(
            "Geoprod reconciliation report",
            extra={
                "fields": {
                    "from": str(from_datetime),
                    "to": str(to_datetime),
                }
            },
        )
        await self._orders_dm.load_geoprod_reconciliation_report(
            from_datetime=from_datetime,
            to_datetime=to_datetime,
            geoprod_service_id=self._geoprod_service_id,
        )

    async def list_orders_debits(
        self, order_ids: List[int], billed_after: datetime
    ) -> Dict[int, Dict]:
        inexistent_ids = await self._orders_dm.list_inexistent_order_ids(order_ids)
        if inexistent_ids:
            raise OrdersDoNotExist(order_ids=inexistent_ids)

        orders_debits = await self._orders_dm.list_orders_debits(
            order_ids, billed_after
        )

        return {
            "orders_debits": [
                {"order_id": order_id, "debits": debits}
                for order_id, debits in orders_debits.items()
            ]
        }
