import time, json, re
import flask
from flask import Flask
from flask_socketio import SocketIO, send, emit, join_room, leave_room, has_request_context, disconnect


"""
## Notes from the python/flask port:

Still to do:

* NPC
* Language Filter
* Connection monitoring: PACKETS_PER_SECONDS
* serverMod.js!

## Notes from original/node:

The client and server version strings MUST be the same!
They can be used to force clients to hard refresh to load the latest client.
If the server gets updated it can be restarted, but if there are active clients (users' open browsers) they could be outdated and create issues.
If the VERSION vars are mismatched they will send all clients in an infinite refresh loop. Make sure you update sketch.js before restarting server.js

"""

PACKETS_PER_SECONDS = 30
VERSION = "1.0"
with open("data.json") as datafile:
    DATA = json.load(datafile)

# time before disconnecting (forgot the tab open?)
ACTIVITY_TIMEOUT = 10 * 60 * 1000
# should be the same as index maxlength="16"
MAX_NAME_LENGTH = 16

# cap the overall players 
MAX_PLAYERS = -1
# refuse people when a room is full 
MAX_PLAYERS_PER_ROOM = 200

# views since the server started counts relogs
visits = 0
admins = []
# We want the server to keep track of the whole game state
# in this case the game state are the attributes of each player
gameState = {
    'players': {},
    'NPCs': {}
}

# save the server startup time and send it in case the clients need to syncronize something
START_TIME = int(time.time()*1000) # datetime.datetime.now()

# a collection of banned IPs
# not permanent, it lasts until the server restarts
banned = []


app = Flask(__name__, static_url_path='', static_folder='public')
app.add_url_rule("/", view_func=lambda **kw: app.send_static_file("index.html", **kw))
# app.config['SECRET_KEY'] = 'secret!'
socketio = SocketIO(app)
connections = 0

@socketio.on('connect')
def connect(sid=None, environ=None, auth=None):
    global connections
    connections += 1
    print (f"A user connected ({connections} connections)")
    emit('serverWelcome', (VERSION, DATA, START_TIME.isoformat()))

@socketio.on('disconnect')
def disconnect(auth=None):
    global connections
    connections -= 1
    # print ("disconnect")
    print(f'Client disconnected ({connections} connections)')
    # when a client disconnects I have to delete its player object
    # or I would end up with ghost players
    try:
        print(f"Player disconnected {flask.request.sid}")
        playerObject = gameState['players'].get(flask.request.sid);
        emit("playerLeft", { 'id': flask.request.sid, 'disconnect': True }, broadcast=True)
        # check if there is a custom function in the MOD to call at this point
        if playerObject is not None:
            if playerObject['room']:
                if (playerObject['room']+"Leave" in MOD):
                    # call it!
                    MOD[playerObject.room+"Leave"](playerObject, playerObject['room'])

        # send the disconnect
        # delete the player object
        if flask.request.sid in gameState['players']:
            del gameState['players'][flask.request.sid]
        print(f"There are now {len(gameState['players'])} players on this server")
    except Exception as e:
        print(f"Error on disconnect, object malformed from {flask.request.sid}?")
        print(f"Exception: {e}")
 
