/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024  Rainbownapkin and the TTN Community

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

This program 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 Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program.  If not, see <https://www.gnu.org/licenses/>.*/

//NPM Imports
const {mongoose} = require('mongoose');
const {validationResult, matchedData} = require('express-validator');

//Local Imports
const server = require('../../server');
const statModel = require('../statSchema');
const {userModel} = require('../userSchema');
const permissionModel = require('../permissionSchema');
const channelPermissionSchema = require('./channelPermissionSchema');
const channelBanSchema = require('./channelBanSchema');
const { exceptionHandler, errorHandler } = require('../../utils/loggerUtils');

const channelSchema = new mongoose.Schema({
    id: {
        type: mongoose.SchemaTypes.Number,
        required: true
    },
    name: {
        type: mongoose.SchemaTypes.String,
        required: true,
        maxLength: 50,
        default: 0
    },
    description: {
        type: mongoose.SchemaTypes.String,
        required: true,
        maxLength: 1000,
        default: 0
    },
    thumbnail: {
        type: mongoose.SchemaTypes.String,
        required: true,
        default: "/img/johnny.png"
    },
    settings: {
        hidden: {
            type: mongoose.SchemaTypes.Boolean,
            required: true,
            default: true
        },
    },
    permissions: {
        type: channelPermissionSchema,
        default: () => ({})
    },
    rankList: [{
        user: {
            type: mongoose.SchemaTypes.ObjectID,
            required: true,
            ref: "user"
        },
        rank: {
            type: mongoose.SchemaTypes.String,
            required: true,
            enum: permissionModel.rankEnum
        }
    }],
    tokeCommands: [{
            type: mongoose.SchemaTypes.String,
            required: true
    }],
    //Thankfully we don't have to keep track of alts, ips, or deleted users so this should be a lot easier than site-wide bans :P
    banList: [channelBanSchema]
});


channelSchema.pre('save', async function (next){
    if(this.isModified("name")){
        if(this.name.match(/^[a-z0-9_\-.]+$/i) == null){
            throw new Error("Username must only contain alpha-numerics and the following symbols: '-_.'");
        }
    }

    //Getting the affected user would be a million times easier elsewhere
    //But this ensures it happens every time channel rank gets changed no matter what
    if(this.isModified('rankList') && this.rankList != null){
        //Get the rank list before it was modified (gross but works, find a better way if you dont like it :P)
        var chanDB = await module.exports.findOne({_id: this._id});
        //Create empty variable for the found rank object
        var foundRank = null;
        if(chanDB != null){
            //If we're removing one
            if(chanDB.rankList.length > this.rankList.length){
                //Child/Parent is *WAY* to atomic family for my tastes :P
                var top = chanDB;
                var bottom = this;
            }else{
                //otherwise reverse the loops
                var top = this;
                var bottom = chanDB;
            }

            //Populate the top doc
            await top.populate('rankList.user');


            top.rankList.forEach((topObj) => {
                //Create empty variable for the matched rank
                var matchedRank = null;
                //For each rank in the old copy of the rank list
                bottom.rankList.forEach((bottomObj) => {
                        if(topObj.user._id.toString() == bottomObj.user._id.toString()){
                            matchedRank = bottomObj;
                        }
                });


                if(matchedRank == null || matchedRank.rank != topObj.rank){
                    foundRank = topObj;
                }

            });

            //get relevant active channel
            const activeChan = server.channelManager.activeChannels.get(this.name);

            //if the channel is online
            if(activeChan != null){
                //make sure we're not trying to kick a deleted user
                if(foundRank.user != null){
                    //Get the relevant user connection
                    const userConn = activeChan.userList.get(foundRank.user.user);
                    //if the user is online
                    if(userConn != null){
                        //kick the user
                        userConn.disconnect("Your channel rank has changed!");
                    }
                }
            }
        }
    }


    //if the toke commands where changed
    if(this.isModified("tokeCommands")){
        //Get the active Channel object from the application side of the house
        const activeChannel = server.channelManager.activeChannels.get(this.name);

        //If the channel is active
        if(activeChannel != null){
            //Reload the toke command list
            activeChannel.tokeCommands = this.tokeCommands;
        }
    }

    next();
});

