# minqlx - Extends Quake Live's dedicated server with extra functionality and scripting.
# Copyright (C) 2015 Mino <mino@minomino.org>

# This file is part of minqlx.

# minqlx is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# minqlx is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.

# You should have received a copy of the GNU General Public License
# along with minqlx. If not, see <http://www.gnu.org/licenses/>.

import queue
import sched
import re

import minqlx

# ====================================================================
#                        REGULAR EXPRESSIONS
# ====================================================================

_re_say = re.compile(r"^say +\"?(?P<msg>.+)\"?$", flags=re.IGNORECASE)
_re_say_team = re.compile(r"^say_team +\"?(?P<msg>.+)\"?$", flags=re.IGNORECASE)
_re_callvote = re.compile(
    r"^(?:cv|callvote) +(?P<cmd>[^ ]+)(?: \"?(?P<args>.+?)\"?)?$", flags=re.IGNORECASE
)
_re_vote = re.compile(r"^vote +(?P<arg>.)", flags=re.IGNORECASE)
_re_team = re.compile(r"^team +(?P<arg>.)", flags=re.IGNORECASE)
_re_vote_ended = re.compile(r"^print \"Vote (?P<result>passed|failed).\n\"$")
_re_userinfo = re.compile(r"^userinfo \"(?P<vars>.+)\"$")


# ====================================================================
#                         LOW-LEVEL HANDLERS
#        These are all called by the C code, not within Python.
# ====================================================================
def handle_rcon(cmd):
    """Console commands that are to be processed as regular pyminqlx
    commands as if the owner executes it. This allows the owner to
    interact with the Python part of minqlx without having to connect.

    """
    # noinspection PyBroadException
    try:
        minqlx.COMMANDS.handle_input(
            minqlx.RconDummyPlayer(), cmd, minqlx.CONSOLE_CHANNEL
        )
    except:  # noqa: E722
        minqlx.log_exception()
        return True


def handle_client_command(client_id, cmd):
    """Client commands are commands such as "say", "say_team", "scores",
    "disconnect" and so on. This function parses those and passes it
    on to the event dispatcher.

    :param: client_id: The client identifier.
    :type: client_id: int
    :param: cmd: The command being run by the client.
    :type: cmd: str

    """
    # noinspection PyBroadException
    try:
        # Dispatch the "client_command" event before further processing.
        player = minqlx.Player(client_id)
        retval = minqlx.EVENT_DISPATCHERS["client_command"].dispatch(player, cmd)
        if retval is False:
            return False
        if isinstance(retval, str):
            # Allow plugins to modify the command before passing it on.
            cmd = retval

        res = _re_say.match(cmd)
        if res:
            msg = res.group("msg").replace('"', "")
            channel = minqlx.CHAT_CHANNEL
            if minqlx.EVENT_DISPATCHERS["chat"].dispatch(player, msg, channel) is False:
                return False
            return cmd

        res = _re_say_team.match(cmd)
        if res:
            msg = res.group("msg").replace('"', "")
            if (
                player.team == "free"
            ):  # I haven't tried this, but I don't think it's even possible.
                channel = minqlx.FREE_CHAT_CHANNEL
            elif player.team == "red":
                channel = minqlx.RED_TEAM_CHAT_CHANNEL
            elif player.team == "blue":
                channel = minqlx.BLUE_TEAM_CHAT_CHANNEL
            else:
                channel = minqlx.SPECTATOR_CHAT_CHANNEL
            if minqlx.EVENT_DISPATCHERS["chat"].dispatch(player, msg, channel) is False:
                return False
            return cmd

        res = _re_callvote.match(cmd)
        if res and not minqlx.Plugin.is_vote_active():
            vote = res.group("cmd")
            args = res.group("args") if res.group("args") else ""
            # Set the caller for vote_started in case the vote goes through.
            # noinspection PyUnresolvedReferences
            minqlx.EVENT_DISPATCHERS["vote_started"].caller(player)
            if (
                minqlx.EVENT_DISPATCHERS["vote_called"].dispatch(player, vote, args)
                is False
            ):
                return False
            return cmd

        res = _re_vote.match(cmd)
        if res and minqlx.Plugin.is_vote_active():
            arg = res.group("arg").lower()
            if (
                arg in ["y", "1"]
                and minqlx.EVENT_DISPATCHERS["vote"].dispatch(player, True) is False
            ):
                return False
            if (
                arg in ["n", "2"]
                and minqlx.EVENT_DISPATCHERS["vote"].dispatch(player, False) is False
            ):
                return False
            return cmd

        res = _re_team.match(cmd)
        if res:
            arg = res.group("arg").lower()
            target_team = ""
            if arg == player.team[0]:
                # Don't trigger if player is joining the same team.
                return cmd
            if arg == "f":
                target_team = "free"
            elif arg == "r":
                target_team = "red"
            elif arg == "b":
                target_team = "blue"
            elif arg == "s":
                target_team = "spectator"
            elif arg == "a":
                target_team = "any"

            if (
                target_team
                and minqlx.EVENT_DISPATCHERS["team_switch_attempt"].dispatch(
                    player, player.team, target_team
                )
                is False
            ):
                return False
            return cmd

        res = _re_userinfo.match(cmd)
        if res:
            new_info = minqlx.parse_variables(res.group("vars"), ordered=True)
            old_info = player.cvars
            changed = {}

            for key in new_info:
                if key not in old_info or (
                    key in old_info and new_info[key] != old_info[key]
                ):
                    changed[key] = new_info[key]

            if changed:
                ret = minqlx.EVENT_DISPATCHERS["userinfo"].dispatch(player, changed)
                if ret is False:
                    return False
                if isinstance(ret, dict):
                    for key in ret:
                        new_info[key] = ret[key]
                    formatted_key_values = "".join(
                        [f"\\{key}\\{value}" for key, value in new_info.items()]
                    )
                    cmd = f'userinfo "{formatted_key_values}"'

        return cmd
    except:  # noqa: E722
        minqlx.log_exception()
        return True


