import base64
import importlib
import json
from datetime import date
from typing import (
    Dict,
    List,
    Optional,
)
from xml.etree import cElementTree as ElementTree
from xml.etree.ElementTree import Element

from messagebus import (
    ApplicationErrorMessage,
    DataType,
    DefaultToken,
    Message,
    MessageBus,
    Timeout,
)
from pos_control_api.shared import (
    PosModel,
    PosUser,
    UserSession,
)
from .shared.exception import (
    DialogTimeout,
    InvalidInitialFloat,
    InvalidXml,
    UnknownError,
    UnmappedApplicationError,
)
from .shared.token import (
    TK_POS_BUSINESS_BEGIN,
    TK_POS_BUSINESS_END,
    TK_POS_DIALOGRESP,
    TK_POS_GET_MODEL,
    TK_POS_GET_POS_LIST,
    TK_POS_GETCONFIG,
    TK_POS_GETSTATE,
    TK_POS_LIST_USERS,
    TK_POS_SET_MODEL_CUSTOM,
    TK_POS_SHOW_DIALOG,
    TK_POS_UPDATEDIALOG,
    TK_POS_USER_LOGIN,
    TK_POS_USER_LOGOUT,
    TK_POS_USER_OPEN,
    TK_POS_USER_PAUSE,
    TK_POS_EXECUTEACTION,
    TK_POS_ASYNCACTION,
    TK_POS_GET_USER_SESSIONS_BY_PERIOD,
    TK_POS_GET_SERVICE_PERIODS,
    TK_POS_START_SERVICE_PERIOD,
    TK_POS_STOP_SERVICE_PERIOD,
)

TIMEOUT = Timeout(seconds=30)
CONFIG_TIMEOUT = Timeout(seconds=5)