//statics
channelSchema.statics.register = async function(channelObj, ownerObj){
    const {name, description, thumbnail} = channelObj;

    const chanDB = await this.findOne({ name });

    if(chanDB){
        throw new Error("Channel name already taken!");
    }else{
        const id = await statModel.incrementChannelCount();
        const rankList = [{
            user: ownerObj._id,
            rank: "admin"
        }];

        const newChannel = await this.create((thumbnail ? {id, name, description, thumbnail, rankList} : {id, name, description, rankList}));
    }
}

channelSchema.statics.getChannelList = async function(includeHidden = false){
    const chanDB = await this.find({});
    var chanGuide = [];

    //crawl through channels
    chanDB.forEach((channel) => {
        //For each channel, push an object with only the information we need to the channel guide
        if(!channel.settings.hidden || includeHidden){
            chanGuide.push({
                id: channel.id,
                name: channel.name,
                description: channel.description,
                thumbnail: channel.thumbnail
            });
        }
    });
    
    //return the channel guide
    return chanGuide;
}

//Middleware for rank checks
//Man, it would be really nice if express middleware actually supported async functions, you know, as if it where't still 2015 >:(
//Also holy shit, sharing a function between two middleware functions is a nightmare
//I'd rather just have this check chanField for '/c/' to handle channels in URL, fuck me this was obnoxious to write
channelSchema.statics.reqPermCheck = function(perm, chanField = "chanName"){
    return (req, res, next)=>{
        try{
            //Check validation result
            const validResult = validationResult(req);

            //if our chan field is set to '/c/', telling us to check the URL
            if(chanField == '/c/'){
                //Rip the chan name out of the URL
                var chanName = (req.originalUrl.split('/c/')[1].replace('/settings',''));
            }else if(validResult.isEmpty()){
                //otherwise if our input is valid, use that
                var chanName = matchedData(req)[chanField];
            }else{
                //We didn't get /c/ and we got a bad input, time for shit to hit the fan!
                res.status(400);
                return res.send({errors: validResult.array()})
            }

            //Find the related channel document, and handle it using a then() block
            this.findOne({name: chanName}).then((chanDB) => {
                //If we didnt find a channel
                if(chanDB == null){
                    //FUCK
                    return errorHandler(res, "You cannot check permissions against a non-existant channel!", 'Unauthorized', 401);
                }

                //Run a perm check against the current user and permission
                chanDB.permCheck(req.session.user, perm).then((permitted) => {
                    if(permitted){
                        //if we're permitted, go on to fulfill the request
                        next();
                    }else{
                        //If not, prevent the request from going through and tell them why
                        return errorHandler(res, "You do not have a high enough rank to access this resource.", 'Unauthorized', 401);
                    }
                });
            });
        }catch(err){
            return exceptionHandler(res, err);
        }
    }
}

channelSchema.statics.processExpiredBans = async function(){
    const chanDB = await this.find({});

    chanDB.forEach((channel) => {
        channel.banList.forEach(async (ban, i) => {
            //ignore permanent and non-expired bans
            if(ban.expirationDays >= 0 && ban.getDaysUntilExpiration() <= 0){
                //Get the index of the ban
                channel.banList.splice(i,1);
                return await channel.save();
            }
        })
    });
}

//methods
channelSchema.methods.updateSettings = async function(settingsMap){
    settingsMap.forEach((value, key) => {
        if(this.settings[key] == null){
            throw new Error("Invalid channel setting.");
        }

        this.settings[key] = value;
    })

    await this.save();
    
    return this.settings;
}

