import json
import logging
import time

from datetime import datetime, timedelta
from logging import Logger
from threading import Lock, Condition, Thread, local
from xml.etree import cElementTree as eTree

from mw_helper import ensure_list
from production.box import ProductionBox
from production.command import ProductionCommandProcessor, ToggleTagLinesCommand, ChangeProdStateCommand, \
    UndoProductionCommand, AddTagLinesCommand, ChangePathCommand, OrderModifiedCommand, PublishOrderCommand, \
    ProductionCommand, PurgeOrdersCommand
from production.command.undo import UndoCommand, TagLinesUndoCommand, ChangeProdStateUndoCommand, \
    TagLinesUndoCommandType
from production.model import ProductionOrder, ProdStates
from production.repository import ProductionRepository
from production.view import OrderXml, KdsView
from messagebus import MessageBus, Message, DataType, TokenPriority, TokenCreator, Event
from typing import List, Dict, Any, Optional, Callable
from util import handle_datetime_str

from ._CommandQueue import CommandQueue
from ._OrderChanger import OrderChanger
from ._PublishScheduler import PublishScheduler
from ._StatisticUpdater import StatisticUpdater

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, StatisticUpdater):
    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.statistics_changed = local()
        self.statistics_changed.value = None
        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()
        self._wait_purging_orders()

        for order in self.production_repository.get_all_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)

        self.purge_waiter.purge_orders_and_wait()

    def purge_all_orders(self):
        self.purge_waiter = ProductionManager.PurgeWaiter(self, self.logger)
        self.purge_waiter.purge_orders(all_orders=True)

    @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):
        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_name, order_id, start_time, tree_box_count)
        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, command_name, order_id, start_time, tree_box_count):
        end_time = datetime.now()
        time_delta = (end_time - start_time).total_seconds()
        message = "{:>25} / {:>10} / {:>8.3f}s".format(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("{} - {}".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):
            tree_box_count = self._handle_order_modified_command(command)
            return tree_box_count

        elif isinstance(command, PublishOrderCommand):
            self.sync_publish_order(command.order)
            return

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

        elif isinstance(command, ChangePathCommand):
            self._handle_change_path_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)
            self.publish_audit_logger_event(order)

        elif isinstance(command, UndoProductionCommand):
            order = self._handle_undo_production_command(command)
        else:
            self.logger.error("[process_command] Unknown command received: {}".format(type(command)))

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

    def _handle_change_path_command(self, command):
        handled = self.publish_command(command)
        for box in handled:
            box.start_refresh()

        for box in handled:
            for order in self.orders.values():
                box.order_modified(order.clone())

        for box in handled:
            box.end_refresh()

    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]
        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
        self.statistics_changed.value = False
        self.send_statistics_update()
        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)
            self.orders.pop(order.order_id, None)

    def _handle_order_modified_command(self, command):
        order = command.order
        if order.order_id in self.orders:
            order.first_processing = False
            saved_order = self.orders[order.order_id]
            order.prod_state = saved_order.prod_state
            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.statistics_changed.value = False
        self.orders[order.order_id] = order
        self.production_repository.save_order(order)
        tree_box_count = self.sync_publish_order(order)
        self.send_statistics_update()

        return tree_box_count

    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
        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]

        order = self.orders[order_id]
        publish_waiter = ProductionManager.PublishWaiter(self, order, wait_time)
        start_thread = False
        waiter_to_deactivate = None
        with self.waiter_lock:
            if order.order_id in self.waiters:
                if self.waiters[order.order_id].wait_time > wait_time:
                    waiter_to_deactivate = self.waiters[order.order_id]
                    del self.waiters[order.order_id]
                    self.waiters[order.order_id] = publish_waiter
                    start_thread = True
            else:
                self.waiters[order.order_id] = publish_waiter
                start_thread = True

        if waiter_to_deactivate is not None:
            waiter_to_deactivate.deactivate()

        if start_thread:
            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, command_order=None):
        # type: (str, Callable, ProductionOrder) -> 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, command_order) if command_order else order_changer(order)
            if changed_order:
                self.production_repository.save_order(order)
                self.publish_order(order)

    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_tag
        else:
            command_callable = order.add_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):
        self.command_queue.put(PublishOrderCommand(order.order_id, order))

    def sync_publish_order(self, order):
        self.logger.info("Publishing order: {}".format(order.order_id))
        with self.waiter_lock:
            if order.order_id in self.waiters:
                self.waiters[order.order_id].deactivate()
                del self.waiters[order.order_id]

        tree_box_count = {}
        for box in self.boxes:
            try:
                self.logger.debug("\n#region    - {} @ {}".format(box.name, box.tree_name))
                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:  # noqa
                self.logger.exception("Exception on box: {}".format(box.name))
            finally:
                self.logger.debug("\n#endregion - {} @ {}".format(box.name, box.tree_name))

        self.logger.info("Finished publishing order: {}".format(order.order_id))

        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 update_statistics(self, order_id, stats):
        self.statistics_changed.value = True
        self.statistics.update(stats)
        self.send_statistics_update()

    def send_statistics_update(self):
        try:
            message = Message(TK_KDS_UPDATE_STATISTICS, DataType.json, json.dumps(self.statistics), 5000000)
            self.message_bus.send_message("NKDSCTRL", message)
        except BaseException:  # noqa
            pass

    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_TIMES", 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, wait_time):
            self.production_manager = production_manager
            self.order = order
            self.wait_time = 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)

    class PurgeWaiter(object):
        def __init__(self, production_manager, logger):
            self.production_manager = production_manager
            self.logger = logger
            self.wait_time = 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!")
                    with self.condition:
                        sleep_time = self.wait_time * 60
                        self.logger.debug("Sleeping for {} seconds".format(sleep_time))
                        self.condition.wait(sleep_time)

                    self.purge_orders()
                except BaseException as _:  # noqa
                    self.logger.exception("Error purging old orders")

        def purge_orders(self, all_orders=False):
            self.logger.info("Starting purge process...")

            if not all_orders:
                limit_date = datetime.now() - timedelta(minutes=self.orders_life_time)
                orders_to_purge = self.production_repository.get_orders_to_purge(limit_date)
            else:
                all_orders = self.production_repository.get_all_orders()
                orders_to_purge = [x.order_id for x in all_orders]

            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))