def handle_server_command(client_id, cmd):
    # noinspection PyBroadException
    try:
        # Dispatch the "server_command" event before further processing.
        try:
            player = minqlx.Player(client_id) if client_id >= 0 else None
        except minqlx.NonexistentPlayerError:
            return True

        retval = minqlx.EVENT_DISPATCHERS["server_command"].dispatch(player, cmd)
        if retval is False:
            return False
        if isinstance(retval, str):
            cmd = retval

        res = _re_vote_ended.match(cmd)
        if res:
            if res.group("result") == "passed":
                minqlx.EVENT_DISPATCHERS["vote_ended"].dispatch(True)
            else:
                minqlx.EVENT_DISPATCHERS["vote_ended"].dispatch(False)

        return cmd
    except:  # noqa: E722
        minqlx.log_exception()
        return True


# Executing tasks right before a frame, by the main thread, will often be desirable to avoid
# weird behavior if you were to use threading. This list will act as a task queue.
# Tasks can be added by simply adding the @minqlx.next_frame decorator to functions.
frame_tasks = sched.scheduler()
next_frame_tasks = queue.Queue()  # type: ignore


def handle_frame():
    """This will be called every frame. To allow threads to call stuff from the
    main thread, tasks can be scheduled using the :func:`minqlx.next_frame` decorator
    and have it be executed here.

    """

    while True:
        # This will run all tasks that are currently scheduled.
        # If one of the tasks throw an exception, it'll log it
        # and continue execution of the next tasks if any.
        # noinspection PyBroadException
        try:
            frame_tasks.run(blocking=False)
            break
        except:  # noqa: E722
            minqlx.log_exception()
            continue
    # noinspection PyBroadException
    try:
        minqlx.EVENT_DISPATCHERS["frame"].dispatch()
    except:  # noqa: E722
        minqlx.log_exception()
        return True

    while not next_frame_tasks.empty():
        func, args, kwargs = next_frame_tasks.get(block=False)
        frame_tasks.enter(0, 1, func, args, kwargs)


_zmq_warning_issued = False
_first_game = True
_ad_round_number = 0


def handle_new_game(is_restart):
    # This is called early in the launch process, so it's a good place to initialize
    # minqlx stuff that needs QLDS to be initialized.
    global _first_game
    if _first_game:
        minqlx.late_init()
        _first_game = False

        # A good place to warn the owner if ZMQ stats are disabled.
        global _zmq_warning_issued
        stats_enabled_cvar = minqlx.get_cvar("zmq_stats_enable")
        if (
            stats_enabled_cvar is None or not bool(int(stats_enabled_cvar))
        ) and not _zmq_warning_issued:
            logger = minqlx.get_logger()
            logger.warning(
                "Some events will not work because ZMQ stats is not enabled. "
                'Launch the server with "zmq_stats_enable 1"'
            )
            _zmq_warning_issued = True

    minqlx.set_map_subtitles()

    if not is_restart:
        # noinspection PyBroadException
        try:
            minqlx.EVENT_DISPATCHERS["map"].dispatch(
                minqlx.get_cvar("mapname"), minqlx.get_cvar("g_factory")
            )
        except:  # noqa: E722
            minqlx.log_exception()
            return True

    # noinspection PyBroadException
    try:
        minqlx.EVENT_DISPATCHERS["new_game"].dispatch()
    except:  # noqa: E722
        minqlx.log_exception()
        return True