channelSchema.methods.rankCrawl = async function(userDB,cb){
    //Crawl through channel rank list
    //TODO: replace this with rank check function shared with setRank
    this.rankList.forEach(async (rankObj, rankIndex) => {
        //check against user ID to speed things up
        if(rankObj.user.user == null){
            if(rankObj.user.toString() == userDB._id.toString()){
                //If we found a match, call back
                cb(rankObj, rankIndex);
            }
        }else{
            //in case someone populated the users
            if(rankObj.user.user == userDB.user){
                //If we found a match, call back
                cb(rankObj, rankIndex);
            }
        }
    });
}

channelSchema.methods.setRank = async function(userDB,rank){
    //Create variable to store found ranks
    var foundRankIndex = null;

    //Crawl through ranks to find matching index
    this.rankCrawl(userDB,(rankObj, rankIndex)=>{foundRankIndex = rankIndex});

    //If we found an existing rank object
    if(foundRankIndex != null){
        if(rank == "user"){
            this.rankList.splice(foundRankIndex,1);
        }else{
            //otherwise, set the users rank
            this.rankList[foundRankIndex].rank = rank;
        }
    }else if(rank != "user"){
        //if the user rank object doesn't exist, and we're not setting to user
        //Create rank object based on input
        const rankObj = {
            user: userDB._id,
            rank: rank
        }

        //Add it to rank list
        this.rankList.push(rankObj);
    }


    //Save our channel and return rankList
    await this.save();
    return this.rankList;
}

channelSchema.methods.getRankList = async function(){
    //Create an empty array to hold the user list
    const rankList = new Map()

    //Populate the user objects in our ranklist based off of their DB ID's
    await this.populate('rankList.user');

    //For each rank object in the rank list
    this.rankList.forEach(async (rankObj, rankObjIndex) => {
        //If the use still exists
        if(rankObj.user != null){
            //Create a new user object from rank object data
            const userObj = {
                id: rankObj.user.id,
                user: rankObj.user.user,
                img: rankObj.user.img,
                rank: rankObj.rank
            }

            //Add our user object to the list
            rankList.set(rankObj.user.user, userObj);
        }else{
            //Otherwise clean deleted users out of list
            this.rankList.splice(rankObjIndex,1);
            await this.save();
        }
    });

    //return userList
    return rankList;
}

channelSchema.methods.getChannelRankByUserDoc = async function(userDB = null){
    var foundRank = null;

    //Check to make sure userDB exists before going forward
    if(userDB == null){
        //If so this user is probably not signed in
        return "anon"
    }

    //Crawl through ranks to find matching rank
    this.rankCrawl(userDB,(rankObj)=>{foundRank = rankObj});

    //If we found an existing rank object
    if(foundRank != null){
        //return rank
        return foundRank.rank;
    }else{
        //default to "user" for registered users, and "anon" for anonymous
        if(userDB.rank == "anon"){
            return "anon";
        }else{
            return "user";
        }
    }
}

channelSchema.methods.getChannelRank = async function(user){
    const userDB = await userModel.findOne({user: user.user});
    return await this.getChannelRankByUserDoc(userDB);
}

channelSchema.methods.permCheckByUserDoc = async function(userDB, perm){
    //Get site-wide rank as number, default to anon for anonymous users
    const rank = userDB ? permissionModel.rankToNum(userDB.rank) : permissionModel.rankToNum("anon");
    //Get channel rank as number
    const chanRank = permissionModel.rankToNum(await this.getChannelRankByUserDoc(userDB));
    //Get channel permission rank requirement as number
    const permRank = permissionModel.rankToNum(this.permissions[perm]);
    //Get site-wide rank requirement to override as number
    const overrideRank = permissionModel.rankToNum((await permissionModel.getPerms()).channelOverrides[perm]);
    //Get channel perm check result
    const permCheck = (chanRank >= permRank);
    //Get site-wide override perm check result
    const overrideCheck = (rank >= overrideRank);

    return (permCheck || overrideCheck);
}

channelSchema.methods.permCheck = async function (user, perm){
    //Set userDB to null if we wheren't passed a real user
    if(user != null){
        var userDB = await userModel.findOne({user: user.user});
    }else{
        var userDB = null;
    }

    return await this.permCheckByUserDoc(userDB, perm)
}

