import json
from logging import Logger

from typing import (
    Any,
    Dict,
    List,
    Optional,
    Union
)

from posot import OrderTaker
from sysactions import get_tender_types

from application.model.benefit import Action, RequiredItemOnSale, OrderBenefitInfo, ActionResult, Conditions
from application.model.conditions import Restrictions
from application.model.exceptions import (
    InvalidTenderIdException,
    InvalidAmountException,
    InvalidPercentageException,
    NotEffectiveDiscountException,
    InvalidQuantityException,
    AddItemsException
)
from application.model.order import Order, SaleLine
from application.model.transactions import AddBenefitToApplyUnitOfWork
from application.util import CompositionType


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

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

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

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

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

        return self._apply_discount_on_total(action, order, restriction, available_discount_ids, 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, free, price, tender_id, items_to_burn, restriction, on_total, action.order
                )
                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

        order_tender_ids = []

        if benefit_order_info is None:
            raise AddItemsException()

        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 _apply_discount_on_total(self, action, order, restriction, available_discount_ids, use_percentage=False):
        # type: (Action, Order, Restrictions, set, bool) -> ActionResult

        total_gross = self._get_total_gross_amount(order, restriction)
        amount = total_gross * (float(action.value) / 100) if use_percentage else float(action.value)
        amount = min(float(amount), self._get_order_max_discount_amount(order, restriction))
        if amount > 0:
            discount_id = available_discount_ids.pop()
            self.pos_ot.applyDiscount(discount_id, amount, forcebyitem=1)
            discount_by_line_number = self._split_discount_by_sale_line(order, restriction, str(discount_id), amount)

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

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

    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
        group_quantity = None
        if by_groups:
            group_quantity = action.times_to_apply
            items_to_apply_discount, discount_by_group = self._get_items_by_groups_to_apply_discount(
                action,
                order.sale_lines,
                use_percentage
            )
        else:
            items_to_apply_discount = self._get_items_to_apply_discount(action, order.sale_lines)

        discount_amount_on_total = 0
        total_discount = 0
        item_values = {}
        discount_by_line_number = {}
        for sale_line in order.sale_lines:
            if not restriction.validate(sale_line) or (group_quantity is not None and group_quantity == 0):
                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 = sale_line.unit_price * quantity_to_be_removed
                    if not apply_on_total:
                        max_value_to_apply = sale_line.current_unit_price * quantity_to_be_removed
                        total_value_to_remove = min(total_value_to_remove, max_value_to_apply)
                else:
                    total_value_to_remove = sale_line.current_unit_price * quantity_to_be_removed

                if not discount_by_group:
                    remaining_sale_line_qty -= quantity_to_be_removed
                    selected_discount["quantity"] -= quantity_to_be_removed
                    group_quantity = None if group_quantity is None else group_quantity - quantity_to_be_removed

                if use_percentage:
                    amount_to_add = total_value_to_remove * (selected_discount["amount"] / 100)
                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
                    group_quantity = None if group_quantity is None else group_quantity - quantity_to_be_removed

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

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

            amount = min(float(amount), self._get_order_max_discount_amount(order, restriction))
            message = "Applying discount by items: sale line amount - {} / {} / {}"
            self.logger.info(message.format(current_discount, sale_line.key, amount))
            if amount > 0:
                line_num_level = "{}.{}".format(
                    sale_line.line_number,
                    sale_line.level
                )
                if line_num_level in item_values.keys():
                    item_values[line_num_level] += amount
                else:
                    item_values[line_num_level] = amount

                if apply_on_total:
                    discount_amount_on_total += amount
                else:
                    discount_id = usable_discount_ids.pop()
                    self._apply_discount(amount, apply_on_total, discount_id, sale_line)
                    applied_discounts.append(discount_id)
                    discount_by_line_number[discount_id] = item_values
                    item_values = {}

            total_discount += amount

        if discount_amount_on_total > 0:
            max_discount_amount = self._get_order_max_discount_amount(order, restriction)
            if discount_amount_on_total > max_discount_amount:
                discount_amount_on_total = max_discount_amount

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

        self.logger.info("Applying discount by items: final total discount - {}".format(total_discount))
        if total_discount > 0:
            return ActionResult(applied_discounts, {}, [], discount_by_line_number, True, False)
        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, order, order_tender_ids, restriction, tender_id)
            else:
                self._apply_payment_on_order(entry, order, order_tender_ids, restriction, tender_id, use_percentage)

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

    def _apply_payments_by_items(self, entry, order, order_tender_ids, restriction, tender_id):
        quantity = self._get_quantity_to_apply_discount(order, restriction.benefit.conditions)
        if not quantity:
            return

        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 = available_quantity * sale_line.item_price
                order_tender_id = self._do_order_tender(order.pos_id, tender_id, amount)
                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
                    )

                quantity -= available_quantity
                if quantity == 0:
                    break

    def _apply_payment_on_order(self, entry, order, order_tender_ids, restriction, tender_id, use_percentage):
        amount = self._get_amount(entry, order, restriction, use_percentage)
        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, apply_on_total, discount_id, sale_line):
        if apply_on_total:
            self.pos_ot.applyDiscount(discount_id, amount, forcebyitem=1)
        else:
            line_number = sale_line.line_number
            context = sale_line.context
            level = sale_line.level
            item_id = sale_line.item_id
            self.pos_ot.applyDiscount(discount_id, amount, line_number, context, level, item_id)

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

        amount = 0
        discount_by_line_number = self._split_discount_by_sale_line(order, restriction, "999", 100)
        total_gross = order.total_gross
        for sale_line in order.sale_lines:
            line_num_level = "{}.{}".format(
                sale_line.line_number,
                sale_line.level
            )
            if line_num_level not in discount_by_line_number["999"]:
                continue

            tip = order.tip * (sale_line.item_price / total_gross)
            amount += (sale_line.item_price - self._get_applied_discount_values(order, sale_line) + tip)

        return amount

    @staticmethod
    def _get_applied_discount_values(order, sale_line):
        # type: (Order, SaleLine) -> float

        amount = 0
        if sale_line.level != "0":
            return 0

        for benefit in order.applied_benefits:
            if not benefit.discount_item_values:
                continue

            for value in benefit.discount_item_values.itervalues():
                line_num_level = "{}.{}".format(
                    sale_line.line_number,
                    sale_line.level
                )
                if line_num_level not in value:
                    continue

                amount += float(value[line_num_level])

        return amount

    def _get_total_gross_amount(self, order, restriction):
        # type: (Order, Restrictions) -> float

        if not restriction.exists():
            return float(order.total_gross + order.tip)

        return self._get_total_amount_with_restrictions(order, restriction)

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

        order_xml = order.order_picture

        if order_xml is None:
            return 0

        due_total_amount = float(order_xml.get("dueAmount", 0))

        if not restriction.exists():
            due_amount = due_total_amount
        else:
            due_amount = self._get_total_amount_with_restrictions(order, restriction)

        if due_total_amount > due_amount + 0.01:
            return due_amount

        return due_amount - 0.01

    def _get_items_to_apply_discount(
            self,
            action,     # type: Action
            sale_lines  # type: List[SaleLine]
    ):
        # type: (...) -> Dict

        items_apply_discount = {}
        for entry in action.value:
            key = int(entry.get("item", -1))
            combos = [line for line in sale_lines if self._check_if_discount_apply_on_sons(str(key), line)]
            if len(combos) > 0:
                return self._get_sons_to_apply_discount(combos, sale_lines, entry, action)

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

        return items_apply_discount

    @staticmethod
    def _check_if_discount_apply_on_sons(
            item_id,    # type: str
            sale_line   # type: SaleLine
    ):
        # type: (...) -> bool

        item_price = sale_line.item_price
        return sale_line.item_id == item_id and sale_line.item_type == CompositionType.COMBO and item_price == 0

    def _get_sons_to_apply_discount(
            self,
            combos,         # type: List[SaleLine]
            sale_lines,     # type: List[SaleLine]
            entry,          # type: Dict
            action,         # type: Action,
            group="",       # type: Optional[str],
    ):
        # type: (...) -> Dict

        items_apply_discount = {}
        for combo in combos:
            context_combo = "{}.{}".format(combo.context, combo.item_id)
            for line in sale_lines:
                is_a_combo_son = line.line_number == combo.line_number and line.context.startswith(context_combo)
                if not is_a_combo_son or line.item_price == 0:
                    continue

                item_id = int(line.item_id)
                items = self._manager_item_to_apply_discount(items_apply_discount, item_id, entry, action, group)
                items_apply_discount = items

        return items_apply_discount

    def _get_items_by_groups_to_apply_discount(self, action, sale_lines, use_percentage=False):
        # type: (Action, List[SaleLine], bool) -> (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)
                combos = [line for line in sale_lines if self._check_if_discount_apply_on_sons(str(key), line)]
                if len(combos) > 0:
                    items = self._get_sons_to_apply_discount(combos, sale_lines, entry, action, group)
                    items_groups_to_apply_discount.update(items)
                    continue
                items = self._manager_item_to_apply_discount(items_groups_to_apply_discount, key, entry, action, 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,
            action,                   # type: Action,
            group="",                 # type: Optional[str],
    ):
        # type: (...) -> Dict

        amount = float(entry.get("amount", entry.get("percentage", 0)))
        quantity = int(entry.get("quantity", 0))
        quantity = min(action.times_to_apply, quantity) if action.times_to_apply else quantity

        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, entry, order, restriction, use_percentage):
        order.get_table_info()
        total_gross = self._get_total_gross_amount(order, restriction)
        if use_percentage:
            amount = float(entry.get("percentage", 0))
            if amount == 0:
                raise InvalidPercentageException()

            amount = total_gross * (amount / 100)
        else:
            amount = float(entry.get("amount", 0))
            if amount == 0:
                raise InvalidAmountException()

        return min(amount, total_gross)

    @staticmethod
    def _get_quantity_to_apply_discount(order, conditions):
        # type: (Order, Conditions) -> 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 order.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))
            return None if order_tender_id == 0 else order_tender_id

    @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_last_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 discount_id, line_detail in benefits.get("discountItemValues", {}).items():
            for line_num_level, amount in line_detail.items():
                if line_num_level not in sale_line_discounts.keys():
                    sale_line_discounts[line_num_level] = amount
                else:
                    sale_line_discounts[line_num_level] += amount

        return sale_line_discounts

    def _split_discount_by_sale_line(self, order, restriction, discount_id, discount_amount):
        # type: (Order, Restrictions, str, float) -> Dict

        last_sale_line_discounts = self._get_last_sale_line_discounts(order)

        discount_by_line_number = {discount_id: {}}
        valid_sale_lines = {}
        total_amount = 0.0
        for sale_line in order.sale_lines:
            if restriction.validate(sale_line):
                line_num_level = "{}.{}".format(
                    sale_line.line_number,
                    sale_line.level
                )
                price = sale_line.unit_price * sale_line.qty
                if last_sale_line_discounts.get(line_num_level, 0.0) >= price:
                    continue
                total_amount += price
                valid_sale_lines[line_num_level] = price

        if len(order.sale_lines) == len(valid_sale_lines.keys()):
            return {}
        else:
            for key, line_amount in valid_sale_lines.items():
                sale_line_amount = round((discount_amount * (line_amount / total_amount)), 2)
                discount_by_line_number[discount_id][key] = sale_line_amount

        return discount_by_line_number