@socketio.on('join')
def join (playerInfo):
    global visits
    # print ("join", playerInfo)
    try:
        # if running locally it's not gonna work
        IP = ""
        if (has_request_context() and flask.request.headers):
            if (flask.request.headers.get("x-forwarded-for") is not None):
                IP = flask.request.headers.get("x-forwarded-for").split(",")[0]
        if (playerInfo['nickName'] == ""):
            print(f"New user joined the server in lurking mode {flask.request.sid} {IP}")
        else:
            print(f"New user joined the game: {playerInfo['nickName']} avatar# {playerInfo['avatar']} colors# {playerInfo['colors']} {flask.request.sid}")

        roomPlayers = 1
        # from https://github.com/miguelgrinberg/python-socketio/blob/main/src/socketio/base_manager.py
        # manager.rooms = {}  # self.rooms[namespace][room][sio_sid] = eio_sid
        # io.sockets.adapter (js) == socketio.server.manager (python)
        # print ("rooms", socketio.server.manager.rooms)
        myRoom = socketio.server.manager.rooms['/'].get(playerInfo['room'])
        if myRoom is not None:
            roomPlayers = len(myRoom.keys()) + 1
            # roomPlayers = len(list(socketio.server.manager.get_participants('/', playerInfo['room'])))
            print (f"There are now {roomPlayers} users in {playerInfo['room']}")
        # serverPlayers = Object.keys(io.sockets.connected).length + 1
        serverPlayers = connections + 1
        isBanned = False

        # prevent banned IPs from joining
        if (IP != ""):
            try:
                index = banned.index(IP);
                print(f"ATTENTION: banned {IP} is trying to log in again")
                isBanned = True
                emit("errorMessage", "You have been banned")
                disconnect()
            except ValueError:
                pass
 
        # prevent secret rooms to be joined through URL
        if playerInfo['room'] in DATA['ROOMS']:
            if DATA['ROOMS'][playerInfo['room']].get('secret'):
                playerInfo['room'] = DATA['SETTINGS']['defaultRoom']

        if isBanned:
            pass
        elif flask.request.sid in gameState['players']:
            # prevent a hacked client from duplicating players
            print(f"ATTENTION: there is already a player associated to the socket {flask.request.sid}")
        elif ((serverPlayers > MAX_PLAYERS and MAX_PLAYERS != -1) or (roomPlayers > MAX_PLAYERS_PER_ROOM and MAX_PLAYERS_PER_ROOM != -1)):
            # limit the number of players
            print(f"ATTENTION: {playerInfo['room']} reached maximum capacity")
            emit("errorMessage", "The server is full, please try again later.")
            disconnect()
        else:
            # if client hacked truncate
            playerInfo['nickName'] = playerInfo['nickName'][:MAX_NAME_LENGTH]

            # the first validation was to give the player feedback, this one is for real
            val = 1

            # always validate lurkers, they can't do anything
            if playerInfo['nickName'] != "":
                val = validateName(playerInfo['nickName'])

            if (val == 0 or val == 3):
                print(f"ATTENTION: {flask.request.sid} tried to bypass username validation")         
            else:
                #if there is an | strip the after so the password remains in the admin client
                combo = playerInfo['nickName'].split("|")
                playerInfo['nickName'] = combo[0]

                if (val == 2):
                    print(f"{playerInfo['nickName']} joins as admin")

                #the player objects on the client will keep track of the room
                newPlayer = { 'id': flask.request.sid, 'nickName': filter.clean(playerInfo['nickName']), 'colors': playerInfo['colors'], 'room': playerInfo['room'], 'avatar': playerInfo['avatar'], 'x': playerInfo['x'], 'y': playerInfo['y']}

                # save the same information in my game state
                sid = flask.request.sid
                gameState['players'][sid] = newPlayer
                # set last message at the beginning of time, the SEVENTIES
                gameState['players'][sid]['lastMessage'] = 0;
                # is it admin?
                gameState['players'][sid]['admin'] = (val == 2)
                gameState['players'][sid]['spam'] = 0;
                gameState['players'][sid]['lastActivity'] = int(time.time()*1000)
                gameState['players'][sid]['muted'] = False
                gameState['players'][sid]['IP'] = IP
                gameState['players'][sid]['floodCount'] = 0
                gameState['players'][sid]['room'] = playerInfo['room']

                # send the user to the default room
                join_room(playerInfo['room'])

                newPlayer['new'] = True

                # let's not count lurkers
                if playerInfo['nickName'] != "":
                    visits += 1

                #send all players information about the new player
                #upon creation destination and position are the same
                # print (f"playerJoined {newPlayer} {playerInfo['room']}")
                emit("playerJoined", newPlayer, room=playerInfo['room'])

                # check if there are NPCs in this room and make them send info to the player
                for NPCId in gameState['NPCs']:
                    npc = gameState['NPCs'][NPCId]
                    if npc['room'] == playerInfo['room']:
                        npc.sendIntroTo(flask.request.sid)
                #check if there is a custom function in the MOD to call at this point
                if playerInfo['room']+"Join" in MOD:
                    # call it!
                    MOD[playerInfo['room']+"Join"](newPlayer, playerInfo['room'])
                    
                print(f"There are now {len(gameState['players'])} players on this server. Total visits {visits}")
    except Exception as e:
        print(f"Error on join, object malformed from {flask.request.sid}?")
        print(f"Exception: {e}")