channelSchema.methods.checkBanByUserDoc = async function(userDB){
    var foundBan = null;

    this.banList.forEach((ban) => {
        if(ban.user != null){
            if(ban.user.toString() == userDB._id.toString()){
                foundBan = ban;
            }
        }
    });

    return foundBan;
}

channelSchema.methods.getChanBans = async function(){
    //Create an empty list to hold our found bans
    var banList = [];
    //Populate the users in the banList
    await this.populate('banList.user');

    //Crawl through known bans
    this.banList.forEach((ban) => {
        
        var banObj = {
            banDate: ban.banDate,
            expirationDays: ban.expirationDays,
            banAlts: ban.banAlts,
        }

        //Check if the ban was permanent (expiration set before ban date)
        if(ban.expirationDays > 0){
            //if not calculate expiration date
            var expirationDate = new Date(ban.banDate);
            expirationDate.setDate(expirationDate.getDate() + ban.expirationDays);

            //Set calculated expiration date
            banObj.expirationDate = expirationDate;
            banObj.daysUntilExpiration = ban.getDaysUntilExpiration();
        }

        //Setup user object (Do this last to keep it at bottom for human-readibility of json :P)
        banObj.user = {
            id: ban.user.id,
            user: ban.user.user,
            img: ban.user.img,
            date: ban.user.date
        }

        banList.push(banObj);
    });

    return banList;
}

channelSchema.methods.banByUserDoc = async function(userDB, expirationDays, banAlts){
    //Throw a shitfit if the user doesn't exist
    if(userDB == null){
        throw new Error("Cannot ban non-existant user!");
    }

    const foundBan = await this.checkBanByUserDoc(userDB);

    if(foundBan != null){
        throw new Error("User already banned!");
    }

    //Create a new ban document based on input
    const banDoc = {
        user: userDB._id,
        expirationDays,
        banAlts
    }

    const activeChan = server.channelManager.activeChannels.get(this.name);
    if(activeChan != null){
        const userConn = activeChan.userList.get(userDB.user);
        if(userConn != null){
            if(expirationDays < 0){
                userConn.disconnect("You have been permanently banned from this channel!");
            }else{
                userConn.disconnect(`You have been banned from this channel for ${expirationDays} day(s)!`);
            }
        }
    }

    //Push the ban to the list
    this.banList.push(banDoc);

    await this.save();
}

channelSchema.methods.ban = async function(user, expirationDays, banAlts){
    const userDB = await userModel.find({user});
    return await this.banByUserDoc(userDB, expirationDays, banAlts);
}

channelSchema.methods.unbanByUserDoc = async function(userDB){
    //Throw a shitfit if the user doesn't exist
    if(userDB == null){
        throw new Error("Cannot ban non-existant user!");
    }

    const foundBan = await this.checkBanByUserDoc(userDB);

    if(foundBan == null){
        throw new Error("User already unbanned!");
    }

    //You know I can't help but feel like an asshole for looking for the index of something I just pulled out of an array using forEach...
    //Then again this is such an un-used function that the issue of code re-use overshadows performance 
    //I mean how often are we REALLY going to be un-banning users from channels?
    const banIndex = this.banList.indexOf(foundBan);
    this.banList.splice(banIndex,1);
    return await this.save();
}

channelSchema.methods.unban = async function(user){
    const userDB = await userModel.find({user});
    return await this.unbanByUserDoc(userDB);
}

channelSchema.methods.nuke = async function(confirm){
    if(confirm == "" || confirm == null){
        throw new Error("Empty Confirmation String!");
    }else if(confirm != this.name){
        throw new Error("Bad Confirmation String!");
    }

    //Annoyingly there isnt a good way to do this from 'this'
    var oldChan = await this.deleteOne();

    if(oldChan.deletedCount == 0){
        throw new Error("Server Error: Unable to delete channel! Please report this error to your server administrator, and with timestamp.");
    }
}

module.exports = mongoose.model("channel", channelSchema);