import base64
import logging
import time
from datetime import datetime, timedelta
from logging import Logger
from threading import Lock, Condition, Thread

from mw_helper import ensure_list
from xml.etree import cElementTree as eTree

from messagebus import (
    MessageBus,
    TokenPriority,
    TokenCreator,
    Event,
)
from production.box import ProductionBox
from production.command import (
    ProductionCommandProcessor,
    ToggleTagLinesCommand,
    ChangeProdStateCommand,
    UndoProductionCommand,
    AddTagLinesCommand,
    OrderModifiedCommand,
    PublishOrderCommand,
    ProductionCommand,
    PurgeOrdersCommand,
    DevicesUpdatedCommand,
)
from production.command.undo import (
    UndoCommand,
    TagLinesUndoCommand,
    ChangeProdStateUndoCommand,
    TagLinesUndoCommandType,
)
from production.model import OrderChanger
from production.model import (
    ProductionOrder,
    ProdStates,
)
from production.repository import ProductionRepository
from production.util import handle_datetime_str
from production.view import OrderXml
from typing import (
    List,
    Dict,
    Any,
    Optional,
    Callable,
)
from ._CommandQueue import CommandQueue
from ._PublishScheduler import PublishScheduler

performance_event_logger = logging.getLogger("ProductionPerformanceCommand")


MSGGRP_KDS = '0B'
TK_KDS_UPDATE_STATISTICS = TokenCreator.create_token(TokenPriority.high, MSGGRP_KDS, "21")