def handle_set_configstring(index, value):
    """Called whenever the server tries to set a configstring. Can return
    False to stop the event.

    """
    global _ad_round_number

    # noinspection PyBroadException
    try:
        res = minqlx.EVENT_DISPATCHERS["set_configstring"].dispatch(index, value)
        if res is False:
            return False
        if isinstance(res, str):
            value = res

        # VOTES
        if index == 9 and value:
            cmd = value.split()
            vote = cmd[0] if cmd else ""
            args = " ".join(cmd[1:]) if len(cmd) > 1 else ""
            minqlx.EVENT_DISPATCHERS["vote_started"].dispatch(vote, args)
            return
        # GAME STATE CHANGES
        if index == 0:
            old_cs = minqlx.parse_variables(minqlx.get_configstring(index))
            if not old_cs:
                return

            new_cs = minqlx.parse_variables(value)
            old_state = old_cs["g_gameState"]
            new_state = new_cs["g_gameState"]
            if old_state != new_state:
                if old_state == "PRE_GAME" and new_state == "IN_PROGRESS":
                    pass
                elif old_state == "PRE_GAME" and new_state == "COUNT_DOWN":
                    _ad_round_number = 1
                    minqlx.EVENT_DISPATCHERS["game_countdown"].dispatch()
                elif (old_state == "COUNT_DOWN" and new_state == "IN_PROGRESS") or (
                    new_state == "PRE_GAME"
                    and old_state
                    in [
                        "IN_PROGRESS",
                        "COUNT_DOWN",
                    ]
                ):
                    pass
                else:
                    logger = minqlx.get_logger()
                    logger.warning(f"UNKNOWN GAME STATES: {old_state} - {new_state}")
        # ROUND COUNTDOWN AND START
        elif index == 661:
            cvars = minqlx.parse_variables(value)
            if cvars:
                if "turn" in cvars:
                    # it is A&D
                    if int(cvars["state"]) == 0:
                        return
                    # round cvar appears only on round countdown
                    # and first round is 0, not 1
                    try:
                        round_number = int(cvars["round"]) * 2 + 1 + int(cvars["turn"])
                        _ad_round_number = round_number
                    except KeyError:
                        round_number = _ad_round_number
                else:
                    # it is CA
                    round_number = int(cvars["round"])

                if round_number and "time" in cvars:
                    minqlx.EVENT_DISPATCHERS["round_countdown"].dispatch(round_number)
                    return
                if round_number:
                    minqlx.EVENT_DISPATCHERS["round_start"].dispatch(round_number)
                    return

        return res
    except:  # noqa: E722
        minqlx.log_exception()
        return True


def handle_player_connect(client_id, _is_bot):
    """This will be called whenever a player tries to connect. If the dispatcher
    returns False, it will not allow the player to connect and instead show them
    a message explaining why. The default message is "You are banned from this
    server.", but it can be set with :func:`minqlx.set_ban_message`.

    :param: client_id: The client identifier.
    :type: client_id: int
    :param: _is_bot: Whether or not the player is a bot.
    :type: _is_bot: bool

    """
    # noinspection PyBroadException
    try:
        player = minqlx.Player(client_id)
        return minqlx.EVENT_DISPATCHERS["player_connect"].dispatch(player)
    except:  # noqa: E722
        minqlx.log_exception()
        return True


def handle_player_loaded(client_id):
    """This will be called whenever a player has connected and finished loading,
    meaning it'll go off a bit later than the usual "X connected" messages.
    This will not trigger on bots.

    :param: client_id: The client identifier.
    :type: client_id: int

    """
    # noinspection PyBroadException
    try:
        player = minqlx.Player(client_id)
        return minqlx.EVENT_DISPATCHERS["player_loaded"].dispatch(player)
    except:  # noqa: E722
        minqlx.log_exception()
        return True


