from abc import ABCMeta
from datetime import (
    datetime,
    timedelta,
)
from xml.etree import cElementTree as eTree
from typing import (
    Dict,
    List,
    Optional,
    Tuple,
)

from helper import round_half_away_from_zero

from application.model.benefit import (
    Benefit,
    OrderBenefitInfo,
    Conditions,
    ActionTypes,
    Action,
)
from application.model.exceptions import (
    ApplyingDiscountUnknownException,
    BenefitExpiredOrInactiveException,
    CustomBenefitAlreadyAppliedException,
    ErrorApplyingCoupon,
    ExcessiveSaleAmountException,
    InsufficientSaleAmountException,
    InvalidBrandException,
    InvalidMinSeatsException,
    InvalidSaleTypeException,
    InvalidSectorException,
    InvalidServicePeriodOrderException,
    InvalidServiceTypeException,
    MissingItemsException,
    PodTypeNotValidException,
    UserLevelNotAllowedException,
)
from application.model.order import (
    Order,
    SaleLine,
    SaleTypes,
)
from application.util import get_service_period
from iso8601 import iso8601
from typing import (
    Dict,
    List,
    Optional,
)


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_unique_for_group(
            benefit=benefit,
            applied_benefits=applied_benefits,
            conditions=conditions,
        )

    @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:
            order_seats = BenefitValidator._get_order_seats(order=order)
            seat = action.seat

            if not seat and not isinstance(action.value, (int, float)):
                seat = action.value[0].get("seat", None)
            if (seat and not order_seats) or (seat and order_seats and int(seat) > order_seats):
                raise ErrorApplyingCoupon("NDISCOUNT_ERROR_BENEFIT_MISSING_SEAT")
            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_on_order(
            benefit=benefit,
            order=order,
            burnt_items=burnt_items,
            by_groups=True,
            product_groups=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 _required_and_available_on_sale_from_conditions(
        conditions,     # type: Conditions
        by_groups,      # type: bool
    ):
        # type: (...) -> Tuple[Dict, Dict]

        required_on_sale = {}
        available_on_sale = {}

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

        return required_on_sale, available_on_sale

    @staticmethod
    def _update_available_on_sale(
        required_on_sale,   # type: Dict
        available_on_sale,  # type: Dict
        sale_line,          # type: SaleLine
        quantity,           # type: float
        by_groups,          # type: bool
        product_groups,     # type: Dict
    ):
        # type: (...) -> None

        if by_groups:
            for group_key in required_on_sale.keys():
                if int(sale_line.item_id) in product_groups[group_key]:
                    available_on_sale[group_key] += quantity
            return

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

            if required_item_context == 'any' or required_item_context in context:
                available_on_sale[item_key] += quantity

    @staticmethod
    def _enough_items_on_order(
        benefit,                # type: Benefit
        order,                  # type: Order
        burnt_items,            # type: Dict
        by_groups=False,        # type: Optional[bool]
        product_groups=None,    # type: Dict
    ):
        # type: (...) -> int

        sale_lines = order.sale_lines
        conditions = benefit.conditions

        required_on_sale, available_on_sale = BenefitValidator._required_and_available_on_sale_from_conditions(
            conditions=conditions,
            by_groups=by_groups,
        )

        if not required_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))
            BenefitValidator._update_available_on_sale(
                required_on_sale=required_on_sale,
                available_on_sale=available_on_sale,
                sale_line=sale_line,
                quantity=available_quantity,
                by_groups=by_groups,
                product_groups=product_groups,
            )

        times = min([available_on_sale[i] / required_on_sale[i] for i in required_on_sale.keys()])

        times = round_half_away_from_zero(
            number=times,
            precision=3,
        )
        if times >= 1:
            benefit.times_to_apply = int(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=action,
                    order=order,
                    benefit=benefit_to_apply,
                )
            if action.name == ActionTypes.discountPercentageOnTotal.value:
                BenefitValidator.get_discount_on_total(
                    action=action,
                    order=order,
                    benefit=benefit_to_apply,
                    use_percentage=True,
                )

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

        discount = order.total_gross * (float(action.value) / 100) if use_percentage else float(action.value)
        total_discounts = discount + order.discount_amount
        max_discount_amount = order.total_gross - 0.01
        max_value_condition = benefit.conditions.discount_max_value
        message = None

        if max_value_condition is not None and total_discounts > max_value_condition:
            remaining_discount_amount = max_value_condition - order.discount_amount
            if remaining_discount_amount <= 0:
                message = 'NDISCOUNT_MAX_DISCOUNT_APPLIED'
            else:
                if use_percentage:
                    return
                message = 'NDISCOUNT_MAX_DISCOUNT_VALUE|{0:.2f}|{1:.2f}'.format(
                    max_value_condition,
                    remaining_discount_amount,
                )
        elif action.max_value is not None and discount > action.max_value:
            message = 'NDISCOUNT_LIMITED_DISCOUNT|{0:.2f}'.format(action.max_value)
        elif discount >= order.total_gross:
            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

        if message is not None:
            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_service_period(
        conditions,     # type: Conditions
        order           # type: Order
    ):
        # type: (...) -> 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,     # type: Conditions
        order,          # type: Order
    ):
        # type: (...) -> bool

        if not conditions.brands:
            return True

        if order.brand in conditions.brands:
            return True

        for sale_line in order.sale_lines:
            if sale_line.brand is None:
                continue
            elif sale_line.brand in conditions.brands:
                return True

        raise InvalidBrandException("NDISCOUNT_ERROR_BRAND")

    @staticmethod
    def _get_order_seats(
        order,  # type: Order
    ):
        # type: (...) -> Optional[int]

        seats = order.custom_properties.get("TABLE_SEATS", None)
        return int(seats) if seats else None

    @staticmethod
    def _validate_unique_for_group(
        benefit,            # type: Benefit
        applied_benefits,   # type: list
        conditions,         # type: Conditions
    ):
        # type: (...) -> None

        if len(applied_benefits) > 0:
            applied_benefits = [Benefit(x.get("benefit")) for x in applied_benefits if x.get("benefit")]
            unique_for_group = conditions.unique_for_group
            for current_benefit in applied_benefits:
                current_unique_for_group = unique_for_group or current_benefit.conditions.unique_for_group
                if current_benefit.group == benefit.group and current_unique_for_group:
                    raise CustomBenefitAlreadyAppliedException('NDISCOUNT_ERROR_UNIQUE_FOR_GROUP_HAS_EXISTS')
