from typing import Optional, Any

from bemani.backend.base import Base, Status
from bemani.common import Model
from bemani.protocol import Node
from bemani.data import Config, Data


class UnrecognizedPCBIDException(Exception):
    def __init__(self, pcbid: str, model: str, ip: str) -> None:
        self.pcbid = pcbid
        self.model = model
        self.ip = ip


class Dispatch:
    """
    Dispatch object responsible for taking a decoded tree of Node objects
    from a game, looking up config, dispatching it to the correct game
    class and then returning a response.
    """

    def __init__(self, config: Config, data: Data, verbose: bool) -> None:
        """
        Initialize the Dispatch object.

        Parameters:
            config - A dictionary of configuration used for various settigs.
            data - A Data singleton for DB access.
            verbose - Whether we get chatty to stdout or not.
        """
        self.__verbose = verbose
        self.__data = data
        self.__config = config

    def log(self, msg: str, *args: Any, **kwargs: Any) -> None:
        """
        Given a message, format it and print it.

        Note that this only prints to stdout if we were initialized with
        verbose = True.

        Parameters:
            msg - A formatstring that should be formatted with any
                  optional arguments or keyword arguments.
        """
        if self.__verbose:
            print(msg.format(*args, **kwargs))

    def handle(self, tree: Node) -> Optional[Node]:
        """
        Given a packet from a game, handle it and return a response.

        Parameters:
            tree - A Node representing the root of a tree. Expected to
                   come from an external game.

        Returns:
            A Node representing the root of a response tree, or None if
            we had a problem parsing or generating a response.
        """
        self.log("Received request:\n{}", tree)

        if tree.name != 'call':
            # Invalid request
            self.log("Invalid root node {}", tree.name)
            return None

        if len(tree.children) != 1:
            # Invalid request
            self.log("Invalid number of children for root node")
            return None

        modelstring = tree.attribute('model')
        model = Model.from_modelstring(modelstring)
        pcbid = tree.attribute('srcid')

        # If we are enforcing, bail out if we don't recognize thie ID
        pcb = self.__data.local.machine.get_machine(pcbid)
        if self.__config.server.enforce_pcbid and pcb is None:
            self.log("Unrecognized PCBID {}", pcbid)
            raise UnrecognizedPCBIDException(pcbid, modelstring, self.__config.client.address)

        # If we don't have a Machine, but we aren't enforcing, we must create it
        if pcb is None:
            pcb = self.__data.local.machine.create_machine(pcbid)

        request = tree.children[0]

        config = self.__config.clone()
        config['machine'] = {
            'pcbid': pcbid,
            'arcade': pcb.arcade,
        }

        # If the machine we looked up is in an arcade, override the global
        # paseli settings with the arcade paseli settings.
        if pcb.arcade is not None:
            arcade = self.__data.local.machine.get_arcade(pcb.arcade)
            if arcade is not None:
                config['paseli']['enabled'] = arcade.data.get_bool('paseli_enabled')
                config['paseli']['infinite'] = arcade.data.get_bool('paseli_infinite')
                if arcade.data.get_bool('mask_services_url'):
                    # Mask the address, no matter what the server settings are
                    config['server']['uri'] = None

        game = Base.create(self.__data, config, model)
        method = request.attribute('method')
        response = None

        # If we are enforcing, make sure the PCBID isn't specified to be
        # game-specific
        if config.server.enforce_pcbid and pcb.game is not None:
            if pcb.game != game.game:
                self.log("PCBID {} assigned to game {}, but connected from game {}", pcbid, pcb.game, game.game)
                raise UnrecognizedPCBIDException(pcbid, modelstring, config.client.address)
            if pcb.version is not None:
                if pcb.version > 0 and pcb.version != game.version:
                    self.log(
                        "PCBID {} assigned to game {} version {}, but connected from game {} version {}",
                        pcbid,
                        pcb.game,
                        pcb.version,
                        game.game,
                        game.version,
                    )
                    raise UnrecognizedPCBIDException(pcbid, modelstring, config.client.address)
                if pcb.version < 0 and (-pcb.version) < game.version:
                    self.log(
                        "PCBID {} assigned to game {} maximum version {}, but connected from game {} version {}",
                        pcbid,
                        pcb.game,
                        -pcb.version,
                        game.game,
                        game.version,
                    )
                    raise UnrecognizedPCBIDException(pcbid, modelstring, config.client.address)

        # First, try to handle with specific service/method function
        try:
            handler = getattr(game, f'handle_{request.name}_{method}_request')
        except AttributeError:
            handler = None
        if handler is not None:
            response = handler(request)

        if response is None:
            # Now, try to pass it off to a generic service handler
            try:
                handler = getattr(game, f'handle_{request.name}_request')
            except AttributeError:
                handler = None
            if handler is not None:
                response = handler(request)

        if response is None:
            # Unrecognized handler
            self.log(f"Unrecognized service {request.name} method {method}")
            return None

        # Make sure we have a status value if one wasn't provided
        if 'status' not in response.attributes:
            response.set_attribute('status', str(Status.SUCCESS))

        root = Node.void('response')
        root.add_child(response)
        root.set_attribute('dstid', pcbid)

        self.log("Sending response:\n{}", root)

        return root
