import json
import sys
from logging import Logger

from messagebus import (
    Event,
    MessageBus,
)
from typing import (
    Any,
    Dict,
    List,
    Optional,
    Union
)
from xml.etree import cElementTree as eTree

from posot import OrderTaker
from sysactions import get_tender_types

from application.model.benefit import (
    Action,
    Benefit,
    RequiredItemOnSale,
    OrderBenefitInfo,
    ActionResult,
    Conditions,
)
from application.model.conditions import Restrictions
from application.model.exceptions import (
    InvalidTenderIdException,
    InvalidAmountException,
    NotEffectiveDiscountException,
    InvalidQuantityException,
    AddItemsOnTotalException
)
from application.model.order import Order, SaleLine
from application.model.transactions import AddBenefitToApplyUnitOfWork
from application.util import (
    CompositionType,
    add_to_dict_value,
    get_item_values_line_ids,
    get_line_id_from_sale_line,
    get_remaining_amount_discount,
    have_action_by_items_or_groups,
    is_invalid_sale_line_or_remaining_quantity,
    subtract_quantity,
    round_value,
)

FAKE_DISCOUNT_ID = "999"


class DiscountService(object):
    def __init__(
        self,
        pos_ot,             # type: OrderTaker
        product_groups,     # type: Dict
        message_bus,        # type: MessageBus
        logger,             # type: Logger
    ):
        # type: (...) -> None

        self.pos_ot = pos_ot
        self.product_groups = product_groups
        self.message_bus = message_bus
        self.logger = logger

    def apply_discount_amount_on_total(
        self,                    # type: DiscountService
        action,                  # type: Action
        order,                   # type: Order
        restriction,             # type: Restrictions
        available_discount_ids,  # type: set
        benefit,                 # type: Benefit
    ):
        # type: (...) -> ActionResult

        return self._apply_discount_on_total(
            action=action,
            order=order,
            restriction=restriction,
            available_discount_ids=available_discount_ids,
            benefit=benefit,
        )

    def apply_discount_percentage_on_total(
        self,
        action,                     # type: Action
        order,                      # type: Order
        restriction,                # type: Restrictions
        available_discount_ids,     # type: set
        benefit,                    # type: Benefit
    ):
        # type: (...) -> ActionResult

        return self._apply_discount_on_total(
            action=action,
            order=order,
            restriction=restriction,
            available_discount_ids=available_discount_ids,
            benefit=benefit,
            use_percentage=True,
        )

    def apply_discount_amount_by_groups(self, action, order, restriction, discount_ids, apply_on_total):
        # type: (Action, Order, Restrictions, set, bool) -> ActionResult

        return self._apply_discount_by_items(action, order, restriction, discount_ids, False, apply_on_total, True)

    def apply_discount_percentage_by_groups(self, action, order, restriction, discount_ids, apply_on_total):
        # type: (Action, Order, Restrictions, set, bool) -> ActionResult

        return self._apply_discount_by_items(action, order, restriction, discount_ids, True, apply_on_total, True)

    def apply_discount_amount_by_items(self, action, order, restriction, discount_ids, apply_on_total):
        # type: (Action, Order, Restrictions, set, bool) -> ActionResult

        return self._apply_discount_by_items(action, order, restriction, discount_ids, False, apply_on_total)

    def apply_discount_percentage_by_items(self, action, order, restriction, discount_ids, apply_on_total):
        # type: (Action, Order, Restrictions, set, bool) -> ActionResult

        return self._apply_discount_by_items(action, order, restriction, discount_ids, True, apply_on_total)

    def apply_discount_amount_on_total_by_tender(self, action, order, restriction, by_items=False):
        # type: (Action, Order, Restrictions, bool) -> ActionResult

        return self.apply_discount_on_total_by_tender(action, order, restriction, by_items=by_items)

    def apply_discount_percentage_on_total_by_tender(self, action, order, restriction):
        # type: (Action, Order, Restrictions) -> ActionResult

        return self.apply_discount_on_total_by_tender(action, order, restriction, True)

    def apply_items_to_add(self, action, burnt_items, uow, restriction, on_total=False):
        # type: (Action, Dict, AddBenefitToApplyUnitOfWork, Restrictions, bool) -> ActionResult

        items_to_burn = {}
        benefit_applied = True

        for entry in action.value:
            part_code = entry.get("item")
            quantity = entry.get("quantity")
            free = bool(entry.get("free"))
            price = entry.get("price", None)
            tender_id = entry.get("tenderId", None)

            quantity = self._get_quantity_to_apply(burnt_items, quantity, uow)
            items_to_burn.update(burnt_items)
            for _ in range(0, quantity):
                action_burnt_items = uow.add_sale_line(
                    part_code=part_code,
                    is_free=free,
                    price=price,
                    tender_id=tender_id,
                    burnt_items=items_to_burn,
                    restriction=restriction,
                    on_total=on_total,
                    action=action,
                )
                items_to_burn.update(action_burnt_items)
                benefit_applied = benefit_applied and not free

        return ActionResult([], items_to_burn, [], {}, benefit_applied, not on_total)

    def apply_tender_on_added_items(self, action, order, benefit_order_info):
        # type: (Action, Order, OrderBenefitInfo) -> ActionResult

        if benefit_order_info is None:
            raise AddItemsOnTotalException()

        order_tender_ids = []

        if not benefit_order_info.promotion_tender:
            return ActionResult([], {}, order_tender_ids, {}, True, True)

        for promotion_tender in benefit_order_info.promotion_tender:
            if action.order != promotion_tender.get("action_id"):
                continue

            tender_amount = promotion_tender.get("amount", 0)
            if tender_amount > 0:
                promotion_tender_id = promotion_tender.get("tender_id")
                if promotion_tender_id is None:
                    raise InvalidTenderIdException()

                tender_id = int(promotion_tender_id)
                pos_id = order.pos_id
                order_tender_id = self._do_order_tender(pos_id, tender_id, tender_amount)
                if order_tender_id:
                    order_tender_ids.append(order_tender_id)

        return ActionResult([], {}, order_tender_ids, {}, True, False)

    def _show_message(
        self,
        pos_id,                 # type: str
        message,                # type: str
        title="$INFORMATION",   # type: str
    ):
        # type: (...) -> None

        if not message:
            return
        data = "{}\\0{}\\0{}\\0{}".format(pos_id, message, title, True)
        self.message_bus.publish_event(Event("SHOW_MESSAGE", "NDISCOUNT", data))

    def _apply_discount_on_total(
        self,
        action,                     # type: Action
        order,                      # type: Order
        restriction,                # type: Restrictions
        available_discount_ids,     # type: set
        benefit,                    # type: Benefit
        use_percentage=False,       # type: bool
    ):
        # type: (...) -> ActionResult

        amount = self._get_amount(
            amount=action.value,
            order=order,
            restriction=restriction,
            seat=action.seat,
            use_percentage=use_percentage,
            persistent_value=action.persistent_value,
        )

        max_value_condition = sys.maxint
        if benefit.conditions:
            if benefit.conditions.discount_max_value is not None:
                max_value_condition = benefit.conditions.discount_max_value - order.discount_amount
            if action.max_value:
                max_value_condition = action.max_value - order.discount_amount

        to_apply_amount = min(float(amount), max_value_condition)
        inserted_value = amount if use_percentage else action.value
        self._validate_discount_amount(
            pos_id=order.pos_id,
            inserted_value=inserted_value,
            max_value_condition=max_value_condition,
            to_apply_amount=to_apply_amount,
            percentage_rate=action.value if use_percentage else None,
        )

        if to_apply_amount > 0:
            to_tender_amount = 0
            total_gross = order.total_gross
            if benefit.tip_included:
                total_gross += order.tip

            if round_value(to_apply_amount - total_gross) >= 0:
                to_tender_amount = self._calculate_to_tender_amount(benefit, order)
                to_apply_amount = to_apply_amount - to_tender_amount

            discount_id = available_discount_ids.pop()
            self.pos_ot.applyDiscount(
                discountid=discount_id,
                discountamt=to_apply_amount,
                forcebyitem=1,
            )
            discount_by_line_number = self._split_discount_by_sale_line(
                order=order,
                restriction=restriction,
                discount_id=str(discount_id),
                discount_amount=to_apply_amount,
            )

            order_tender_ids = []
            if to_tender_amount > 0:
                tender_id = self._get_tender_id(
                    action=action,
                )
                if tender_id is not None:
                    order_tender_id = self._do_order_tender(order.pos_id, tender_id, to_tender_amount)
                    if order_tender_id is not None:
                        order_tender_ids.append(order_tender_id)

            return ActionResult([discount_id], dict(), order_tender_ids, discount_by_line_number, True, False)

        return ActionResult([], {}, [], {}, False, False)

    def _validate_discount_amount(
        self,                       # type: DiscountService
        pos_id,                     # type: str
        inserted_value,             # type: float
        max_value_condition,        # type: float
        to_apply_amount,            # type: float
        percentage_rate,            # type: Optional[float]
    ):
        # type: (...) -> None

        message = None
        if percentage_rate is not None:
            if inserted_value > to_apply_amount:
                message = '$NDISCOUNT_LIMITED_PERCENTAGE_DISCOUNT|{0:.2f}|{1:.2f}|{2:.2f}'.format(
                    percentage_rate,
                    max_value_condition,
                    max_value_condition,
                )
        else:
            if inserted_value > to_apply_amount:
                message = '$NDISCOUNT_LIMITED_AMOUNT_DISCOUNT|{0:.2f}'.format(
                    to_apply_amount
                )

        if message:
            self._show_message(
                pos_id=pos_id,
                message=message,
            )

    def _calculate_to_tender_amount(self, benefit, order):
        total_tender = sum([x for x in (order.tenders or {}).itervalues()])
        if round_value(total_tender) > 0.00:
            return 0.00

        if self._is_table_order(order) and benefit.tip_included and order.tip > 0:
            return order.tip

        return 0.01

    @staticmethod
    def _is_table_order(
        order,  # type: Order
    ):
        return "SERVICE_ID" in order.custom_properties

    def _get_discounts_in_order_picture_by_sale_line(
        self,
        current_discount_id,     # type: str
        order_id,                # type: int
        order_picture_xml=None,  # type: str
    ):
        # type: (...) -> Dict

        if not order_picture_xml:
            order_picture_xml = self.pos_ot.orderPicture(
                orderid=order_id,
            )
            order_picture = eTree.XML(order_picture_xml).find("Order")
        else:
            order_picture = eTree.XML(order_picture_xml)

        sale_lines = order_picture.findall("SaleLine")
        discount_by_line_number = {}
        for sale_line in sale_lines:
            if not sale_line.get("discountsApplied"):
                continue
            discounts_applied = sale_line.get("discountsApplied").split(',')
            for discount_applied in discounts_applied:
                discount_id, discount_amount = discount_applied.split(':')
                if current_discount_id != int(discount_id):
                    continue

                if discount_id not in discount_by_line_number:
                    discount_by_line_number.update({
                        discount_id: {},
                    })
                line_id = get_line_id_from_sale_line(
                    sale_line=sale_line,
                    from_xml=True,
                )

                discount_line = float(discount_amount) + discount_by_line_number[discount_id].get(line_id, 0.0)
                discount_by_line_number[discount_id].update({
                    line_id: discount_line,
                })

        return discount_by_line_number

    def _apply_discount_by_items(
        self,
        action,                 # type: Action
        order,                  # type: Order
        restriction,            # type: Restrictions
        usable_discount_ids,    # type: set
        use_percentage,         # type: bool
        apply_on_total,         # type: bool
        by_groups=False         # type: bool
    ):
        # type: (...) -> ActionResult

        applied_discounts = []
        discount_by_group = None
        remaining_group_qty = None
        if by_groups:
            action_quantity = int(action.value[0].get("quantity", "1"))
            remaining_group_qty = (action.times_to_apply or 1) * action_quantity
            items_to_apply_discount, discount_by_group = self._get_items_by_groups_to_apply_discount(
                action=action,
                use_percentage=use_percentage,
            )
        else:
            items_to_apply_discount = self._get_items_to_apply_discount(action)

        discount_amount_on_total = 0
        total_discount = 0
        item_values = {}
        discount_by_line_number = {}
        entry_sale_lines = self._filter_sale_lines_by_seat(
            action=action,
            order=order,
        )
        benefits_to_remove = []
        for sale_line in entry_sale_lines:
            is_invalid = is_invalid_sale_line_or_remaining_quantity(
                restriction=restriction,
                sale_line=sale_line,
                remaining_quantity=remaining_group_qty,
            )
            if is_invalid:
                continue

            current_discount = items_to_apply_discount.get(int(sale_line.item_id))
            if current_discount is None:
                continue

            message = "Applying discount by items: {} / {} / {}"
            self.logger.info(message.format(current_discount, sale_line.key, sale_line.qty))
            remaining_sale_line_qty = sale_line.qty
            amount = 0

            while remaining_sale_line_qty > 0 and current_discount is not None:
                selected_discount = current_discount
                group = selected_discount["group"] if discount_by_group else ""
                if discount_by_group:
                    quantity_to_be_removed = min(remaining_sale_line_qty, discount_by_group[group]["quantity"])
                else:
                    quantity_to_be_removed = min(remaining_sale_line_qty, selected_discount["quantity"])

                if use_percentage:
                    total_value_to_remove = self._get_item_price(
                        order=order,
                        sale_line=sale_line,
                        quantity=quantity_to_be_removed,
                        current_price=False,
                    )
                    if not apply_on_total:
                        max_value_to_apply = self._get_item_price(
                            order=order,
                            sale_line=sale_line,
                            quantity=quantity_to_be_removed,
                        )
                        total_value_to_remove = min(total_value_to_remove, max_value_to_apply)
                else:
                    total_value_to_remove = self._get_item_price(
                        order=order,
                        sale_line=sale_line,
                        quantity=quantity_to_be_removed,
                    )

                if not discount_by_group:
                    remaining_sale_line_qty -= quantity_to_be_removed
                    selected_discount["quantity"] -= quantity_to_be_removed
                    remaining_group_qty = subtract_quantity(
                        value=remaining_group_qty,
                        value_to_subtract=quantity_to_be_removed,
                    )

                if use_percentage:
                    amount_to_add = total_value_to_remove * (selected_discount["amount"] / 100.0)
                else:
                    if discount_by_group:
                        amount_to_add = discount_by_group[group]["amount"]
                    else:
                        amount_to_add = quantity_to_be_removed * selected_discount["amount"]

                amount += min(amount_to_add, total_value_to_remove)
                if discount_by_group and amount > 0:
                    discount_by_group[group]["amount"] -= min(amount_to_add, total_value_to_remove)
                    discount_by_group[group]["quantity"] -= quantity_to_be_removed
                    remaining_sale_line_qty -= quantity_to_be_removed
                    selected_discount["quantity"] -= quantity_to_be_removed
                    remaining_group_qty = subtract_quantity(
                        value=remaining_group_qty,
                        value_to_subtract=quantity_to_be_removed,
                    )

                if discount_by_group and group in discount_by_group:
                    if discount_by_group[group]["quantity"] == 0 or discount_by_group[group]["amount"] == 0:
                        break

                if remaining_group_qty == 0 or selected_discount["quantity"] == 0 or selected_discount["amount"] == 0:
                    break

            max_discount_amount = self._get_order_max_discount_amount(
                order=order,
                restriction=restriction,
                seat=action.seat,
            )
            persistent_amount = get_remaining_amount_discount(
                max_value_to_discount=action.persistent_value,
                actual_value=total_discount,
            )
            amount = min(float(amount), max_discount_amount, persistent_amount)
            message = "Applying discount by items: sale line amount - {} / {} / {}"
            self.logger.info(message.format(current_discount, sale_line.key, amount))
            if amount > 0:
                line_id = get_line_id_from_sale_line(sale_line=sale_line)
                benefits_to_remove = self._add_id_if_needs_to_remove_previous_benefits(
                    order=order,
                    line_id=line_id,
                    benefits_ids_to_remove=benefits_to_remove,
                    apply_on_total=apply_on_total,
                )
                if benefits_to_remove:
                    continue

                add_to_dict_value(
                    object_dict=item_values,
                    dict_key=line_id,
                    value=amount,
                )

                if apply_on_total:
                    discount_amount_on_total += amount
                else:
                    discount_id = usable_discount_ids.pop()
                    self._apply_discount(
                        amount=amount,
                        discount_id=discount_id,
                        line_number=sale_line.line_number,
                    )

                    applied_discounts.append(discount_id)
                    discount_by_line_number[discount_id] = item_values

                    item_values = {}

                total_discount += amount

        order_tender_ids = []
        if discount_amount_on_total > 0 and not benefits_to_remove:
            max_discount_amount = self._get_order_max_discount_amount(
                order=order,
                restriction=restriction,
                seat=action.seat,
            )
            persistent_amount = get_remaining_amount_discount(max_value_to_discount=action.persistent_value)
            discount_amount_on_total = min(float(discount_amount_on_total), max_discount_amount, persistent_amount)

            to_tender_amount = 0
            total_gross = order.total_gross
            if restriction.benefit.tip_included:
                total_gross += order.tip

            if round_value(discount_amount_on_total - total_gross) >= 0:
                to_tender_amount = self._calculate_to_tender_amount(restriction.benefit, order)
                discount_amount_on_total = discount_amount_on_total - to_tender_amount

            discount_id = usable_discount_ids.pop()
            self._apply_discount(
                amount=discount_amount_on_total,
                discount_id=discount_id,
                line_number=None,
            )
            discount_by_line_number[discount_id] = item_values
            applied_discounts.append(discount_id)

            if to_tender_amount > 0:
                tender_id = self._get_tender_id(
                    action=action,
                )
                if tender_id is not None:
                    order_tender_id = self._do_order_tender(order.pos_id, tender_id, to_tender_amount)
                    if order_tender_id is not None:
                        order_tender_ids.append(order_tender_id)

        self.logger.info("Applying discount by items: final total discount - {}".format(total_discount))
        if total_discount > 0 or benefits_to_remove:
            return ActionResult(
                discounts_id=applied_discounts,
                items_to_burn={},
                order_tender_ids=order_tender_ids,
                discount_item_values=discount_by_line_number,
                benefit_applied=True,
                pending_to_apply=False,
                benefit_ids_to_remove=benefits_to_remove,
            )
        else:
            self.logger.warn("Non effective discount. Total discount: {}".format(total_discount))
            raise NotEffectiveDiscountException()

    def apply_discount_on_total_by_tender(self, action, order, restriction, use_percentage=False, by_items=False):
        # type: (Action, Order, Restrictions, bool, bool) -> ActionResult

        order_tender_ids = []
        for entry in action.value:
            tender_id = int(entry.get("tenderId", -1))
            if not self._validate_tender(tender_id):
                raise InvalidTenderIdException()

            if by_items:
                self._apply_payments_by_items(
                    entry=entry,
                    order=order,
                    order_tender_ids=order_tender_ids,
                    restriction=restriction,
                    tender_id=tender_id,
                    persistent_value=action.persistent_value,
                )
            else:
                self._apply_payment_on_order(
                    entry=entry,
                    order=order,
                    order_tender_ids=order_tender_ids,
                    restriction=restriction,
                    tender_id=tender_id,
                    use_percentage=use_percentage,
                    persistent_value=action.persistent_value,
                )

        return ActionResult([], {}, order_tender_ids, {}, True, False)

    def _apply_payments_by_items(
        self,
        entry,              # type: Dict
        order,              # type: Order
        order_tender_ids,   # type: List[int]
        restriction,        # type: Restrictions
        tender_id,          # type: int
        persistent_value,   # type: float
    ):
        # type: (...) -> None

        seat = entry.get("seat", None)
        sale_lines = self._get_sale_lines_with_seat(
            order=order,
            seat=seat,
        )

        quantity = self._get_quantity_to_apply_discount(
            sale_lines=sale_lines,
            conditions=restriction.benefit.conditions,
        )
        if not quantity:
            return

        total_tender_amount = 0
        for sale_line in order.sale_lines:
            item_id = sale_line.item_id
            if restriction.validate(sale_line) and item_id == str(entry["item"]):
                available_quantity = min(sale_line.qty, quantity)
                amount = self._get_item_price(
                    order=order,
                    sale_line=sale_line,
                    quantity=available_quantity,
                )
                persistent_amount = get_remaining_amount_discount(
                    max_value_to_discount=persistent_value,
                    actual_value=total_tender_amount,
                )
                amount = min(amount, persistent_amount)
                order_tender_id = self._do_order_tender(order.pos_id, tender_id, amount) if amount else False
                if order_tender_id:
                    order_tender_ids.append(order_tender_id)
                    line_number = sale_line.line_number
                    self.pos_ot.setOrderCustomProperty(
                            "discountPayment", order_tender_id, "", line_number, "1", "0", item_id
                    )
                    total_tender_amount += amount

                quantity -= available_quantity
                if quantity == 0:
                    break

    def _apply_payment_on_order(
        self,
        entry,              # type: Dict
        order,              # type: Order
        order_tender_ids,   # type: List[int]
        restriction,        # type: Restrictions
        tender_id,          # type: int
        use_percentage,     # type: bool
        persistent_value,   # type: Optional[float]
    ):
        # type: (...) -> None

        value = float(entry.get("percentage", 0)) if use_percentage else float(entry.get("amount", 0))

        amount = self._get_amount(
            amount=value,
            order=order,
            restriction=restriction,
            seat=entry.get("seat"),
            use_percentage=use_percentage,
            persistent_value=persistent_value,
        )

        if not amount:
            raise NotEffectiveDiscountException()

        order_tender_id = self._do_order_tender(order.pos_id, tender_id, amount)
        if order_tender_id:
            order_tender_ids.append(order_tender_id)

    def _apply_discount(
        self,
        amount,             # type: float
        discount_id,        # type: int
        line_number=None,   # type: Optional[int]
    ):
        # type: (...) -> str

        if line_number is None:
            order_picture_xml = self.pos_ot.applyDiscount(discount_id, amount, forcebyitem=1)
        else:
            order_picture_xml = self.pos_ot.applyDiscount(
                discountid=discount_id,
                discountamt=amount,
                linenumber=line_number,
            )

        return order_picture_xml

    def _get_total_amount_with_restrictions(
        self,
        order,          # type: Order
        restriction,    # type: Restrictions
        seat,           # type: int
    ):
        # type: (...) -> float

        if seat is not None:
            order.get_table_info()

        amount = 0
        discount_line_values = self._split_discount_by_sale_line(
            order=order,
            restriction=restriction,
            discount_id=FAKE_DISCOUNT_ID,
            discount_amount=sys.maxint,
            seat=seat,
        )
        total_gross = order.total_gross
        if not discount_line_values:
            return total_gross - order.discount_amount

        sale_lines = self._get_sale_lines_with_seat(
            order=order,
            seat=seat,
        )

        for sale_line in sale_lines:
            line_id = get_line_id_from_sale_line(sale_line=sale_line)
            if line_id not in discount_line_values[FAKE_DISCOUNT_ID]:
                continue

            amount += discount_line_values[FAKE_DISCOUNT_ID][line_id]

        return amount

    def _get_total_gross_amount_with_restrictions(
        self,
        order,          # type: Order
        restriction,    # type: Restrictions
        seat=None,      # type: Optional[int]
    ):
        # type: (...) -> float

        if not restriction.exists() and not seat:
            return float(order.total_gross)

        return self._get_total_amount_with_restrictions(
            order=order,
            restriction=restriction,
            seat=seat,
        )

    def _get_order_max_discount_amount(
        self,
        order,          # type: Order
        restriction,    # type: Restrictions
        seat=None,      # type: Optional[int]
    ):
        # type: (...) -> float

        order_xml = order.order_picture
        if order_xml is None:
            return 0

        due_total_amount = float(order_xml.get("dueAmount") or 0)
        if due_total_amount > 0:
            due_total_amount -= 0.01
            due_total_amount = round_value(value=due_total_amount)

        if not restriction.exists() and not seat:
            return due_total_amount

        due_amount = self._get_total_amount_with_restrictions(
            order=order,
            restriction=restriction,
            seat=seat,
        )

        return min(due_amount, due_total_amount)

    def _get_items_to_apply_discount(
            self,
            action,     # type: Action
    ):
        # type: (...) -> Dict

        items_apply_discount = {}
        for entry in action.value:
            key = int(entry.get("item", -1))

            items_apply_discount = self._manager_item_to_apply_discount(items_apply_discount, key, entry)

        return items_apply_discount

    def _get_items_by_groups_to_apply_discount(
        self,
        action,                 # type: Action
        use_percentage=False,   # type: Optional[bool]
    ):
        # type: (...) -> (dict, dict)

        items_groups_to_apply_discount = {}
        discount_by_group = None if use_percentage else {}

        for entry in action.value:
            group = entry.get("group", "")
            if group not in self.product_groups:
                continue

            if not use_percentage:
                quantity = int(entry.get("quantity", 0))
                quantity = min(action.times_to_apply, quantity) if action.times_to_apply else quantity
                discount_by_group[group] = dict(amount=float(entry.get("amount", 0)), quantity=quantity)

            for product_id in self.product_groups[group]:
                key = int(product_id)
                items = self._manager_item_to_apply_discount(items_groups_to_apply_discount, key, entry, group)
                items_groups_to_apply_discount = items

        return items_groups_to_apply_discount, discount_by_group

    @staticmethod
    def _manager_item_to_apply_discount(
            items_to_apply_discount,  # type: Dict
            item_id,                  # type: int
            entry,                    # type: Dict,
            group="",                 # type: Optional[str],
    ):
        # type: (...) -> Dict

        amount = float(entry.get("amount", entry.get("percentage", 0)))
        quantity = int(entry.get("quantity", 0))

        if item_id not in items_to_apply_discount:
            items_to_apply_discount[item_id] = {"quantity": quantity, "amount": amount,  "group": group}

        return items_to_apply_discount

    def _get_amount(
        self,
        amount,             # type: float
        order,              # type: Order
        restriction,        # type: Restrictions
        use_percentage,     # type: bool
        seat,               # type: str
        persistent_value,   # type: float
    ):
        # type: (...) -> float

        if amount == 0:
            raise InvalidAmountException()

        total_gross = self._get_total_gross_amount_with_restrictions(
            order=order,
            restriction=restriction,
            seat=seat,
        )
        if use_percentage:
            amount = total_gross * (amount / 100.0)

        amount = persistent_value if persistent_value is not None else amount
        return min(amount, total_gross)

    @staticmethod
    def _get_quantity_to_apply_discount(
        sale_lines,     # type: List[SaleLine]
        conditions,     # type: Conditions
    ):
        # type: (...) -> int

        necessary_items = {}
        for required_item in conditions.required_items_on_sale:
            necessary_items[str(required_item.item_id)] = required_item.quantity

        order_necessary_items = {}
        for sale_line in sale_lines:
            if sale_line.item_id in necessary_items:
                if sale_line.item_id not in order_necessary_items:
                    order_necessary_items[sale_line.item_id] = 0

                order_necessary_items[sale_line.item_id] += sale_line.qty

        quantity = None
        for key, value in order_necessary_items.iteritems():
            x = value / necessary_items[key]
            quantity = min(quantity, x) if quantity else x

        return quantity

    def _get_quantity_to_apply(self, burnt_items, quantity, uow):
        # type: (Dict, int, AddBenefitToApplyUnitOfWork) -> int

        available_qty = []
        ignore = uow.benefit.conditions.ignore_burned_items
        for required in uow.benefit.conditions.required_items_on_sale:
            available_item_quantity = self._get_available_item_quantity(
                burnt_items={} if ignore else burnt_items,
                required=required,
                uow=uow
            )
            available_qty.append(available_item_quantity / required.quantity)

        for required in uow.benefit.conditions.required_groups_on_sale:
            available_item_quantity = self._get_available_item_quantity(
                burnt_items={} if ignore else burnt_items,
                required=required,
                uow=uow,
                is_group=True
            )
            available_qty.append(available_item_quantity / required.quantity)

        quantity *= min(available_qty) if available_qty else 1

        # TODO: Need to validate rule to handle cases where the item is sold by kg
        if quantity % 1 > 0:
            raise InvalidQuantityException()

        return int(quantity)

    def _get_available_item_quantity(
        self,
        burnt_items,    # type: Dict
        required,       # type: Any[Union[RequiredItemOnSale, str]]
        uow,            # type: AddBenefitToApplyUnitOfWork
        is_group=False  # type: Optional[bool]
    ):
        # type: (...) -> int

        available_quantity = 0
        for sale_line in uow.order.sale_lines:
            valid_context = is_group or required.context is None or sale_line.context == required.context
            item_in_group = is_group and int(sale_line.item_id) in self.product_groups.get(required.group, [])
            required_item_id = "" if is_group else str(required.item_id)
            if valid_context and (item_in_group or sale_line.item_id == required_item_id):
                available_quantity += sale_line.qty
                if sale_line.key in burnt_items:
                    available_quantity -= burnt_items[sale_line.key]

        return available_quantity

    def _do_order_tender(self, pos_id, tender_id, amount):
        # type: (int, int, float) -> Optional[int]

        order_tender = self.pos_ot.doTender(pos_id, tender_id, amount, donotclose=True)
        if order_tender:
            order_tender_id = int(order_tender.get("tenderId", 0) or 0)
            return None if order_tender_id == 0 else order_tender_id
        return None

    @staticmethod
    def _validate_tender(tender_id):
        # type: (int) -> bool

        all_tenders = get_tender_types()
        all_tenders = [key for key in all_tenders.keys()]
        return tender_id in all_tenders

    @staticmethod
    def _get_sale_line_discounts(order):
        sale_line_discounts = {}

        order_picture = order.order_picture
        benefit_list = order_picture.find(".//OrderProperty[@key=\"BENEFIT_LIST\"]")
        benefit_list = benefit_list.get("value") if benefit_list is not None else None

        benefits = json.loads(benefit_list) if benefit_list else {}

        for _, line_detail in benefits.get("discountItemValues", {}).items():
            for line_id, amount in line_detail.items():
                if line_id not in sale_line_discounts.keys():
                    sale_line_discounts[line_id] = amount
                else:
                    sale_line_discounts[line_id] += amount

        return sale_line_discounts

    def _split_discount_by_sale_line(
        self,
        order,                  # type: Order
        restriction,            # type: Restrictions
        discount_id,            # type: str
        discount_amount,        # type: float
        seat=None,              # type: Optional[int]
    ):
        # type: (...) -> Dict

        sale_line_discounts = self._get_sale_line_discounts(order=order)

        discount_by_line_id = {discount_id: {}}
        valid_sale_lines = {}
        total_amount = 0.0
        sale_lines = self._get_sale_lines_with_seat(
            order=order,
            seat=seat,
        )
        for sale_line in sale_lines:
            if restriction.validate(sale_line):
                line_id = get_line_id_from_sale_line(sale_line=sale_line)
                current_discount = sale_line_discounts.get(line_id, 0.0)
                price = (sale_line.unit_price * sale_line.qty) - current_discount

                if price <= 0:
                    continue

                total_amount += price
                valid_sale_lines[line_id] = price

        for key, line_amount in valid_sale_lines.iteritems():
            discount_amount = min(discount_amount, total_amount)
            sale_line_amount = round((discount_amount * (line_amount / total_amount)), 2)
            discount_by_line_id[discount_id][key] = sale_line_amount

        self._distribute_pending_discount_amount(
            order_sale_lines=order.sale_lines,
            sale_line_discounts=sale_line_discounts,
            discount_by_line_id=discount_by_line_id[discount_id],
            discount_amount=discount_amount,
        )

        return discount_by_line_id

    def _distribute_pending_discount_amount(
        self,
        order_sale_lines,               # type: List[SaleLine]
        sale_line_discounts,            # type: Dict[str, float]
        discount_by_line_id,            # type: Dict[str, float]
        discount_amount,                # type: float
    ):
        # type: (...) -> None

        applied_discount = sum(discount_by_line_id.values())
        pending_discount = round_value(value=discount_amount - applied_discount)
        if abs(pending_discount) == 0:
            return

        sale_lines = dict([(get_line_id_from_sale_line(sale_line=s), s) for s in order_sale_lines])
        self._split_and_apply_pending_discounts(
            sale_line_discounts=sale_line_discounts,
            sale_lines=sale_lines,
            discount_pending_to_apply=pending_discount,
            discount_by_line_id=discount_by_line_id,
        )

    @staticmethod
    def _split_and_apply_pending_discounts(
        sale_line_discounts,            # type: Dict[str, float]
        sale_lines,                     # type: Dict[str, SaleLine]
        discount_pending_to_apply,      # type: float
        discount_by_line_id,            # type: Dict[str, float]
    ):
        # type: (...) -> None

        for sale_line_id, _ in discount_by_line_id.items():
            if round_value(value=discount_pending_to_apply) == 0:
                break

            sale_line = sale_lines[sale_line_id]
            real_value = sale_line.item_price - sale_line_discounts.get(sale_line_id, 0)
            remove = discount_pending_to_apply < 0
            discount_limit = discount_by_line_id[sale_line_id] if remove else real_value
            discount_amount = min(discount_limit, abs(discount_pending_to_apply))
            if remove:
                discount_by_line_id[sale_line_id] -= discount_amount
                discount_pending_to_apply += discount_amount
            else:
                discount_by_line_id[sale_line_id] += discount_amount
                discount_pending_to_apply -= discount_amount

    @staticmethod
    def _get_item_price(
        order,                  # type: Order
        sale_line,              # type: SaleLine
        quantity=1,             # type: Optional[int]
        current_price=True,     # type: Optional[str]
    ):
        # type: (...) -> float

        not_combo = sale_line.item_type != CompositionType.COMBO
        if not_combo or sale_line.item_price != 0:
            return (sale_line.current_unit_price if current_price else sale_line.unit_price) * quantity

        combo_price = 0.0
        for line in order.sale_lines:
            if sale_line.line_number != line.line_number:
                continue
            combo_price += (line.current_unit_price if current_price else line.unit_price) * quantity

        return combo_price

    def _filter_sale_lines_by_seat(
        self,
        action,     # type: Action
        order,      # type: Order
    ):
        # type: (...) -> List[SaleLine]

        entry_sale_lines = []
        validate_seat = False
        for entry in action.value:
            entry_seat = entry.get("seat", None)
            if not entry_seat:
                continue
            elif not validate_seat:
                validate_seat = True

            entry_sale_lines.extend(self._get_sale_lines_with_seat(
                order=order,
                seat=entry_seat,
            ))

        if validate_seat:
            return entry_sale_lines
        else:
            return order.sale_lines

    @staticmethod
    def _get_sale_lines_with_seat(
        order,  # type: Order
        seat,   # type: int
    ):
        # type: (...) -> List[SaleLine]

        if not seat:
            return order.sale_lines

        sale_lines = []
        seats_by_line = {}
        for line in order.sale_lines:
            if line.level == '0':
                line_seat = int(line.seat) if line.seat else None
                seats_by_line[line.line_number] = int(line_seat) if line_seat else None
            else:
                line_seat = seats_by_line.get(line.line_number, None)

            if line_seat == seat:
                sale_lines.append(line)

        return sale_lines

    @staticmethod
    def _add_id_if_needs_to_remove_previous_benefits(
        order,                      # type: Order
        line_id,                    # type: str
        benefits_ids_to_remove,     # type: List[str]
        apply_on_total,             # type: bool
    ):
        # type: (...) -> List[str]

        if apply_on_total:
            return benefits_ids_to_remove

        discounts_ids = order.order_discounts.discount_ids.get(line_id, None)
        to_remove = []
        if not discounts_ids:
            return benefits_ids_to_remove

        benefit_list = order.applied_benefits
        for benefit in benefit_list:
            if not benefit.benefit_applied:
                continue

            used_benefits = benefit.used_discount_ids
            if not (set(discounts_ids) & set(used_benefits)):
                continue

            if have_action_by_items_or_groups(benefit=benefit.benefit):
                continue

            if have_action_by_items_or_groups(
                benefit=benefit.benefit,
                on_total=True,
            ):
                item_values_line_ids = get_item_values_line_ids(benefit=benefit)
                if line_id in item_values_line_ids:
                    continue

            to_remove.append(benefit.benefit_key)

        if to_remove:
            benefits_ids_to_remove = list(set(benefits_ids_to_remove + to_remove))

        return benefits_ids_to_remove

    def _get_tender_id(self, action):
        if action.tender_id is None:
            return None

        tender_id = action.tender_id
        if self._validate_tender(tender_id):
            return action.tender_id

        return None
