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

from xml.etree import cElementTree as eTree
from xml.etree.ElementTree import Element

import iso8601
from messagebus import (
    MessageBus,
    Message,
    DataType,
    DefaultToken,
    TokenCreator,
    TokenPriority,
)

from .dto import (
    Operator,
    OperatorState,
    PosState,
    User,
    PosControlErrorCodes,
    PosModel,
    UserSession,
)
from .dto.exception import (
    DayNotClosed,
    DayNotOpened,
    PosControllerError,
    PosHasAnOpenOrder,
    UserAlreadyOpened,
    UserNotOpened,
    UserOpened,
)
from .token import (
    TK_POS_BUSINESSBEGIN,
    TK_POS_BUSINESSEND,
    TK_POS_GET_MODEL,
    TK_POS_GETCONFIG,
    TK_POS_LISTUSERS,
    TK_POS_USERLOGIN,
    TK_POS_USERLOGOUT,
    TK_POS_USEROPEN,
)


TK_CMP_TERM_NOW = TokenCreator.create_token(
    priority=TokenPriority.high,
    message_group="3",
    number="1"
)


def requires_model(f):
    def wrapper(*args, **kwargs):
        if args[0].model is None:
            args[0].model = args[0]._retrieve_new_model()

        return f(*args, **kwargs)

    return wrapper