class ProductionManager(ProductionCommandProcessor, PublishScheduler, OrderChanger):
    def __init__(
            self,
            production_repository,  # type: ProductionRepository
            purge_interval,         # type: int
            orders_life_time,       # type: int
            message_bus,            # type: MessageBus
            audit_prod_states,      # type: List
            kds_manager,            # type: str
            logger                  # type: Logger
    ):
        # type: (...) -> None

        self.production_repository = production_repository
        self.purge_interval = purge_interval
        self.orders_life_time = orders_life_time
        self.message_bus = message_bus
        self.logger = logger
        self.audit_logger_prod_states = audit_prod_states
        self.kds_manager = kds_manager

        self.boxes = []  # type: List[ProductionBox]
        self.orders = {}  # type: Dict[str, ProductionOrder]
        self.undo_lock = Lock()
        self.undo_view_map = {}  # type: Dict[str, List[UndoCommand]]
        self.waiters = {}  # type: Dict[str, ProductionManager.PublishWaiter]
        self.waiter_lock = Lock()
        self.purge_waiter = None  # type: Optional[ProductionManager.PurgeWaiter]
        self.statistics = {}  # type: Dict[str, Any]
        self.view_boxes = {}

        self.command_queue = CommandQueue(self, self.logger)

    def remove_tags(self, items):
        for item in items:
            item.tags = {}
            item.tag_history = []

            self.remove_tags(item.items)

    def initialize(self):
        self.purge_waiter = ProductionManager.PurgeWaiter(self, self.logger)
        self.purge_waiter.purge_orders_by_life_time()
        self._wait_purging_orders()

        init_orders = self.production_repository.get_all_orders()
        self.logger.info("Loading {} ORDERS from Database... Please Wait...".format(len(init_orders)))

        for order in init_orders:
            order.first_processing = False
            for undo_command in order.undo_list:  # type: UndoCommand
                self.enqueue_undo_command(undo_command.view, undo_command)
            self.orders[order.order_id] = order
            self.sync_publish_order(order.order_id)

        self.purge_waiter.purge_orders_and_wait()

    def purge_orders(
        self,           # type: ProductionManager
        order_ids,      # type: List[str]
    ):
        # type: (...) -> None

        self.purge_waiter = ProductionManager.PurgeWaiter(self, self.logger)
        self.purge_waiter.purge_orders_by_id(
            order_ids=order_ids
        )

    @staticmethod
    def _wait_purging_orders():
        time.sleep(5)

    def set_root_boxes(self, boxes):
        # type: (List[ProductionBox]) -> None
        self.boxes = boxes
        for box in self.boxes:
            box.parent = self

    def set_views(self, view_boxes):
        # type: (List) -> None

        for box in view_boxes:
            self.view_boxes[box.get_view_name()] = box

    def process_command(
        self,
        command,        # type: ProductionCommand
        queue_size=0,   # type: Optional[int]
    ):
        # type: (...) -> None

        command_name = type(command).__name__
        order_id = "-"
        try:
            start_time = datetime.now()
            tree_box_count = self.inner_process_command(command)

            if command.order_id is not None:
                order_id = command.order_id.split("-")[0]

            self._performance_log(
                command=command,
                command_name=command_name,
                order_id=order_id,
                start_time=start_time,
                tree_box_count=tree_box_count,
                queue_size=queue_size,
            )
        except Exception as ex:
            performance_event_logger.exception("Error executing command {} for order {}".format(command_name, order_id))
            raise ex

    @staticmethod
    def _performance_log(
        command,            # type: Any
        command_name,       # type: str
        order_id,           # type: int
        start_time,         # type: datetime
        tree_box_count,     # type: Any[Dict, Any]
        queue_size,         # type: int
    ):
        # type: (...) -> None

        end_time = datetime.now()
        time_delta = (end_time - start_time).total_seconds()
        message = "Queue: {:2} -- {:>25} / {:>10} / {:>8.3f}s".format(queue_size, command_name, order_id, time_delta)
        if hasattr(command, 'order') and command.order is not None:
            last_order_update_state_str = handle_datetime_str(command.order.state_history[-1].timestamp)
            last_order_update_state = datetime.strptime(last_order_update_state_str, "%Y-%m-%dT%H:%M:%S.%f")
            last_order_update_state_difference = (start_time - last_order_update_state).total_seconds()

            if isinstance(command, OrderModifiedCommand):
                message += " - Info: {:7} / {:>12} / {:>12} / {} / {} / {:>8.3f}s".format(
                    command.order.originator,
                    command.order.state,
                    command.order.prod_state,
                    command.order.recalled,
                    command.order.state_history[-1].timestamp,
                    last_order_update_state_difference,
                )

        if tree_box_count is not None:
            tree_messages = []
            total_boxes = 0
            for key, value in tree_box_count.iteritems():
                tree_messages.append("{} - {:3}".format(key, str(value)))
                total_boxes += value

            message += " / " + " / ".join(tree_messages) + " / " + str(total_boxes)

        performance_event_logger.info(message)

    def inner_process_command(self, command):
        # type: (ProductionCommand) -> Optional[Dict]

        order = None
        if command.order_id is not None:
            command.order_id = command.order_id.split("-")[0]

        if isinstance(command, OrderModifiedCommand):
            self._handle_order_modified_command(command)
            return

        elif isinstance(command, PublishOrderCommand):
            tree_box_count = self.sync_publish_order(command.order_id)
            return tree_box_count

        elif isinstance(command, PurgeOrdersCommand):
            self.handle_purge_orders_command(command=command)
            return

        if command.order_id is not None and command.order_id not in self.orders:
            if command.view in self.view_boxes:
                current_view = self.view_boxes[command.view]
                current_view.delete_order(command.order_id)
                current_view.reload_view()

            return

        if isinstance(command, ToggleTagLinesCommand) or isinstance(command, AddTagLinesCommand):
            order = self.tag_lines(command)

        elif isinstance(command, ChangeProdStateCommand):
            order = self._handle_change_prod_state_command(command)
            if order is not None:
                self.publish_audit_logger_event(order)

        elif isinstance(command, UndoProductionCommand):
            order = self._handle_undo_production_command(command)

        elif isinstance(command, DevicesUpdatedCommand):
            self._handle_devices_updated()

        else:
            self.logger.error("[process_command] Unknown command received: {}".format(type(command)))

        if order is not None:
            self.production_repository.save_order(order)
            self.publish_order(order.order_id)

    def _handle_undo_production_command(self, command):
        try:
            view_undo_commands = self.undo_view_map[command.view]
            if len(view_undo_commands) == 0:
                return
        except KeyError:
            self.logger.info("No Undo Commands for the View: {}".format(command.view))
            return

        with self.undo_lock:
            next_available = None
            for undo_command in view_undo_commands:
                if undo_command.is_enabled():
                    next_available = undo_command
                    break

            if next_available is not None:
                view_undo_commands.remove(next_available)

        if next_available is None:
            return

        if next_available.order_id not in self.orders:
            self.logger.info("The order {} has no longer available".format(next_available.order_id))
            return

        order = self.orders[next_available.order_id]
        if order is None:
            return
        next_available.undo(order)

        return order

    def _handle_change_prod_state_command(self, command):
        order = self.orders[command.order_id]
        if order.prod_state == command.state:
            self.logger.debug("Order {} is already in prod state {}".format(command.order_id, command.state))
            return

        state_undo_command = ChangeProdStateUndoCommand(order.order_id, command.view, order.prod_state, [])
        self.add_undo_command_to_order_and_view(command.view, order, state_undo_command)

        order.prod_state = command.state
        return order

    def handle_purge_orders_command(
        self,
        command,    # type: PurgeOrdersCommand
    ):
        # type: (...) -> None

        self.logger.debug("Purging orders command start")
        self.production_repository.purge_orders(command.orders)
        cached_purged_orders = self._get_cached_purged_orders(command.orders)
        self.logger.debug("Syncing cached purged orders")
        for order in cached_purged_orders:
            self.sync_publish_order(order.order_id)
            self.orders.pop(order.order_id, None)

    def _handle_order_modified_command(self, command):
        order = command.order
        if order.order_id in self.orders:
            saved_order = self.orders[order.order_id]
            if len(saved_order.state_history) > len(order.state_history):
                self.logger.info("Order Id {} ignored for state history".format(order.order_id))
                return

            order.tags = saved_order.tags
            order.prod_state = saved_order.prod_state
            order.selected_pickup_area = saved_order.selected_pickup_area
            order.first_processing = saved_order.first_processing

            self.update_order_items(order, saved_order)
            if hasattr(saved_order, "round_robin_path"):
                order.round_robin_path = saved_order.round_robin_path
        else:
            order.first_processing = True

        self.orders[order.order_id] = order
        self.production_repository.save_order(order)

        self.delayed_publish_order(order.order_id)

    def _handle_devices_updated(self):
        # type: (...) -> None

        self.logger.info("Event DEVICES_UPDATED received, passing to all view boxes")
        for view_box in self.view_boxes.values():
            view_box.handle_devices_status_changed()

    def order_modified(self, order):
        # type: (ProductionOrder) -> None

        self.command_queue.put(OrderModifiedCommand(order.order_id, order))

    def update_order_items(self, order, saved_order):
        # type: (ProductionOrder, ProductionOrder) -> None
        for item in order.items:
            for saved_item in saved_order.items:
                if item.get_line_id() == saved_item.get_line_id():
                    self.add_saved_tags_to_item(item, saved_item)

    def add_saved_tags_to_item(self, item, saved_item):
        item.tags = saved_item.tags
        item.tag_history = saved_item.tag_history
        item.jit_lines = saved_item.jit_lines
        for son in item.items:
            for saved_son in saved_item.items:
                if son.get_line_id() == saved_son.get_line_id():
                    self.add_saved_tags_to_item(son, saved_son)

    def handle_command(self, command):
        self.command_queue.put(command)

    def schedule_publish(self, order_id, wait_time):
        if order_id is not None:
            order_id = str(order_id)
            order_id = order_id.split("-")[0]

        if wait_time >= self.orders_life_time * 60:
            return

        wait_time = max(wait_time, 0)
        publish_waiter = ProductionManager.PublishWaiter(self, order_id, wait_time)
        waiter_to_deactivate = None
        with self.waiter_lock:
            if order_id in self.waiters:
                publish_time = min(self.waiters[order_id].date_limit, publish_waiter.date_limit)
                due_limite = (publish_time - datetime.now()).total_seconds()
                message = "Saved a Publish Order on Order ID {} - Will be Publish in {}"
                performance_event_logger.info(message.format(order_id, due_limite))

                if self.waiters[order_id].date_limit > publish_waiter.date_limit:
                    waiter_to_deactivate = self.waiters[order_id]
                    del self.waiters[order_id]
                    self.waiters[order_id] = publish_waiter
                else:
                    return
            else:
                self.waiters[order_id] = publish_waiter

        if waiter_to_deactivate is not None:
            waiter_to_deactivate.deactivate()

        publish_waiter.wait_and_publish()

    def remove_schedule_publish(self, order_id):
        # type: (str) -> None

        if order_id is not None:
            order_id = str(order_id)
            order_id = order_id.split("-")[0]

        if order_id in self.waiters:
            self.waiters[order_id].deactivate()
            del self.waiters[order_id]

    def change_order(self, order_id, order_changer):
        # type: (str, Callable[[ProductionOrder], bool]) -> None

        if order_id is not None:
            order_id = str(order_id)

        order_id = order_id.split("-")[0]

        if order_id in self.orders:
            order = self.orders[order_id]
            changed_order = order_changer(order)

            if changed_order:
                self.production_repository.save_order(order)
                self.publish_order(order.order_id)

    def set_all_items(self, item_view, item_save, item_setter):
        def get_diff_key(x):
            return '{}-{}-{}'.format(x.line_number, x.item_id, x.part_code)

        if item_save.is_product():
            if get_diff_key(item_save) == get_diff_key(item_view):
                return item_setter(item_save)
            return False

        were_change = False
        for son in item_save.items:
            if self.set_all_items(item_view, son, item_setter):
                were_change = True

        return were_change

    def change_items(self, order, item_changer):
        order_id = order.order_id.split("-")[0]
        saved_order = self.orders[order_id]
        was_change = False

        for view_item in order.items:
            for saved_item in saved_order.items:
                if self.set_all_items(view_item, saved_item, item_changer):
                    was_change = True

        if was_change:
            self.production_repository.save_order(saved_order)

    def tag_lines(self, command):
        order_id = command.order_id.split("-")[0]
        order = self.orders[order_id]
        self.change_order_tags(command, order)
        self.create_undo_command(command, order)
        return order

    @staticmethod
    def change_order_tags(command, order):
        tags = ensure_list(command.tag)

        if isinstance(command, ToggleTagLinesCommand):
            command_callable = order.toggle_line_tag
        else:
            command_callable = order.add_line_tag

        for tag in tags:
            for line_id in command.line_ids:
                line_jit_list = command.line_ids_jit_lines.get(line_id) if command.line_ids_jit_lines else None
                command_callable(line_id, tag, line_jit_list)

    def create_undo_command(self, command, order):
        undo_command = self.build_undo_command(command, order)
        self.add_undo_command_to_order_and_view(command.view, order, undo_command)

    def build_undo_command(self, command, order):
        disabled_commands = self.get_disabled_commands(command, order)
        if isinstance(command, ToggleTagLinesCommand):
            undo_type = TagLinesUndoCommandType.toggle
        else:
            undo_type = TagLinesUndoCommandType.add

        undo_command = TagLinesUndoCommand(
            order.order_id,
            command.view,
            command.tag,
            command.line_ids,
            undo_type,
            disabled_commands,
            command.line_ids_jit_lines
        )

        return undo_command

    @staticmethod
    def get_disabled_commands(command, order):
        disabled_commands = []
        if command.tag == "served":
            for undo_command in order.undo_list:
                if isinstance(undo_command, TagLinesUndoCommand) and undo_command.tag in ("doing", "done"):
                    undo_command.disable()
                    disabled_commands.append(undo_command)
        return disabled_commands

    def get_order_xml(self, order_id):
        if order_id not in self.orders:
            return None

        return OrderXml().to_xml(self.orders[order_id])

    def get_production_order(self, order_id):
        if order_id not in self.orders:
            return None

        return self.orders[order_id]

    def publish_order(self, order_id):
        if order_id not in self.orders:
            return

        self.command_queue.put(PublishOrderCommand(order_id))

    def delayed_publish_order(self, order_id):
        self.schedule_publish(order_id, 1)

    def sync_publish_order(self, order_id):
        with self.waiter_lock:
            if order_id in self.waiters:
                self.waiters[order_id].deactivate()
                del self.waiters[order_id]

        if order_id not in self.orders:
            self.logger.error("OrderId {} not in self.orders".format(order_id))
            return {}

        self.logger.debug(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>", extra={'simple': True})
        self.logger.debug("#region    - Publishing order: {}".format(order_id), extra={'simple': True})

        order = self.orders[order_id]
        tree_box_count = {}
        for box in self.boxes:
            try:
                self.logger.debug("#region    - {} @ {}".format(box.name, box.tree_name), extra={'simple': True})
                cloned_order = order.clone()
                box.order_modified(cloned_order)
                tree_box_count[box.tree_name] = cloned_order.box_count if hasattr(cloned_order, 'box_count') else 1
            except Exception as _:  # noqa
                self.logger.exception("Exception on box: {}".format(box.name))
            finally:
                self.logger.debug("#endregion - {} @ {}".format(box.name, box.tree_name), extra={'simple': True})

        final_message = "#endregion    - Finished publishing order: {}".format(order.order_id)
        self.logger.debug(final_message, extra={'simple': True})
        self.logger.debug("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<", extra={'simple': True})

        order.first_processing = False
        return tree_box_count

    def publish_command(self, command):
        handled = []
        for box in self.boxes:
            try:
                if box.handle_production_command(command):
                    handled.append(box)
            except Exception as _:  # noqa
                self.logger.exception("Exception on box: {}".format(box.name))

        return handled

    @staticmethod
    def add_tag_to_dict(tags, line_id, tag):
        if line_id not in tags:
            tags[line_id] = []
        tags[line_id].append(tag)

    def add_undo_command_to_order_and_view(self, view, order, undo_command):
        order.undo_list.append(undo_command)
        self.enqueue_undo_command(view, undo_command)

    def enqueue_undo_command(self, view, undo_command):
        with self.undo_lock:
            if view not in self.undo_view_map:
                self.undo_view_map[view] = []
            self.undo_view_map[view].insert(0, undo_command)

    def stop(self):
        if self.purge_waiter is not None:
            self.purge_waiter.deactivate()

    def _get_cached_purged_orders(self, orders_to_purge):
        # type: (List[str]) -> List[ProductionOrder]
        orders = []
        for order_id in orders_to_purge:
            str_order_id = str(order_id)
            if str_order_id in self.orders:
                order = self.orders[str_order_id]
                order.purged = True
                order.prod_state = ProdStates.INVALID
                orders.append(order)
        return orders

    def publish_audit_logger_event(self, order):
        if order.prod_state not in self.audit_logger_prod_states:
            return

        order_id = str(order.order_id)
        try:
            production_order = eTree.fromstring(ProductionManager.get_order_xml(self, order.order_id))
            production_order_xml = eTree.tostring(production_order)

            data = """<ProductionOrderEvent posId="{0}" businessPeriod="{1}" orderId="{2}">{3}</ProductionOrderEvent>"""
            msg_data = data.format(str(order.pos_id), str(order.business_day), order_id, production_order_xml)
            event = Event(subject="PRODUCTION_ORDER_EVENT", event_type="PRODUCTION_ORDER", data=msg_data)

            self.logger.info("Publishing AuditLog event: {} / {}".format(event.subject, event.event_type))
            self.message_bus.publish_event(event)
        except Exception as _:  # noqa
            self.logger.exception("Error publishing AuditLogger event for orderId#{}".format(order_id))

    class PublishWaiter(object):
        def __init__(self, production_manager, order_id, wait_time):
            self.production_manager = production_manager
            self.order_id = order_id
            self.wait_time = wait_time
            self.date_limit = datetime.now() + timedelta(seconds=self.wait_time)
            self.condition = Condition()
            self.active = True

        def wait_and_publish(self):
            t = Thread(target=self._wait_and_publish, name="PublishWaiter")
            t.daemon = True
            t.start()

        def deactivate(self):
            self.active = False
            with self.condition:
                self.condition.notify_all()

        def _wait_and_publish(self):
            with self.condition:
                self.condition.wait(self.wait_time)

            if self.active:
                self.production_manager.publish_order(self.order_id)

    class PurgeWaiter(object):
        def __init__(self, production_manager, logger):
            self.production_manager = production_manager
            self.logger = logger
            self.purge_interval = production_manager.purge_interval
            self.orders_life_time = production_manager.orders_life_time
            self.production_repository = production_manager.production_repository
            self.orders = production_manager.orders
            self.condition = Condition()
            self.active = True

        def deactivate(self):
            self.active = False
            with self.condition:
                self.condition.notify_all()

        def purge_orders_and_wait(self):
            t = Thread(target=self._purge_orders_and_wait, name="PurgeWaiter")
            t.daemon = True
            t.start()

        def _purge_orders_and_wait(self):
            while self.active:
                try:
                    self.logger.debug("Starting thread!")
                    self.purge_orders_by_life_time()

                    with self.condition:
                        sleep_time = self.purge_interval * 60
                        self.logger.debug("Sleeping for {} seconds".format(sleep_time))
                        self.condition.wait(sleep_time)
                except (Exception,):
                    self.logger.exception("Error purging old orders")

        def purge_orders_by_life_time(
            self
        ):
            # type: (...) -> None

            self.logger.info("Starting purge process by orders life time")

            limit_date = datetime.now() - timedelta(minutes=self.orders_life_time)
            orders_to_purge = self.production_repository.get_orders_to_purge(limit_date)
            self._purge_orders(
                orders_to_purge=orders_to_purge,
            )

        def purge_orders_by_id(
            self,
            order_ids=None,         # type: Optional[List[str]]
        ):
            # type: (...) -> None

            self.logger.info("Starting purge process by order id: {}".format(",".join(order_ids)))

            if order_ids is not None and len(order_ids) > 0:
                orders_to_purge = self.production_repository.get_orders_by_orders_ids(order_ids)
                orders_to_purge = [x.order_id for x in orders_to_purge]
            else:
                all_orders = self.production_repository.get_all_orders()
                orders_to_purge = [x.order_id for x in all_orders]

            self._purge_orders(
                orders_to_purge=orders_to_purge,
            )

        def _purge_orders(
            self,
            orders_to_purge,        # type: List[str]
        ):
            # type: (...) -> None

            self.logger.info("Found {} orders to purge".format(len(orders_to_purge)))
            if len(orders_to_purge) != 0:
                self.production_manager.handle_command(PurgeOrdersCommand(orders_to_purge))

    def save_pick_list_order(
        self,
        order,          # type: ProductionOrder
        receipt,        # type: str
        status,         # type: bool
    ):
        # type: (...) -> None

        order_id = order.order_id.split('-')[0]
        order_id_count = order.order_id if '-' in order.order_id else ''

        receipt_data = base64.b64encode(
            s=receipt,
        )

        self.production_repository.save_pick_list_order(
            order_id=order_id,
            order_id_count=order_id_count,
            jit=order.jit_printer_name,
            course=order.current_course,
            receipt=receipt_data,
            status=status,
        )

    def get_picklist_id(
        self,
        order,      # type: ProductionOrder
    ):
        # type: (...) -> Optional[int]

        order_id = order.order_id.split('-')[0]
        order_id_count = order.order_id if '-' in order.order_id else ''

        return self.production_repository.get_picklist_id(
            order_id=order_id,
            order_id_count=order_id_count,
            jit=order.jit_printer_name,
            course=order.current_course,
        )
