//check README.md

//load secret config vars
require("dotenv").config();
const DATA = require("./data");

//.env content
/*
ADMINS=username1|pass1,username2|pass2
PORT = 3000
*/


var port = process.env.PORT || 3000;

//number of emits per second allowed for each player, after that ban the IP.
//over 30 emits in this game means that the client is hacked and the flooding is malicious
//if you change the game logic make sure this limit is still reasonable
var PACKETS_PER_SECONDS = 30;

/*
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
*/
var VERSION = "1.0";

//create a web application that uses the express frameworks and socket.io to communicate via http (the web protocol)
var express = require("express");
var app = express();
var http = require("http").createServer(app);
var io = require("socket.io")(http);
var Filter = require("bad-words");


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

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

//views since the server started counts relogs
var visits = 0;

/*
A very rudimentary admin system. 
Reserved usernames and admin pass are stored in .env file as
ADMINS=username1|pass1,username2|pass2

Admin logs in as username|password in the normal field
If combo user|password is correct (case insensitive) mark the player as admin on the server side
The "username|password" remains stored on the client as var nickName 
and it's never shared to other clients, unlike player.nickName

admins can call admin commands from the chat like /kick nickName
*/
var admins = [];
if (process.env.ADMINS != null)
    admins = process.env.ADMINS.split(",");

//We want the server to keep track of the whole game state
//in this case the game state are the attributes of each player
var gameState = {
    players: {},
    NPCs: {}
}

//save the server startup time and send it in case the clients need to syncronize something
var START_TIME = Date.now();

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

//when a client connects serve the static files in the public directory ie public/index.html
app.use(express.static("public"));