def handle_player_disconnect(client_id, reason):
    """This will be called whenever a player disconnects.

    :param: client_id: The client identifier.
    :type: client_id: int
    :param: reason: The reason for the disconnect
    :type: reason: str

    """
    # noinspection PyBroadException
    try:
        player = minqlx.Player(client_id)
        return minqlx.EVENT_DISPATCHERS["player_disconnect"].dispatch(player, reason)
    except:  # noqa: E722
        minqlx.log_exception()
        return True


def handle_player_spawn(client_id):
    """Called when a player spawns. Note that a spectator going in free spectate mode
    makes the client spawn, so you'll want to check for that if you only want "actual"
    spawns.

    """
    # noinspection PyBroadException
    try:
        player = minqlx.Player(client_id)
        return minqlx.EVENT_DISPATCHERS["player_spawn"].dispatch(player)
    except:  # noqa: E722
        minqlx.log_exception()
        return True


def handle_kamikaze_use(client_id):
    """This will be called whenever player uses kamikaze item.

    :param: client_id: The client identifier.
    :type: client_id: int

    """
    # noinspection PyBroadException
    try:
        player = minqlx.Player(client_id)
        return minqlx.EVENT_DISPATCHERS["kamikaze_use"].dispatch(player)
    except:  # noqa: E722
        minqlx.log_exception()
        return True


def handle_kamikaze_explode(client_id, is_used_on_demand):
    """This will be called whenever kamikaze explodes.

    :param: client_id: The client identifier.
    :type: client_id: int
    :param: is_used_on_demand: Non-zero if kamikaze is used on demand.
    :type: is_used_on_demand: int


    """
    # noinspection PyBroadException
    try:
        player = minqlx.Player(client_id)
        return minqlx.EVENT_DISPATCHERS["kamikaze_explode"].dispatch(
            player, bool(is_used_on_demand)
        )
    except:  # noqa: E722
        minqlx.log_exception()
        return True


def handle_damage(target_id, attacker_id, damage, dflags, mod):
    target_player = minqlx.Player(target_id) if target_id in range(0, 64) else target_id
    attacker_player = (
        minqlx.Player(attacker_id) if attacker_id in range(0, 64) else attacker_id
    )
    # noinspection PyBroadException
    try:
        minqlx.EVENT_DISPATCHERS["damage"].dispatch(
            target_player, attacker_player, damage, dflags, mod
        )
    except:  # noqa: E722
        minqlx.log_exception()
        return True


def handle_console_print(text):
    """Called whenever the server prints something to the console and when rcon is used."""
    if not text:
        return

    # noinspection PyBroadException
    try:
        # Log console output. Removes the need to have stdout logs in addition to minqlx.log.
        minqlx.get_logger().debug(text.rstrip("\n"))

        res = minqlx.EVENT_DISPATCHERS["console_print"].dispatch(text)
        if res is False:
            return False

        if _print_redirection:
            global _print_buffer
            _print_buffer += text

        if isinstance(res, str):
            return res

        return text
    except:  # noqa: E722
        minqlx.log_exception()
        return True


_print_redirection = None
_print_buffer = ""


def redirect_print(channel):
    """Redirects print output to a channel. Useful for commands that execute console commands
    and want to redirect the output to the channel instead of letting it go to the console.

    To use it, use the return value with the "with" statement.

    .. code-block:: python
        def cmd_echo(self, player, msg, channel):
            with minqlx.redirect_print(channel):
                minqlx.console_command("echo {}".format(" ".join(msg)))

    """

    class PrintRedirector:
        def __init__(self, _channel):
            if not isinstance(_channel, minqlx.AbstractChannel):
                raise ValueError(
                    "The redirection channel must be an instance of minqlx.AbstractChannel."
                )

            self.channel = _channel

        def __enter__(self):
            global _print_redirection
            _print_redirection = self.channel

        def __exit__(self, exc_type, exc_val, exc_tb):
            global _print_redirection
            self.flush()
            _print_redirection = None

        def flush(self):
            global _print_buffer
            self.channel.reply(_print_buffer)
            _print_buffer = ""

    return PrintRedirector(channel)


def register_handlers():
    minqlx.register_handler("rcon", handle_rcon)
    minqlx.register_handler("client_command", handle_client_command)
    minqlx.register_handler("server_command", handle_server_command)
    minqlx.register_handler("new_game", handle_new_game)
    minqlx.register_handler("set_configstring", handle_set_configstring)
    minqlx.register_handler("console_print", handle_console_print)