@socketio.on('intro')
def intro (newComer, obj):
    """ when I receive an intro send it to the recipient """
    # print (f"intro {newComer} {obj}")
    # verify the id to make sure a hacked client can"t fake players
    if (obj is not None):
        if obj['id'] == flask.request.sid:
            emit("onIntro", obj, room=newComer)
            if (obj['room'] + "Intro" in MOD):
                MOD[obj.room + "Intro"](newComer, obj)
        else:
            print(f"ATTENTION: Illegitimate intro from {flask.request.sid}")        

@socketio.on('talk')
def talk (obj):
    """ when I receive a talk send it to everybody in the room """
    try:
        mtime = int(time.time()*1000)

        # block if spamming
        if (mtime - gameState['players'][flask.request.sid]['lastMessage'] > DATA['SETTINGS']['ANTI_SPAM'] and not gameState['players'][flask.request.sid]['muted']):

            # Admin commands can be typed as messages
            # is this an admin
            if (gameState['players'][flask.request.sid].get("admin") and obj['message'][0] == "/"):
                print(f"Admin {gameState['players'][flask.request.sid]['nickName']} attempts command {obj['message']}")
                adminCommand(socket, obj['message'])

            else:
                # normal talk stuff
                # aphostrophe
                obj['message'] = obj['message'].replace("’", "'")

                # replace unmapped characters
                # obj.message = obj.message.replace(/[^A-Za-z0-9_!$%*()@./#&+-|]*$/g, "");
                obj['message'] = re.sub(r'[^A-Za-z0-9_!$%*()@./#&+-|]*$', "", obj['message'])

                # remove leading and trailing whitespaces
                obj['message'] = obj['message'].strip()
                
                # filter bad words
                obj['message'] = filter.clean(obj['message'])

                # advanced cleaning
                # f u c k
                test = re.sub('\s', "", obj['message'])
                # fffffuuuuck
                test2 = re.sub('(.)(?=.*\1)', "", obj['message'])
                # f*u*c*k
                test3 = re.sub('\W', "", obj['message'])
                # spaces
                test4 = re.sub('\s', "", obj['message']);

                if (filter.isProfane(test) or filter.isProfane(test2) or filter.isProfane(test3) or test4 == ""):
                    print(f"{flask.request.sid} is problematic")
                else:

                    # check if there is a custom function in the MOD to call at this point
                    if obj['room']+"TalkFilter" in MOD:

                        # call it!
                        obj.message = MOD[obj.room + "TalkFilter"](gameState['players'][flask.request.sid], obj['message'])

                        if obj['message'] is not None:
                            print("MOD: Warning - TalkFilter should return a message ")
                            obj['message'] = ""

                    if obj['message']:
                        emit("playerTalked", { 'id': flask.request.sid, 'color': obj['color'], 'message': obj['message'], 'x': obj['x'], 'y': obj['y'] }, room=obj['room'])

            # update the last message time
            # if (gameState['players'][flask.request.sid]:
            gameState['players'][flask.request.sid]['lastMessage'] = mtime
            gameState['players'][flask.request.sid]['lastActivity'] = mtime

    except Exception as e:
        print(f"Error on talk, object malformed from {flask.request.sid}?")
        print(f"Exception: {e}")