class PosService(object):

    def __init__(
        self,               # type: PosService
        message_bus,        # type: MessageBus
        pos_id,             # type: int
        model=None,         # type: Optional[eTree.Element]
    ):
        # type: (...) -> None

        self.message_bus = message_bus
        self.pos_id = pos_id

        self.component_name = "POS{}".format(self.pos_id)

        self.pos_config_cache = {}  # type: Dict[str, Optional[str]]

        self.model = model

    def get_model(self):
        # type: (...) -> Element

        if self.model is None:
            self.model = self._retrieve_new_model()

        return self.model

    def get_updated_model(self):
        # type: (...) -> Element

        self.model = self._retrieve_new_model()
        return self.model

    @requires_model
    def get_operator_session(self):
        # type: (...) -> Optional[str]

        for op in self.model.findall("Operator"):
            if op.get("state") == "LOGGEDIN":
                return op.get("sessionId")

        return None

    @requires_model
    def get_operator(self):
        # type: (...) -> Optional[Operator]

        operator = self.model.find("Operator")
        if operator is None:
            return None

        level = operator.get("level")
        return Operator(
            id=int(operator.attrib["id"]),
            name=operator.attrib["name"],
            level=int(level) if level is not None else None,
            state=OperatorState(int(operator.attrib["stateCode"])),
            session_id=operator.attrib["sessionId"],
        )

    @requires_model
    def get_pod_type(self):
        # type: (...) -> str

        return self.model.find("WorkingMode").get("podType")

    @requires_model
    def get_pos_function(self):
        # type: (...) -> str

        return self.model.find("WorkingMode").get("posFunction")

    def get_hour_to_block_pos(
        self,    # type: PosService
    ):
        # type: (...) -> int

        hour_to_block_pos = self._get_pos_config_key(
            tag="[@name='hourToBlockPos']/integer",
        )
        return int(hour_to_block_pos or 0)

    def _get_pos_config_key(
        self,                 # type: PosService
        tag,                  # type: str
    ):
        # type: (...) -> Optional[str]

        if tag in self.pos_config_cache:
            return self.pos_config_cache[tag]

        message = Message(
            token=TK_POS_GETCONFIG,
            data_type=DataType.param,
        )
        response = self.message_bus.send_message(
            component=self.component_name,
            message=message,
        )
        if response.token != DefaultToken.TK_SYS_ACK:
            return None

        config = eTree.XML(response.data)
        complete_tag = "group[@name='POS']/group[@name='POS{}']/key{}".format(
            str(self.pos_id).zfill(2),
            tag,
        )
        tag_element = config.find(complete_tag)
        if tag_element is not None:
            self.pos_config_cache[tag] = tag_element.text
            return self.pos_config_cache[tag]

        self.pos_config_cache[tag] = None
        return self.pos_config_cache[tag]

    def _retrieve_new_model(self):
        # type: (...) -> Element

        msg = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_GET_MODEL,
                data_type=DataType.string,
                data=str(self.pos_id).encode()
            )
        )
        if msg.token != DefaultToken.TK_SYS_ACK:
            raise RuntimeError("Could not retrieve state of pos '{}'".format(self.pos_id))

        return eTree.XML(msg.data)

    def logout_user(
        self,               # type: PosService
        user_id,            # type: int
    ):
        # type: (...) -> None

        params = self._format_params(self.pos_id, user_id)

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_USERLOGOUT,
                data_type=DataType.param,
                data=params.encode(),
            )
        )

        if reply.token == DefaultToken.TK_SYS_ACK:
            return

        if reply.token == DefaultToken.TK_SYS_NAK:
            error_code = self._get_error_code(data=reply.data)

            if error_code == PosControlErrorCodes.SE_DAYNOTOPENED:
                raise DayNotOpened()

            if error_code == PosControlErrorCodes.SE_OPEROPENED:
                raise UserNotOpened()

            if error_code == PosControlErrorCodes.SE_ORDEROPENED:
                raise PosHasAnOpenOrder()

        raise self._build_pos_controller_error(
            err_msg="Error opening user id '{}' of pos '{}'".format(user_id, self.pos_id),
            data=reply.data,
        )

    def open_user(
        self,                       # type: PosService
        user_id,                    # type: int
        initial_float=0.0,          # type: float
        long_user_name=None,        # type: Optional[str]
        level=None,                 # type: Optional[int]
    ):
        # type: (...) -> None

        params_list = [self.pos_id, user_id, initial_float]
        if long_user_name is not None:
            params_list.append(long_user_name)

        if level is not None:
            params_list.append(level)

        params = self._format_params(*params_list)

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_USEROPEN,
                data_type=DataType.param,
                data=params.encode(),
            )
        )

        if reply.token == DefaultToken.TK_SYS_ACK:
            return

        if reply.token == DefaultToken.TK_SYS_NAK:
            error_code = self._get_error_code(data=reply.data)

            if error_code == PosControlErrorCodes.SE_DAYNOTOPENED:
                raise DayNotOpened()

            if error_code == PosControlErrorCodes.SE_OPEROPENED:
                raise UserAlreadyOpened()

        raise self._build_pos_controller_error(
            err_msg="Error opening user id '{}' of pos '{}'".format(user_id, self.pos_id),
            data=reply.data,
        )

    def login_user(
        self,                       # type: PosService
        user_id,                    # type: int
        long_user_name=None,        # type: Optional[str]
    ):
        # type: (...) -> None

        params_list = [self.pos_id, user_id]
        if long_user_name is not None:
            params_list.append(long_user_name)

        params = self._format_params(*params_list)

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_USERLOGIN,
                data_type=DataType.param,
                data=params.encode(),
            )
        )

        if reply.token == DefaultToken.TK_SYS_ACK:
            return

        if reply.token == DefaultToken.TK_SYS_NAK:
            error_code = self._get_error_code(data=reply.data)

            if error_code == PosControlErrorCodes.SE_DAYNOTOPENED:
                raise DayNotOpened()

            if error_code == PosControlErrorCodes.SE_OPEROPENED:
                raise UserNotOpened()

        raise self._build_pos_controller_error(
            err_msg="Error login user id '{}' of pos '{}'".format(user_id, self.pos_id),
            data=reply.data,
        )

    def close_business_day(
        self,
    ):
        # type: (...) -> None

        params = str(self.pos_id)

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_BUSINESSEND,
                data_type=DataType.param,
                data=params.encode(),
            )
        )

        if reply.token == DefaultToken.TK_SYS_ACK:
            return

        if reply.token == DefaultToken.TK_SYS_NAK:
            error_code = self._get_error_code(data=reply.data)

            if error_code == PosControlErrorCodes.SE_OPEROPENED:
                raise UserOpened()

        raise self._build_pos_controller_error(
            err_msg="Error closing day of pos '{}'".format(self.pos_id),
            data=reply.data,
        )

    def open_business_day(
        self,
        business_date,        # type: date
    ):
        # type: (...) -> None

        params = self._format_params(self.pos_id, business_date.strftime("%Y%m%d"))

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_BUSINESSBEGIN,
                data_type=DataType.param,
                data=params.encode(),
            )
        )

        if reply.token == DefaultToken.TK_SYS_ACK:
            return

        if reply.token == DefaultToken.TK_SYS_NAK:
            error_code = self._get_error_code(data=reply.data)

            if error_code == PosControlErrorCodes.SE_DAYNOTCLOSED:
                raise DayNotClosed()

        raise self._build_pos_controller_error(
            err_msg="Error opening day of pos '{}'".format(self.pos_id),
            data=reply.data,
        )

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

        params = str(self.pos_id)

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(
                token=TK_POS_LISTUSERS,
                data_type=DataType.param,
                data=params.encode(),
            )
        )

        if reply.token != DefaultToken.TK_SYS_ACK:
            raise self._build_pos_controller_error(
                err_msg="Error opening day of pos '{}'".format(self.pos_id),
                data=reply.data,
            )

        list_users_xml = eTree.XML(reply.data)
        users = list_users_xml.findall("User")
        return [self._xml_user_to_user_session(user) for user in users]

    def stop_component(
        self,
    ):
        # type: (...) -> None

        reply = self.message_bus.send_message(
            component=self.component_name,
            message=Message(token=TK_CMP_TERM_NOW)
        )

        if reply.token != TK_CMP_TERM_NOW:
            raise self._build_pos_controller_error(
                err_msg="Error stopping pos component '{}'".format(self.pos_id),
                data=reply.data,
            )

    def _xml_user_to_user_session(
            self,
            xml_user,  # type: Element
    ):
        # type: (...) -> UserSession

        level = xml_user.get("level")
        user = User(
            id=int(xml_user.attrib["id"]),
            name=xml_user.attrib["name"],
            level=int(level) if level is not None else None,
        )

        session_id = xml_user.get("sessionId", "")
        count = self._get_count_from_session_id(session_id=session_id) or 1

        return UserSession(
            user=user,
            state=OperatorState(int(xml_user.attrib["lastOpState"])),
            count=count,
            initial_float=float(xml_user.get("initialFloat")),
            session_id=session_id,
            open_time=xml_user.get("openTime"),
            close_time=xml_user.get("closeTime"),
        )

    def get_pos_model(self):
        xml_model = self.get_model()

        pos_state = PosState.UNDEFINED
        business_period = None
        state_xml = xml_model.find("./PosState")
        if state_xml is not None:
            pos_state = PosState(int(state_xml.attrib["stateCode"]))
            if state_xml.attrib["period"] != "0":
                business_period = iso8601.parse_date(state_xml.attrib["period"]).date()

        logged_user = None
        logged_operator_xml = xml_model.find("./Operator[@state='LOGGEDIN']")
        if logged_operator_xml is not None:
            level = logged_operator_xml.attrib.get("level")
            if level is not None:
                level = int(level)

            logged_user = User(
                id=int(logged_operator_xml.attrib["id"]),
                name=logged_operator_xml.attrib.get("name"),
                level=level,
            )

        active_sessions = []
        sessions_xml = xml_model.findall("./Operators/Operator")
        for session_xml in sessions_xml:
            level = session_xml.attrib.get("level")
            if level is not None:
                level = int(level)

            user = User(
                id=int(session_xml.attrib["id"]),
                name=session_xml.attrib.get("name"),
                level=level,
            )

            session_id = session_xml.attrib["sessionId"]
            count = self._get_count_from_session_id(session_id=session_id) or 1

            active_sessions.append(
                UserSession(
                    user=user,
                    state=OperatorState(int(session_xml.attrib["stateCode"])),
                    count=count,
                    initial_float=float(session_xml.attrib["initialFloat"]),
                    session_id=session_id,
                    open_time=None,
                    close_time=None,
                )
            )

        return PosModel(
            pos_id=int(xml_model.attrib["posId"]),
            state=pos_state,
            business_period=business_period,
            logged_user=logged_user,
            active_sessions=active_sessions,
        )

    @staticmethod
    def _format_params(
        *params      # type: Tuple[Any]
    ):
        # type: (...) -> str

        return "\0".join(map(str, params))

    @staticmethod
    def _get_count_from_session_id(
        session_id,     # type: str
    ):
        # type: (...) -> Optional[int]

        parts = session_id.split(",")
        for part in parts:
            inner_parts = part.split("=")
            if inner_parts[0] == "count":
                return int(inner_parts[1])

    @staticmethod
    def _get_error_code(
        data,       # type: bytes
    ):
        # type: (...) -> Optional[int]

        parts = data.split(b"\0")
        if len(parts) < 2:
            return None

        try:
            return int(parts[0])
        except ValueError:
            return None

    @staticmethod
    def _build_pos_controller_error(
        err_msg,            # type: str
        data,               # type: bytes
    ):
        # type: (...) -> PosControllerError

        if data:
            err_msg += ": {}".format(data)

        raise PosControllerError(message=err_msg)

    @staticmethod
    def _convert_to_str(
        data,       # type: bytes
    ):
        # type: (...) -> str

        try:
            return data.decode("utf-8")
        except UnicodeDecodeError:
            return "".join("{:02x}".format(ord(b)) for b in data)