class PosController(object):
    def __init__(
        self,
        message_bus,  # type: MessageBus
        component_name="PosController",  # type: str
    ):
        # type: (...) -> None

        self.message_bus = message_bus
        self.component_name = component_name
        self._config_cache = {}

    def get_pos_list(self):
        # type: (...) -> List[int]

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_GET_POS_LIST,
                timeout=TIMEOUT,
            ),
        )

        return list(map(int, reply.data.decode("utf-8").split("\0")))

    def get_model(
        self,       # type: PosController
        pos_id,     # type: int
    ):
        # type: (...) -> PosModel

        model_xml = self.get_model_xml(pos_id=pos_id)
        return PosModel.from_xml(xml=model_xml)

    def get_model_str(
        self,                   # type: PosController
        pos_id,                 # type: int
    ):
        # type: (...) -> str

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_GET_MODEL,
                data=str(pos_id).encode("utf-8"),
                timeout=TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)
        return reply.data.decode("utf-8")

    def get_model_xml(
        self,                   # type: PosController
        pos_id,                 # type: int
    ):
        # type: (...) -> Element

        model_str = self.get_model_str(pos_id=pos_id)
        return ElementTree.XML(model_str)

    def open_day(
        self,                   # type: PosController
        pos_id,                 # type: int
        business_period=None,   # type: Optional[date]
    ):
        # type: (...) -> PosModel

        data = str(pos_id)
        if business_period is not None:
            data += "\0{}".format(business_period.strftime("%Y%m%d"))

        data = data.encode("utf-8")

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_BUSINESS_BEGIN,
                data=data,
                data_type=DataType.param,
                timeout=TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)
        return PosModel.from_xml(xml=ElementTree.XML(reply.data.decode("utf-8")))

    def close_day(
        self,                   # type: PosController
        pos_id                  # type: int
    ):
        # type: (...) -> PosModel

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_BUSINESS_END,
                data=str(pos_id).encode("utf-8"),
                data_type=DataType.param,
                timeout=TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)
        return PosModel.from_xml(xml=ElementTree.XML(reply.data.decode("utf-8")))

    def open_user(
        self,                   # type: PosController
        pos_id,                 # type: int
        user,                   # type: PosUser
        initial_float=0,        # type: Optional[float]
    ):
        # type: (...) -> PosModel

        if initial_float is not None and initial_float < 0:
            raise InvalidInitialFloat()

        data = "{}\0{}\0{}\0{}\0{}".format(pos_id, user.id, initial_float, user.name, user.level)

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_USER_OPEN,
                data=data.encode("utf-8"),
                data_type=DataType.param,
                timeout=TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)
        return PosModel.from_xml(xml=ElementTree.XML(reply.data.decode("utf-8")))

    def logout_user(
        self,                   # type: PosController
        pos_id,                 # type: int
        user_id=None,           # type: Optional[int]
    ):
        # type: (...) -> PosModel

        data = "{}".format(pos_id)
        if user_id is not None:
            data += "\0{}".format(user_id)

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_USER_LOGOUT,
                data=data.encode("utf-8"),
                data_type=DataType.param,
                timeout=TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)
        return PosModel.from_xml(xml=ElementTree.XML(reply.data.decode("utf-8")))

    def login_user(
        self,                   # type: PosController
        pos_id,                 # type: int
        user_id,                # type: int
    ):
        # type: (...) -> PosModel

        data = "{}\0{}".format(pos_id, user_id)

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_USER_LOGIN,
                data=data.encode("utf-8"),
                data_type=DataType.param,
                timeout=TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)
        return PosModel.from_xml(xml=ElementTree.XML(reply.data.decode("utf-8")))

    def pause_user(
        self,                   # type: PosController
        pos_id,                 # type: int
        user_id=None,           # type: Optional[int]
    ):
        # type: (...) -> PosModel

        data = "{}".format(pos_id)
        if user_id is not None:
            data += "\0{}".format(user_id)

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_USER_PAUSE,
                data=data.encode("utf-8"),
                data_type=DataType.param,
                timeout=TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)
        return PosModel.from_xml(xml=ElementTree.XML(reply.data.decode("utf-8")))

    def async_execute_action(
        self,               # type: PosController
        pos_id,             # type: int
        action_name,        # type: str
        timeout=None,       # type: Optional[Timeout]
        params=None,        # type: Optional[List[str]]
    ):
        # type: (...) -> int

        response = self.execute_action(
            pos_id=pos_id,
            action_name=action_name,
            timeout=timeout,
            params=params,
            async_mode=True
        )

        return int(response)

    def execute_action(
        self,               # type: PosController
        pos_id,             # type: int
        action_name,        # type: str
        timeout=None,       # type: Optional[Timeout]
        params=None,        # type: Optional[List[str]]
        async_mode=False,   # type: Optional[bool]
    ):
        # type: (...) -> str

        payload = [action_name, str(pos_id)]
        if params is not None and len(params) > 0:
            payload.extend(list(params))
        data = "\0".join(payload).encode("utf-8")

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_ASYNCACTION if async_mode else TK_POS_EXECUTEACTION,
                data=data,
                data_type=DataType.param,
                timeout=TIMEOUT if timeout is None else timeout,
            ),
        )

        if reply.token != DefaultToken.TK_SYS_ACK:
            self._handle_response(reply=reply)

        return reply.data.decode()

    def list_users(
        self,                   # type: PosController
        pos_id,                 # type: int
    ):
        # type: (...) -> List[UserSession]

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_LIST_USERS,
                data=str(pos_id).encode("utf-8"),
                timeout=TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)
        return_xml = ElementTree.XML(reply.data.decode("utf-8"))

        return [UserSession.from_xml(user) for user in return_xml.iter("User")]

    def set_model_custom(
        self,                   # type: PosController
        pos_id,                 # type: int
        key,                    # type: str
        value,                  # type: str
        persist=False,          # type: bool
    ):
        # type: (...) -> PosModel

        data = "{}\0{}\0{}\0{}".format(pos_id, key, value, str(persist).lower())

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_SET_MODEL_CUSTOM,
                data=data.encode("utf-8"),
                data_type=DataType.param,
                timeout=TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)
        return PosModel.from_xml(xml=ElementTree.XML(reply.data.decode("utf-8")))

    def clear_model_custom(
        self,                   # type: PosController
        pos_id,                 # type: int
        key,                    # type: str
    ):
        # type: (...) -> PosModel

        return self.set_model_custom(
            pos_id=pos_id,
            key=key,
            value="",
            persist=True,
        )

    @staticmethod
    def _add_operator_fields(
        payload,        # type: Dict
        operator_id,    # type: Optional[int]
        operator_name,  # type: Optional[str]
    ):
        # type: (...) -> None

        if operator_id is not None:
            payload["operatorId"] = operator_id

        if operator_name is not None:
            payload["operatorName"] = operator_name

    def get_service_periods(
        self,    # type: PosController
    ):
        # type: (...) -> List[Dict]

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_GET_SERVICE_PERIODS,
                timeout=TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)
        return json.loads(reply.data.decode("utf-8"))

    def start_service_period(
        self,                   # type: PosController
        service_period_id,      # type: int
        sectors=None,           # type: Optional[Dict]
        operator_id=None,       # type: Optional[int]
        operator_name=None,     # type: Optional[str]
        pos_ids=None,           # type: Optional[List[int]]
    ):
        # type: (...) -> None

        payload = {
            "servicePeriodId": service_period_id,
            "sectors": sectors if sectors is not None else {},
        }
        self._add_operator_fields(
            payload=payload,
            operator_id=operator_id,
            operator_name=operator_name,
        )
        if pos_ids is not None:
            payload["posIds"] = pos_ids

        data = json.dumps(payload).encode("utf-8")

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_START_SERVICE_PERIOD,
                data=data,
                data_type=DataType.param,
                timeout=TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)

    def stop_service_period(
        self,                   # type: PosController
        operator_id=None,       # type: Optional[int]
        operator_name=None,     # type: Optional[str]
        pos_ids=None,           # type: Optional[List[int]]
        service_period_id=None,  # type: Optional[int]
    ):
        # type: (...) -> None

        payload = {}
        self._add_operator_fields(
            payload=payload,
            operator_id=operator_id,
            operator_name=operator_name,
        )
        if pos_ids is not None:
            payload["posIds"] = pos_ids
        if service_period_id is not None:
            payload["servicePeriodId"] = service_period_id

        data = json.dumps(payload).encode("utf-8")

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_STOP_SERVICE_PERIOD,
                data=data,
                data_type=DataType.param,
                timeout=TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)

    def show_asynch_dialog(
        self,                   # type: PosController
        pos_id,                 # type: int
        xml,                    # type: str
        timeout,                # type: int
    ):
        # type: (...) -> int

        dialog_id = self.show_dialog(
            pos_id=pos_id,
            xml=xml,
            timeout=timeout,
            async_mode=True
        )
        return int(dialog_id)

    def show_dialog(
        self,                   # type: PosController
        pos_id,                 # type: int
        xml,                    # type: str
        timeout,                # type: int
        async_mode=False,       # type: Optional[bool]
    ):
        # type: (...) -> str

        data = "\0".join([
            str(pos_id),
            xml,
            str(timeout),
            "true" if async_mode else "false",
        ])

        message_timeout = Timeout(milliseconds=timeout + 5000)
        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_SHOW_DIALOG,
                data=data.encode("utf-8"),
                data_type=DataType.param,
                timeout=TIMEOUT if async_mode else message_timeout,
            ),
        )

        if reply.token == DefaultToken.TK_SYS_NAK:
            raise DialogTimeout()

        self._handle_response(reply=reply)
        return reply.data.decode("utf-8")

    def set_dialog_response(
        self,                   # type: PosController
        pos_id,                 # type: int
        dialog_id,              # type: int
        response_xml,           # type: str
    ):
        # type: (...) -> None

        data = "\0".join([
            str(pos_id),
            str(dialog_id),
            response_xml,
        ])

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_DIALOGRESP,
                data=data.encode("utf-8"),
                data_type=DataType.param,
                timeout=TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)

    def update_dialog(
        self,                   # type: PosController
        pos_id,                 # type: int
        dialog_id,              # type: int
        xml,                    # type: str
    ):
        # type: (...) -> int

        try:
            ElementTree.fromstring(xml)
        except ElementTree.ParseError:
            raise InvalidXml(xml)

        data = "\0".join([
            str(pos_id),
            str(dialog_id),
            xml,
        ])

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_UPDATEDIALOG,
                data=data.encode("utf-8"),
                data_type=DataType.param,
                timeout=TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)
        return int(reply.data.decode("utf-8"))

    def close_all_dialogs(
        self,                   # type: PosController
        pos_id,                 # type: int
    ):
        # type: (...) -> None

        data = "\0".join([
            str(pos_id),
            str(-1),
        ])

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_DIALOGRESP,
                data=data.encode("utf-8"),
                data_type=DataType.param,
                timeout=TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)

    def get_config(
        self,                   # type: PosController
        pos_id,                 # type: int
    ):
        # type: (...) -> str

        if pos_id in self._config_cache:
            return self._config_cache[pos_id]

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_GETCONFIG,
                data=str(pos_id).encode("utf-8"),
                timeout=CONFIG_TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)
        self._config_cache[pos_id] = reply.data.decode("utf-8")
        return self._config_cache[pos_id]

    def get_state(
        self,                   # type: PosController
        pos_id,                 # type: int
    ):
        # type: (...) -> dict

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_GETSTATE,
                data=str(pos_id).encode("utf-8"),
                timeout=TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)

        response_parts = reply.data.decode("utf-8").split("\0")
        if len(response_parts) < 6:
            raise ValueError("Invalid get_state response: expected 6 parts, got {}".format(len(response_parts)))

        state_data = {
            "business_period": response_parts[0],
            "pos_state": response_parts[1],
            "operator_id": response_parts[2],
            "operator_state": response_parts[3],
            "user_count": response_parts[4],
            "session_id": response_parts[5]
        }

        return state_data

    def get_user_sessions_by_period(
        self,                   # type: PosController
        business_period,        # type: date
    ):
        # type: (...) -> List[UserSession]

        period_str = business_period.strftime("%Y%m%d")

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_GET_USER_SESSIONS_BY_PERIOD,
                data=period_str.encode("utf-8"),
                timeout=TIMEOUT,
            ),
        )

        self._handle_response(reply=reply)
        if not reply.data:
            return []

        response = []
        xml = ElementTree.XML(base64.b64decode(reply.data).decode("utf-8"))
        for node in xml.findall(".//Operator"):
            response.append(UserSession.from_xml(node))

        return response

    def _handle_response(
        self,     # type: PosController
        reply,    # type: Message
    ):
        # type: (...) -> None

        if reply.token == DefaultToken.TK_SYS_ACK:
            return

        if reply.token == DefaultToken.TK_SYS_APPLICATION_ERROR:
            error_msg = ApplicationErrorMessage.build_from_message(message=reply)
            extra = getattr(error_msg, "extra", None)
            if extra is None or "type" not in extra:
                raise UnmappedApplicationError(
                    error_code=error_msg.error_code,
                    error_message=error_msg.error_message,
                )

            raise self._build_application_exception_from_error_message(
                error_msg=error_msg,
                extra=extra,
            )

        raise UnknownError(
            token=reply.token,
            data=reply.data,
        )

    @staticmethod
    def _build_application_exception_from_error_message(
        error_msg,    # type: ApplicationErrorMessage
        extra,        # type: dict
    ):
        # type: (...) -> Exception

        extra = dict(extra)
        ex_type_name = extra.pop("type")
        parts = ex_type_name.split(".")
        modules = parts[:-1]
        class_name = parts[-1]
        module = importlib.import_module(".".join(modules))
        return getattr(module, class_name)(**extra)
