from application.model.exceptions import (
    ApplyingDiscountUnknownException,
    AskValueDiscountMaxValueReachedException,
    BenefitExpiredOrInactiveException,
    CustomBenefitAlreadyAppliedException,
    ErrorApplyingCoupon,
    ExcessiveSaleAmountException,
    InsufficientSaleAmountException,
    InvalidBrandException,
    InvalidMinSeatsException,
    InvalidSaleTypeException,
    InvalidSectorException,
    InvalidServicePeriodOrderException,
    InvalidServiceTypeException,
    MissingItemsException,
    PodTypeNotValidException,
    UserLevelNotAllowedException,
)

from abc import ABCMeta
from datetime import datetime, timedelta
from xml.etree import cElementTree as eTree

from application.model.benefit import Benefit, OrderBenefitInfo, Conditions, ActionTypes, Action
from application.model.order import Order, SaleTypes
from application.util import get_service_period
from iso8601 import iso8601
from typing import Dict, List


class BenefitValidator:
    __metaclass__ = ABCMeta

    @staticmethod
    def validate_benefits(uow, applied_benefits, burnt_items, product_groups):
        BenefitValidator.validate_benefit_static_info(applied_benefits, uow.benefit)
        BenefitValidator.validate_benefit_model_info(
            benefit=uow.benefit,
            model=uow.model,
            user_auth_level=uow.user_auth_level
        )
        BenefitValidator.validate_benefit_order_info(uow.benefit, uow.order, burnt_items, product_groups)
        BenefitValidator.validate_benefit_table_info(uow.benefit, uow.order)
        BenefitValidator.validate_benefit_actions(uow.benefit, uow.order, product_groups)

    @staticmethod
    def validate_benefit_static_info(applied_benefits, benefit):
        # type: (list, Benefit) -> None

        conditions = benefit.conditions
        BenefitValidator._is_benefit_valid_now(conditions)
        BenefitValidator._validate_unique_for_sale(applied_benefits, conditions)
        BenefitValidator._validate_single_use(benefit, applied_benefits)
        BenefitValidator._validate_ask_benefit(benefit)

    @staticmethod
    def validate_benefit_model_info(
        benefit,            # type: Benefit
        model,              # type: eTree.Element
        user_auth_level     # type: bool
    ):
        # type: (...) -> None

        conditions = benefit.conditions
        BenefitValidator._is_benefit_valid_for_business_period(conditions, model)
        BenefitValidator._validate_pod_type(conditions, model)
        BenefitValidator._validate_user_min_level(conditions, user_auth_level)

    @staticmethod
    def validate_benefit_order_info(benefit, order, burnt_items, product_groups):
        # type: (Benefit, Order, Dict, Dict) -> None

        conditions = benefit.conditions
        BenefitValidator._validate_sale_type(conditions, order)
        BenefitValidator._validate_sector(conditions, order)
        BenefitValidator._validate_min_sale_amount(conditions, order)
        BenefitValidator._validate_max_sale_amount(conditions, order)
        BenefitValidator._validate_required_items_on_sale(benefit, burnt_items, order)
        BenefitValidator._validate_required_groups_on_sale(benefit, burnt_items, order, product_groups)
        BenefitValidator._validate_amount_discount(benefit, order)
        BenefitValidator._validate_service_type(conditions, order)
        BenefitValidator._validate_service_period(conditions, order)
        BenefitValidator._validate_brand(conditions, order)

    @staticmethod
    def validate_benefit_table_info(benefit, order):
        # type: (Benefit, Order) -> None

        conditions = benefit.conditions
        BenefitValidator._validate_min_seats(conditions, order)

    @staticmethod
    def validate_benefit_actions(benefit, order, product_groups):
        for action in benefit.actions:
            if benefit.ask_value and action.value <= 0:
                raise ErrorApplyingCoupon("NDISCOUNT_ERROR_BENEFIT_MISSING_VALUE")
            if action.name == "itemsToAdd":
                continue

            if isinstance(action.value, list):
                for value in action.value:
                    if "item" in value:
                        required_item_id = value["item"]
                        for sale_line in order.sale_lines:
                            if int(sale_line.item_id) == int(required_item_id) and sale_line.qty > 0:
                                break
                        else:
                            raise MissingItemsException('NDISCOUNT_ERROR_REQUIRED_ITEMS_ON_SALE')
                    elif "group" in value:
                        required_group = value["group"]
                        if required_group not in product_groups:
                            raise MissingItemsException('NDISCOUNT_ERROR_REQUIRED_ITEMS_ON_SALE')

                        for sale_line in order.sale_lines:
                            if int(sale_line.item_id) in product_groups[required_group] and sale_line.qty > 0:
                                break
                        else:
                            raise MissingItemsException('NDISCOUNT_ERROR_REQUIRED_ITEMS_ON_SALE')

    @staticmethod
    def _validate_required_items_on_sale(benefit, burnt_items, order):
        if not BenefitValidator._enough_items_on_order(benefit, order, burnt_items):
            raise MissingItemsException('NDISCOUNT_ERROR_REQUIRED_ITEMS_ON_SALE')

    @staticmethod
    def _validate_required_groups_on_sale(benefit, burnt_items, order, product_groups):
        if not BenefitValidator._enough_items_groups_on_order(benefit, order, burnt_items, product_groups):
            raise MissingItemsException('NDISCOUNT_ERROR_REQUIRED_GROUPS_ON_SALE')

    @staticmethod
    def _validate_max_sale_amount(conditions, order):
        order_xml = order.order_picture
        due_amount = float(order_xml.get("dueAmount", 0))
        if conditions.max_sale_amount is not None and due_amount > conditions.max_sale_amount:
            raise ExcessiveSaleAmountException('NDISCOUNT_ERROR_MAX_SALE_AMOUNT')

    @staticmethod
    def _validate_min_sale_amount(conditions, order):
        order_xml = order.order_picture
        due_amount = float(order_xml.get("dueAmount", 0))
        if conditions.min_sale_amount is not None and due_amount < conditions.min_sale_amount:
            raise InsufficientSaleAmountException('NDISCOUNT_ERROR_MIN_SALE_AMOUNT')

    @staticmethod
    def _validate_unique_for_sale(applied_benefits, conditions):
        # type: (list, Conditions) -> None

        if len(applied_benefits) > 0:
            if conditions.unique_for_sale:
                raise CustomBenefitAlreadyAppliedException('NDISCOUNT_ERROR_UNIQUE_FOR_SALE')
            for applied_benefit in applied_benefits:
                benefit = Benefit(applied_benefit.get("benefit"))
                if benefit.conditions.unique_for_sale:
                    raise CustomBenefitAlreadyAppliedException('NDISCOUNT_ERROR_UNIQUE_FOR_SALE_HAS_EXISTS')

    @staticmethod
    def _is_benefit_valid_now(conditions):
        # type: (Conditions) -> None

        valid_hours = conditions.valid_hours
        now = datetime.now().isoformat().split("T")[1]
        if valid_hours.start_hour is not None and now < valid_hours.start_hour:
            raise BenefitExpiredOrInactiveException('NDISCOUNT_START_HOUR')
        if valid_hours.end_hour is not None and now > valid_hours.end_hour:
            raise BenefitExpiredOrInactiveException('NDISCOUNT_END_HOUR')

    @staticmethod
    def _is_benefit_valid_for_business_period(
        conditions,     # type: Conditions
        model,          # type: eTree.XML
    ):
        # type: (...) -> None

        period = model.find("PosState").get("period")
        datetime_period = iso8601.parse_date(period)

        if conditions.valid_days is not None and str(datetime_period.isoweekday() % 7 + 1) not in conditions.valid_days:
            raise BenefitExpiredOrInactiveException('NDISCOUNT_VALID_DAYS')

        if conditions.valid_start_period is not None and datetime_period < conditions.valid_start_period:
            raise BenefitExpiredOrInactiveException('NDISCOUNT_START_PERIOD')

        period_end = datetime_period + timedelta(days=1)
        if conditions.valid_end_period is not None and period_end > conditions.valid_end_period:
            raise BenefitExpiredOrInactiveException('NDISCOUNT_END_PERIOD')

    @staticmethod
    def _enough_items_on_order(benefit, order, burnt_items):
        # type: (Benefit, Order, dict) -> int

        sale_lines = order.sale_lines
        conditions = benefit.conditions

        required_items_on_sale = {}
        available_items_on_sale = {}

        for item in conditions.required_items_on_sale:
            key = "{};{}".format(item.context if item.context else 'any', str(item.item_id))
            required_items_on_sale[key] = item.quantity
            available_items_on_sale[key] = 0

        if not required_items_on_sale:
            return True

        for sale_line in sale_lines:
            context = sale_line.context
            available_quantity = sale_line.qty
            available_quantity -= 0 if conditions.ignore_burned_items else int(burnt_items.get(sale_line.key, 0))

            for item_index in required_items_on_sale.keys():
                required_item_context, required_item_id = item_index.split(';')
                if int(sale_line.item_id) != int(required_item_id):
                    continue

                if required_item_context == 'any' or required_item_context in context:
                    available_items_on_sale[item_index] += available_quantity

        times = min([available_items_on_sale[i] / required_items_on_sale[i] for i in required_items_on_sale.keys()])

        if times > 0:
            benefit.times_to_apply = times if conditions.burn_items else None
            return True

        return False

    @staticmethod
    def _enough_items_groups_on_order(benefit, order, burnt_items, product_groups):
        # type: (Benefit, Order, dict, dict) -> int

        sale_lines = order.sale_lines
        conditions = benefit.conditions

        required_groups_on_sale = {}
        available_groups_on_sale = {}

        for group in conditions.required_groups_on_sale:
            required_groups_on_sale[group.group] = group.quantity
            available_groups_on_sale[group.group] = 0

        if not required_groups_on_sale:
            return True

        for sale_line in sale_lines:
            available_quantity = sale_line.qty
            available_quantity -= 0 if conditions.ignore_burned_items else int(burnt_items.get(sale_line.key, 0))

            for group_index in required_groups_on_sale.keys():
                if int(sale_line.item_id) not in product_groups[group_index]:
                    continue

                available_groups_on_sale[group_index] += available_quantity

        times = min([available_groups_on_sale[i]/required_groups_on_sale[i] for i in required_groups_on_sale.keys()])

        if times > 0:
            benefit.times_to_apply = times if conditions.burn_items else None
            return True
        return False

    @staticmethod
    def validate_added_sale_line_benefit(applied_benefit, order):
        # type: (OrderBenefitInfo, Order) -> bool

        for sale_line in order.sale_lines:
            item_removed = int(sale_line.qty) == int(sale_line.level) == 0
            benefits_exists = int(sale_line.line_number) in applied_benefit.added_sale_lines
            if benefits_exists and item_removed:
                return False

        return True

    @staticmethod
    def _validate_pod_type(conditions, model):
        # type: (Conditions, eTree.XML) -> None

        if conditions.pod_type:
            try:
                pod_type = model.find(".//WorkingMode").get("podType")
                if pod_type is None:
                    raise PodTypeNotValidException('NDISCOUNT_ERROR_POD_TYPE')

                pod_types = [pod_type]
                if pod_type.upper() == "FL":
                    pod_types = ["FC", "TS"]

                if not any(x in pod_types for x in conditions.pod_type):
                    raise PodTypeNotValidException('NDISCOUNT_ERROR_POD_TYPE')
            except PodTypeNotValidException as _:
                raise
            except Exception as _:
                raise ApplyingDiscountUnknownException('NDISCOUNT_ERROR_UNKNOWN')

    @staticmethod
    def _validate_user_min_level(conditions, user_auth_level):
        # type: (Conditions, int) -> None

        if conditions.user_min_level is not None:
            try:
                if user_auth_level < conditions.user_min_level:
                    raise UserLevelNotAllowedException('NDISCOUNT_ERROR_USER_MIN_LEVEL')
            except UserLevelNotAllowedException as _:
                raise
            except Exception as _:
                raise ApplyingDiscountUnknownException('NDISCOUNT_ERROR_UNKNOWN')

    @staticmethod
    def _validate_service_type(conditions, order):
        # type: (Conditions, Order) -> None

        if conditions.service_type:
            try:
                service_type = "Table" if order.custom_properties.get("TABLE_ID") is not None else "Quick"
                if service_type not in conditions.service_type:
                    raise InvalidServiceTypeException("NDISCOUNT_ERROR_SERVICE_TYPE_" + service_type.upper())
            except InvalidServiceTypeException as _:
                raise
            except Exception as _:
                raise ApplyingDiscountUnknownException('NDISCOUNT_ERROR_UNKNOWN')

    @staticmethod
    def _validate_sale_type(conditions, order):
        # type: (Conditions, Order) -> None

        sale_types = conditions.sale_type
        if sale_types:
            try:
                order_sale_type = SaleTypes[order.sale_type].value if SaleTypes[order.sale_type] else 'ERROR'
                any_table_order_has_correct_sale_type = BenefitValidator._validate_table_sale_type(sale_types, order)

                if order_sale_type not in sale_types and not any_table_order_has_correct_sale_type:
                    raise InvalidSaleTypeException("NDISCOUNT_ERROR_SALE_TYPE")
            except InvalidSaleTypeException as _:
                raise
            except Exception as _:
                raise ApplyingDiscountUnknownException('NDISCOUNT_ERROR_UNKNOWN')

    @staticmethod
    def _validate_sector(conditions, order):
        # type: (Conditions, Order) -> None

        if conditions.sector:
            order.get_table_info()
            try:
                if order.sector is None:
                    raise InvalidSectorException("NDISCOUNT_ERROR_SECTOR_NOT_TABLE")

                if order.sector not in conditions.sector:
                    raise InvalidSectorException("NDISCOUNT_ERROR_SECTOR")
            except InvalidSectorException as _:
                raise
            except Exception as _:
                raise ApplyingDiscountUnknownException()

    @staticmethod
    def _validate_min_seats(conditions, order):
        # type: (Conditions, Order) -> None

        if conditions.min_seats:
            order.get_table_info()
            message = 'NDISCOUNT_ERROR_MIN_REQUIRED_SEATS|{0}'.format(conditions.min_seats)
            try:
                if conditions.min_seats > order.seats:
                    raise InvalidMinSeatsException(message)
            except InvalidMinSeatsException as _:
                raise
            except Exception as _:
                raise ApplyingDiscountUnknownException('NDISCOUNT_ERROR_UNKNOWN')

    @staticmethod
    def _validate_amount_discount(benefit_to_apply, order):
        # type: (Benefit, Order) -> None

        for action in benefit_to_apply.actions:
            if action.name == ActionTypes.discountAmountOnTotal.value:
                BenefitValidator.get_discount_on_total(action, order)
            if action.name == ActionTypes.discountPercentageOnTotal.value:
                BenefitValidator.get_discount_on_total(action, order, True)

    @staticmethod
    def get_discount_on_total(action, order, use_percentage=False):
        # type: (Action, Order, bool) -> None

        discount = order.total_gross * (float(action.value) / 100) if use_percentage else float(action.value)
        if discount >= order.total_gross:
            max_discount_amount = order.total_gross - 0.01
            message_value = 'NDISCOUNT_ERROR_MIN_REQUIRED_SALE_AMOUNT|{0:.2f}'.format(max_discount_amount)
            message_percentage = 'NDISCOUNT_ERROR_MAX_DISCOUNT_SALE_AMOUNT|{0:.0f}'.format(action.value)
            message = message_percentage if use_percentage else message_value
            raise InsufficientSaleAmountException(message)

    @staticmethod
    def _validate_single_use(benefit, applied_benefits):
        # type: (Benefit, list) -> None

        if benefit.conditions.single_use:
            for applied_benefit in applied_benefits:
                if applied_benefit["benefit"]["id"] == benefit.id:
                    raise CustomBenefitAlreadyAppliedException('NDISCOUNT_ERROR_SINGLE_USE')

    @staticmethod
    def _validate_ask_benefit(benefit):
        # type: (Benefit) -> None

        if benefit.ask_value:
            discount_max_value = benefit.conditions.discount_max_value
            if discount_max_value:
                total_discount_to_apply = sum([float(x.value) for x in benefit.actions])
                if total_discount_to_apply > discount_max_value:
                    raise AskValueDiscountMaxValueReachedException("NDISCOUNT_ASK_VALUE_MAX_VALUE_REACHED")

    @staticmethod
    def _validate_service_period(conditions, order):
        # type: (Conditions, Order) -> None

        if conditions.service_period and (order.order_state == 'IN_PROGRESS' or not order.table_id):
            current_service_period = get_service_period(order)
            current_service_period_id = current_service_period.get("servicePeriodId", None)
            if current_service_period_id not in conditions.service_period:
                raise InvalidServicePeriodOrderException('NDISCOUNT_INVALID_SERVICE_PERIOD_ORDER')

    @staticmethod
    def _validate_table_sale_type(conditions_sale_types, order):
        # type: (List[str], Order) -> bool

        if order.table_id is None:
            return False

        for sale_line in order.sale_lines:
            if sale_line.sale_type is None:
                continue
            sale_type = SaleTypes[sale_line.sale_type].value if SaleTypes[sale_line.sale_type] else 'ERROR'
            if sale_type in conditions_sale_types:
                return True
        return False

    @staticmethod
    def _validate_brand(conditions, order):
        # type: (Conditions, Order) -> None

        if conditions.brands and order.brand not in conditions.brands:
            raise InvalidBrandException("NDISCOUNT_ERROR_BRAND")