@socketio.on('changeRoom')
def changeRoom (obj):
    """ when I receive a move sent it to everybody"""
    try:
        roomPlayers = 1

        # myRoom = io.sockets.adapter.rooms[obj.to];
        # if myRoom is not None:
        #     roomPlayers = myRoom.length + 1;

        myRoom = socketio.server.manager.rooms['/'].get(obj['to'])
        if myRoom is not None:
            roomPlayers = len(myRoom.keys()) + 1

        if roomPlayers > MAX_PLAYERS_PER_ROOM and MAX_PLAYERS_PER_ROOM != -1:
            # limit the number of players
            print(f"ATTENTION: {obj['to']} reached maximum capacity")
            # keep the player in game, send a message
            emit("godMessage", "The room looks full")
        else:
            print(f"Player {flask.request.sid} moved from {obj['from']} to {obj['to']}")

            leave_room(obj['from'])
            join_room(obj['to'])

            # broadcast the change to everybody in the current room
            # from the client perspective leaving the room is the same as disconnecting
            emit("playerLeft", { 'id': flask.request.sid, 'room': obj['from'], 'disconnect': False }, room=obj['from'])

            # same for joining, sending everybody in the room the player state
            playerObject = gameState['players'][flask.request.sid]
            playerObject['room'] = obj['to']
            playerObject['x'] = playerObject['destinationX'] = obj['x']
            playerObject['y'] = playerObject['destinationY'] = obj['y']
            playerObject['new'] = False

            # check if there is a custom function in the MOD to call at this point
            if obj['from']+"Leave" in MOD:
                # call it!
                MOD[obj['from'] + "Leave"](playerObject, obj['from'])

            emit("playerJoined", playerObject, room=obj['to'])

            # check if there is a custom function in the MOD to call at this point
            if obj['to']+"Join" in MOD:
                # call it!
                MOD[obj['to']+"Join"](playerObject, obj['to'])

            # check if there are NPCs in this room and make them send info to the player
            for NPCId in gameState['NPCs']:
                npc = gameState['NPCs'][NPCId]
                if (npc.room == obj['to']):
                    npc.sendIntroTo(flask.request.sid)
    except Exception as e:
        print(f"Error on changeRoom, object malformed from {flask.request.sid}?")
        print(f"Exception: {e}")



# when I receive a move sent it to everybody
@socketio.on('move')
def move(obj):
    try:
        gameState['players'][flask.request.sid]['lastActivity'] = int(time.time()*1000)

        # broadcast the movement to everybody
        # print (f"emit playerMoved to everybody in {obj['room']}")
        emit("playerMoved", { 'id': flask.request.sid, 'x': obj['x'], 'y': obj['y'], 'destinationX': obj['destinationX'], 'destinationY': obj['destinationY'] }, room=obj['room'])

    except Exception as e:
        print(f"Error on move, object malformed from {flask.request.sid}?")
        print(f"Exception: {e}")

# when I receive a user name validate it
@socketio.on('sendName')
def sendName(nn):
    try:
        res = validateName(nn)
        # send the code 0 no - 1 ok - 2 admin
        emit("nameValidation", res)
    except Exception as e:
        print(f"Error on sendName, object malformed from {flask.request.sid}?")
        print(f"Exception: {e}")

@socketio.on('emote')
def emote(obj):
    """when a character emote animation changes"""
    try:
        emit("playerEmoted", (flask.request.sid, obj['em']), room=obj['room'])
    except Exception as e:
        print(f"Error on emote, object malformed from {flask.request.sid}?")
        print(f"Exception: {e}")

@socketio.on('focus')
def focus(obj):
    """user afk """
    try:
        # print(f"{flask.request.sid} back from AFK")
        emit("playerFocused", flask.request.sid, room=obj['room']);
    except Exception as e:
        print(f"Error on focus, object malformed from {flask.request.sid}?")
        print(f"Exception: {e}")

@socketio.on('blur')
def blur(obj):
    try:
        # print(f"{flask.request.sid} is AFK")
        emit("playerBlurred", flask.request.sid, room=obj['room'])
    except Exception as e:
        print(f"Error on blur, object malformed from {flask.request.sid}?")
        print(f"Exception: {e}")

@socketio.on('action')
def action(aId):
    """ generic action listener, looks for a function with that id in the mod """ 
    if "on"+aId in MOD:
        # call it!
        # print(f"on {aId} exists in the mod, call it")
        MOD["on"+aId](flask.request.sid);