//when a client connects the socket is established and I set up all the functions listening for events
io.on("connection", function (socket) {


    //this bit (middleware?) catches all incoming packets
    //I use to make my own lil rate limiter without unleashing 344525 dependencies
    //a rate limiter prevents malicious flooding from a hacked client
    socket.use((packet, next) => {
        if (gameState.players[socket.id] != null) {
            var p = gameState.players[socket.id];
            p.floodCount++;
            if (p.floodCount > PACKETS_PER_SECONDS) {
                console.log(socket.id + " is flooding! BAN BAN BAN");


                if (p.IP != "") {
                    //comment this if you don't want to ban the IP
                    banned.push(p.IP);
                    socket.emit("errorMessage", "Flooding attempt! You are banned");
                    socket.disconnect();
                }

            }
        }
        next();
    });


    //this appears in the terminal
    console.log("A user connected");

    //this is sent to the client upon connection
    socket.emit("serverWelcome", VERSION, DATA, START_TIME);

    //wait for the player to send their name and info, then broadcast them
    socket.on("join", function (playerInfo) {
        // console.log("join", playerInfo)
        //console.log("Number of sockets " + Object.keys(io.sockets.connected).length);

        try {


            //if running locally it's not gonna work
            var IP = "";
            //oh look at this beautiful socket.io to get an goddamn ip address
            if (socket.handshake.headers != null)
                if (socket.handshake.headers["x-forwarded-for"] != null) {
                    IP = socket.handshake.headers["x-forwarded-for"].split(",")[0];
                }

            if (playerInfo.nickName == "")
                console.log("New user joined the server in lurking mode " + socket.id + " " + IP);
            else
                console.log("New user joined the game: " + playerInfo.nickName + " avatar# " + playerInfo.avatar + " colors# " + playerInfo.colors + " " + socket.id);

            var roomPlayers = 1;
            var myRoom = io.sockets.adapter.rooms[playerInfo.room];
            if (myRoom != undefined) {
                roomPlayers = myRoom.length + 1;
                console.log("There are now " + roomPlayers + " users in " + playerInfo.room);
            }

            var serverPlayers = Object.keys(io.sockets.connected).length + 1;

            var isBanned = false;

            //prevent banned IPs from joining
            if (IP != "") {
                var index = banned.indexOf(IP);
                //found
                if (index > -1) {
                    console.log("ATTENTION: banned " + IP + " is trying to log in again");
                    isBanned = true;
                    socket.emit("errorMessage", "You have been banned");
                    socket.disconnect();
                }

            }

            //prevent secret rooms to be joined through URL
            if (DATA.ROOMS[playerInfo.room] != null)
                if (DATA.ROOMS[playerInfo.room].secret == true) {

                    playerInfo.room = DATA.SETTINGS.defaultRoom;

                }

            if (isBanned) {

            }
            //prevent a hacked client from duplicating players
            else if (gameState.players[socket.id] != null) {
                console.log("ATTENTION: there is already a player associated to the socket " + socket.id);
            }
            else if ((serverPlayers > MAX_PLAYERS && MAX_PLAYERS != -1) || (roomPlayers > MAX_PLAYERS_PER_ROOM && MAX_PLAYERS_PER_ROOM != -1)) {
                //limit the number of players
                console.log("ATTENTION: " + playerInfo.room + " reached maximum capacity");
                socket.emit("errorMessage", "The server is full, please try again later.");
                socket.disconnect();
            }
            else {

                //if client hacked truncate
                if (playerInfo.nickName.length > MAX_NAME_LENGTH)
                    playerInfo.nickName = playerInfo.nickName.substring(0, MAX_NAME_LENGTH);


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

                //always validate lurkers, they can't do anything
                if (playerInfo.nickName != "")
                    val = validateName(playerInfo.nickName);

                if (val == 0 || val == 3) {
                    console.log("ATTENTION: " + socket.id + " tried to bypass username validation");
                }
                else {

                    //if there is an | strip the after so the password remains in the admin client
                    var combo = playerInfo.nickName.split("|");
                    playerInfo.nickName = combo[0];

                    if (val == 2)
                        console.log(playerInfo.nickName + " joins as admin");

                    //the player objects on the client will keep track of the room
                    var newPlayer = { id: socket.id, 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
                    gameState.players[socket.id] = newPlayer;
                    //set last message at the beginning of time, the SEVENTIES
                    gameState.players[socket.id].lastMessage = 0;
                    //is it admin?
                    gameState.players[socket.id].admin = (val == 2) ? true : false;
                    gameState.players[socket.id].spam = 0;
                    gameState.players[socket.id].lastActivity = new Date().getTime();
                    gameState.players[socket.id].muted = false;
                    gameState.players[socket.id].IP = IP;
                    gameState.players[socket.id].floodCount = 0;
                    gameState.players[socket.id].room = playerInfo.room;

                    //send the user to the default room
                    socket.join(playerInfo.room, function () {
                        //console.log(socket.rooms);
                    });

                    newPlayer.new = true;

                    //let"s not count lurkers
                    if (playerInfo.nickName != "")
                        visits++;


                    //send all players information about the new player
                    //upon creation destination and position are the same 
                    io.to(playerInfo.room).emit("playerJoined", newPlayer);

                    //check if there are NPCs in this room and make them send info to the player
                    for (var NPCId in gameState.NPCs) {
                        var npc = gameState.NPCs[NPCId];

                        if (npc.room == playerInfo.room) {
                            npc.sendIntroTo(socket.id);
                        }
                    }

                    //check if there is a custom function in the MOD to call at this point
                    if (MOD[playerInfo.room + "Join"] != null) {
                        //call it!
                        MOD[playerInfo.room + "Join"](newPlayer, playerInfo.room);
                    }


                    console.log("There are now " + Object.keys(gameState.players).length + " players on this server. Total visits " + visits);
                }
            }
        } catch (e) {
            console.log("Error on join, object malformed from" + socket.id + "?");
            console.error(e);
        }
    });

    //when a client disconnects I have to delete its player object
    //or I would end up with ghost players
    socket.on("disconnect", function () {
        try {
            console.log("Player disconnected " + socket.id);

            var playerObject = gameState.players[socket.id];

            io.sockets.emit("playerLeft", { id: socket.id, disconnect: true });


            //check if there is a custom function in the MOD to call at this point
            if (playerObject != null)
                if (playerObject.room != null) {
                    if (MOD[playerObject.room + "Leave"] != null) {
                        //call it!
                        MOD[playerObject.room + "Leave"](playerObject, playerObject.room);
                    }
                }

            //send the disconnect
            //delete the player object
            delete gameState.players[socket.id];
            console.log("There are now " + Object.keys(gameState.players).length + " players on this server");
        }
        catch (e) {
            console.log("Error on disconnect, object malformed from" + socket.id + "?");
            console.error(e);
        }
    });

    //when I receive an intro send it to the recipient
    socket.on("intro", function (newComer, obj) {
        //verify the id to make sure a hacked client can"t fake players
        // console.log("intro", newComer, obj);
        if (obj != null) {

            if (obj.id == socket.id) {

                io.to(newComer).emit("onIntro", obj);

                if (MOD[obj.room + "Intro"] != null) {
                    MOD[obj.room + "Intro"](newComer, obj);
                }
            }
            else {
                console.log("ATTENTION: Illegitimate intro from " + socket.id);
            }
        }
    });


    //when I receive a talk send it to everybody in the room
    socket.on("talk", function (obj) {
        try {

            var time = new Date().getTime();

            //block if spamming
            if (time - gameState.players[socket.id].lastMessage > DATA.SETTINGS.ANTI_SPAM && !gameState.players[socket.id].muted) {

                //Admin commands can be typed as messages
                //is this an admin
                if (gameState.players[socket.id].admin && obj.message.charAt(0) == "/") {
                    console.log("Admin " + gameState.players[socket.id].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, "");

                    //remove leading and trailing whitespaces
                    obj.message = obj.message.replace(/^\s+|\s+$/g, "");
                    //filter bad words
                    obj.message = filter.clean(obj.message);
                    //advanced cleaning

                    //f u c k
                    var test = obj.message.replace(/\s/g, "");
                    //fffffuuuuck
                    var test2 = obj.message.replace(/(.)(?=.*\1)/g, "");
                    //f*u*c*k
                    var test3 = obj.message.replace(/\W/g, "");
                    //spaces
                    var test4 = obj.message.replace(/\s/g, "");

                    if (filter.isProfane(test) || filter.isProfane(test2) || filter.isProfane(test3) || test4 == "") {
                        console.log(socket.id + " is problematic");
                    }
                    else {

                        //check if there is a custom function in the MOD to call at this point
                        if (MOD[obj.room + "TalkFilter"] != null) {

                            //call it!
                            obj.message = MOD[obj.room + "TalkFilter"](gameState.players[socket.id], obj.message);

                            if (obj.message == null) {
                                console.log("MOD: Warning - TalkFilter should return a message ");
                                obj.message = "";
                            }

                        }

                        if (obj.message != "")
                            io.to(obj.room).emit("playerTalked", { id: socket.id, color: obj.color, message: obj.message, x: obj.x, y: obj.y });
                    }
                }

                //update the last message time
                if (gameState.players[socket.id] != null) {
                    gameState.players[socket.id].lastMessage = time;
                    gameState.players[socket.id].lastActivity = time;
                }
            }
        } catch (e) {
            console.log("Error on talk, object malformed from" + socket.id + "?");
            console.error(e);
        }

    });


    //when I receive a move sent it to everybody
    socket.on("changeRoom", function (obj) {
        try {

            var roomPlayers = 1;
            var myRoom = io.sockets.adapter.rooms[obj.to];
            if (myRoom != undefined) {
                roomPlayers = myRoom.length + 1;
            }

            if (roomPlayers > MAX_PLAYERS_PER_ROOM && MAX_PLAYERS_PER_ROOM != -1) {
                //limit the number of players
                console.log("ATTENTION: " + obj.to + " reached maximum capacity");
                //keep the player in game, send a message
                socket.emit("godMessage", "The room looks full");
            }
            else {
                //console.log("Player " + socket.id + " moved from " + obj.from + " to " + obj.to);

                socket.leave(obj.from);
                socket.join(obj.to);


                //broadcast the change to everybody in the current room
                //from the client perspective leaving the room is the same as disconnecting
                io.to(obj.from).emit("playerLeft", { id: socket.id, room: obj.from, disconnect: false });

                //same for joining, sending everybody in the room the player state
                var playerObject = gameState.players[socket.id];
                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 (MOD[obj.from + "Leave"] != null) {
                    //call it!
                    MOD[obj.from + "Leave"](playerObject, obj.from);
                }



                io.to(obj.to).emit("playerJoined", playerObject);

                //check if there is a custom function in the MOD to call at this point
                if (MOD[obj.to + "Join"] != null) {
                    //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 (var NPCId in gameState.NPCs) {
                    var npc = gameState.NPCs[NPCId];

                    if (npc.room == obj.to) {
                        npc.sendIntroTo(socket.id);
                    }
                }
            }
        } catch (e) {
            console.log("Error on join, object malformed from" + socket.id + "?");
            console.error(e);
        }

    });

    //when I receive a move sent it to everybody
    socket.on("move", function (obj) {
        try {
            gameState.players[socket.id].lastActivity = new Date().getTime();

            //broadcast the movement to everybody
            io.to(obj.room).emit("playerMoved", { id: socket.id, x: obj.x, y: obj.y, destinationX: obj.destinationX, destinationY: obj.destinationY });

        } catch (e) {
            console.log("Error on join, object malformed from" + socket.id + "?");
            console.error(e);
        }
    });

    //when I receive a user name validate it
    socket.on("sendName", function (nn) {
        try {

            var res = validateName(nn);

            //send the code 0 no - 1 ok - 2 admin
            socket.emit("nameValidation", res);
        } catch (e) {
            console.log("Error on sendName " + socket.id + "?");
            console.error(e);
        }
    });

    //when a character emote animation changes
    socket.on("emote", function (obj) {
        try {
            io.to(obj.room).emit("playerEmoted", socket.id, obj.em);
        } catch (e) {
            console.log("Error on emote " + socket.id + "?");
            console.error(e);
        }
    });

    //user afk
    socket.on("focus", function (obj) {
        try {
            //console.log(socket.id + " back from AFK");
            io.to(obj.room).emit("playerFocused", socket.id);
        } catch (e) {
            console.log("Error on focus " + socket.id + "?");
            console.error(e);
        }
    });

    socket.on("blur", function (obj) {
        try {
            //console.log(socket.id + " is AFK");
            io.to(obj.room).emit("playerBlurred", socket.id)
        } catch (e) {
            console.log("Error on blur " + socket.id + "?");
            console.error(e);
        }
    });


    //generic action listener, looks for a function with that id in the mod 
    socket.on("action", function (aId) {

        if (MOD["on" + aId] != null) {
            //call it!
            //console.log("on" + aId + " exists in the mod, call it");
            MOD["on" + aId](socket.id);
        }
    });

});


//rate limiting - clears the flood count
setInterval(function () {
    for (var id in gameState.players) {
        if (gameState.players.hasOwnProperty(id)) {
            gameState.players[id].floodCount = 0;
        }
    }
}, 1000);



function validateName(nn) {

    var admin = false;
    var duplicate = false;
    var reserved = false;

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

    //it may be
    if (combo.length > 1) {
        var n = combo[0];
        var p = combo[1];

        for (var i = 0; i < admins.length; i++) {
            if (admins[i].toUpperCase() == nn.toUpperCase()) {
                //it is an admin name! check if the password is correct, case insensitive 
                var envCombo = admins[i].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 (!admin) {
        for (var i = 0; i < admins.length; i++) {
            var combo = admins[i].split("|");
            if (combo[0].toUpperCase() == nn.toUpperCase()) {
                //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;
            }
        }
    }

    var id = idByName(nn);
    if (id != null) {
        duplicate = true;
        console.log("There is already a player named " + nn);
    }

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


    if (res == null)
        return 3
    else if (duplicate || reserved)
        return 0
    else if (admin) {
        console.log(nn + " logging as admin");
        return 2
    }
    else
        return 1

}


//parse a potential admin command
function adminCommand(adminSocket, str) {
    try {
        //remove /
        str = str.substr(1);
        var cmd = str.split(" ");
        switch (cmd[0]) {
            case "kick":
                var s = socketByName(cmd[1]);
                if (s != null) {
                    //shadow disconnect
                    s.disconnect();

                }
                else {
                    //popup to admin
                    adminSocket.emit("popup", "I can't find a user named " + cmd[1]);
                }
                break;

            case "mute":
                var s = idByName(cmd[1]);
                if (s != null) {
                    gameState.players[s].muted = true;
                }
                else {
                    //popup to admin
                    adminSocket.emit("popup", "I can't find a user named " + cmd[1]);
                }
                break;

            case "unmute":
                var s = idByName(cmd[1]);
                if (s != null) {
                    gameState.players[s].muted = false;
                }
                else {
                    //popup to admin
                    adminSocket.emit("popup", "I can't find a user named " + cmd[1]);
                }
                break;

            //trigger a direct popup
            case "popup":

                var s = socketByName(cmd[1]);
                if (s != null) {
                    //take the rest as string
                    cmd.shift();
                    cmd.shift();
                    var msg = cmd.join(" ");
                    s.emit("popup", msg);
                }
                else {
                    //popup to admin
                    adminSocket.emit("popup", "I can't find a user named " + cmd[1]);
                }
                break;

            //send fullscreen message to everybody
            case "god":
                cmd.shift();
                var msg = cmd.join(" ");
                io.sockets.emit("godMessage", msg);
                break;

            //disconnect all sockets
            case "nuke":

                for (var id in io.sockets.sockets) {
                    io.sockets.sockets[id].emit("errorMessage", "Server Restarted\nPlease Refresh");

                    io.sockets.sockets[id].disconnect();
                }
                break;

            //add to the list of banned IPs
            case "ban":
                var IP = IPByName(cmd[1]);
                var s = socketByName(cmd[1]);
                if (IP != "") {
                    banned.push(IP);
                }

                if (s != null) {
                    s.emit("errorMessage", "You have been banned");
                    s.disconnect();
                }
                else {
                    //popup to admin
                    adminSocket.emit("popup", "I can't find a user named " + cmd[1]);
                }

                break;

            case "unban":
                //releases the ban
                banned = [];
                break;

            //forces a hard refresh - all players disconnect
            //used to load a new version of the client
            case "refresh":
                io.sockets.emit("refresh");
                break;

        }
    }
    catch (e) {
        console.log("Error admin command");
        console.error(e);
    }
}

//admin functions, the admin exists in the client frontend so they don't have access to ip and id of other users
function socketByName(nick) {
    var s = null;
    for (var id in gameState.players) {
        if (gameState.players.hasOwnProperty(id)) {
            if (gameState.players[id].nickName.toUpperCase() == nick.toUpperCase()) {
                s = io.sockets.sockets[id];
            }
        }
    }
    return s;
}

function idByName(nick) {
    var i = null;
    for (var id in gameState.players) {
        if (gameState.players.hasOwnProperty(id)) {
            if (gameState.players[id].nickName.toUpperCase() == nick.toUpperCase()) {
                i = id;
            }
        }
    }
    return i;
}

function IPByName(nick) {
    var IP = "";
    for (var id in gameState.players) {
        if (gameState.players.hasOwnProperty(id)) {
            if (gameState.players[id].nickName.toUpperCase() == nick.toUpperCase()) {
                IP = gameState.players[id].IP;
            }
        }
    }
    return IP;
}


//listen to the port 3000 this powers the whole socket.io
http.listen(port, function () {
    console.log("listening on *:3000");
});

//check the last activity and disconnect players that have been idle for too long
setInterval(function () {
    var time = new Date().getTime();

    for (var id in gameState.players) {
        if (gameState.players.hasOwnProperty(id)) {

            if (gameState.players[id].nickName != "" && (time - gameState.players[id].lastActivity) > ACTIVITY_TIMEOUT) {
                console.log(id + " has been idle for more than " + ACTIVITY_TIMEOUT + " disconnecting");
                io.sockets.sockets[id].emit("refresh");
                io.sockets.sockets[id].disconnect();
            }
        }
    }
}, 1000);



//in my gallery people can swear but not use slurs, override bad-words list, and add my own, pardon for my french
let myBadWords = ["chink", "cunt", "cunts", "fag", "fagging", "faggitt", "faggot", "faggs", "fagot", "fagots", "fags", "jap", "homo", "nigger", "niggers", "n1gger", "nigg3r"];
var filter = new Filter({ emptyList: true });
filter.addWords(...myBadWords);

//p5 style alias
function print(s) { console.log(s); }

/*
NPC 
exists in a room
broadcasts the the same join, leave, move, talk, intro events
is rendered like and avatar by the client
is controlled by the server
*/

global.NPC = function (o) {
    console.log("Create NPC " + o.id + " in room " + o.room + " nickNamed " + o.nickName);

    this.id = o.id;
    this.nickName = o.nickName;
    this.room = o.room;
    this.avatar = o.avatar;
    this.colors = o.colors;
    this.x = o.x * 2;
    this.y = o.y * 2;
    this.destinationX = o.x;
    this.destinationY = o.y;

    if (o.labelColor != null)
        this.labelColor = o.labelColor;
    else
        this.labelColor = "#FFFFFF"; //oops server doesn't know about colors

    //mimicks the emission from players
    this.sendIntroTo = function (pId) {
        //print("HELLO I"m " + this.nickName + " in " + this.room);
        //If I"m not the new player send an introduction to the new player 
        //slight issue, server doesn't compute movements so if moving it appears at the destination
        //a way to solve this would be to save the time of the movement and lerp it
        io.sockets.sockets[pId].emit("onIntro", {
            id: this.id,
            nickName: this.nickName,
            colors: this.colors,
            avatar: this.avatar,
            room: this.room,
            x: this.destinationX,
            y: this.destinationY,
            destinationX: this.destinationX,
            destinationY: this.destinationY
        });
    }


    this.move = function (dx, dy) {

        //print("HELLO I'm " + this.nickName + " and I move to " + dx + " " + dy);
        //broadcast the movement to everybody in the room
        //it doesn't check if the position is valid
        io.to(this.room).emit("playerMoved", {
            id: this.id,
            x: this.x,
            y: this.y,
            destinationX: dx,
            destinationY: dy
        });

        //update for future intros
        this.destinationX = this.x = dx;
        this.destinationY = this.y = dy;

    }

    this.talk = function (message) {

        io.to(this.room).emit("playerTalked", {
            id: this.id,
            color: this.labelColor,
            message: message,
            x: this.x,
            y: this.y
        });
    }


    this.delete = function () {
        io.to(this.room).emit("playerLeft", { id: this.id, room: this.room, disconnect: true });
        delete gameState.NPCs[this.id];
    }


    //add to NPC list
    gameState.NPCs[this.id] = this;

}

//modding
var MOD = {};
//load server side mod file
try {
    MOD = require("./serverMod");

    if (MOD.initMod != null) {
        MOD.initMod(io, gameState, DATA);
    }
}
catch (e) {
    console.log(e);
}