def validateName(nn):
    admin = False
    duplicate = False
    reserved = False

    #check if the nickname is a name + password combo
    combo = nn.split("|")

    #it may be
    if len(combo) > 1:
        n = combo[0]
        p = combo[1]
        for admin in admins:
            if admin.upper() == nn.upper():
                #it is an admin name! check if the password is correct, case insensitive 
                envCombo = admin.split("|")
                if (p == envCombo[1]):
                    admin = True
        # if there is an | just strip the after
        nn = n


    #if not admin check if the nickname is reserved (case insensitive)
    if not admin:
        for admin in admins:
            combo = admin.split("|");
            if combo[0].upper() == nn.upper():
                #it is! kill it. Yes, it should be done at login and communicated 
                #but hey I don't have to be nice to users who steal my name
                reserved = True

    id = idByName(nn)
    if (id is not None):
        duplicate = True
        print(f"There is already a player named {nn}")

    #i hate this double negative logic but I hate learning regex more
    res = re.match(r'^([a-zA-Z0-9 !@#$%&*(),._-]+)$', nn)

    if (res is None):
        return 3
    elif duplicate or reserved:
        return 0
    elif admin:
        print(f"{nn} logging as admin")
        return 2
    else:
        return 1


def adminCommand(adminSocket, str):
    try:
        # remove /
        str = str[1:]
        cmd = str.split(" ")
        if cmd[0] == "kick":
            sid = socketIDByName(cmd[1])
            if sid:
                # shadow disconnect
                disconnect(sid)
            else:
                # popup to admin
                emit("popup", f"I can't find a user named {cmd[1]}")

        elif cmd[0] == "mute":
            s = idByName(cmd[1])
            if s:
                gameState['players'][s]['muted'] = True
            else:
                # popup to admin
                emit("popup", f"I can't find a user named {cmd[1]}")

        elif cmd[0] == "unmute":
            s = idByName(cmd[1]);
            if s:
                gameState['players'][s]['muted'] = False
            else:
                # popup to admin
                emit("popup", f"I can't find a user named {cmd[1]}")

        # trigger a direct popup
        elif cmd[0] == "popup":
            s = socketIDByName(cmd[1])
            if s:
                msg = cmd[2:].join(" ");
                emit("popup", msg, room=s)
            else:
                # popup to admin
                emit("popup", f"I can't find a user named {cmd[1]}")

        # send fullscreen message to everybody
        elif cmd[0] == "god":
            msg = cmd[1:].join(" ");
            emit("godMessage", msg, broadcast=True)

        # disconnect all sockets
        elif cmd[0] == "nuke":
            emit("errorMessage", "Server Restarted\nPlease Refresh", broadcast=True)
            for sid in gameState['players']:
                disconnect(sid)

        # add to the list of banned IPs
        elif cmd[0] == "ban":
            IP = IPByName(cmd[1])
            s = socketByName(cmd[1])
            if IP:
                banned.append(IP)
            if s:
                s.emit("errorMessage", "You have been banned")
                s.disconnect()
            else:
                # popup to admin
                emit("popup", f"I can't find a user named {cmd[1]}")

        elif cmd[0] == "unban":
            # releases the ban
            banned = []

        # forces a hard refresh - all players disconnect
        # used to load a new version of the client
        elif cmd[0] == "refresh":
            io.sockets.emit("refresh");

    except Exception as e:
        print ("Error admin command")
        print (f"Exception: {e}")

    
# admin functions, the admin exists in the client frontend so they don't have access to ip and id of other users
def socketIDByName(nick):
    s = None
    for sid in gameState['players']:
        if gameState['players'][sid]['nickName'].upper() == nick.upper():
            s = sid
    return s

def idByName(nick):
    i = None
    for sid in gameState['players']:
        if gameState['players'][sid]['nickName'].upper() == nick.upper():
            i = sid
    return i

def IPByName(nick):
    IP = ""
    for sid in gameState['players']:
        if gameState['players'][sid]['nickName'].upper() == nick.upper():
            IP = gameState['players'][sid]['IP']
    return IP

class BadWordsFilter (object):
    def __init__(self, initial_data=None):
        pass
    def clean (self, text):
        return text
    def isProfane(self, text):
        return False
    
filter = BadWordsFilter()


# modding
MOD = {}
# try:
#     import serverMod as MOD
#     MOD.initMod(gameState, DATA)
# except ImportError:
#     pass

if __name__ == '__main__':
    socketio.run(app)