# JSDoc: Source: schemas/user/userSchema.js

# Source: schemas/user/userSchema.js

    /*Canopy - The next generation of stoner streaming software
    Copyright (C) 2024-2025 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');

    //local imports
    //server
    const server = require('../../server');
    //DB Models
    const statModel = require('../statSchema');
    const flairModel = require('../flairSchema');
    const permissionModel = require('../permissionSchema');
    const emoteModel = require('../emoteSchema');
    const emailChangeModel = require('./emailChangeSchema');
    const playlistSchema = require('../channel/media/playlistSchema');
    //Utils
    const hashUtil = require('../../utils/hashUtils');
    const mailUtil = require('../../utils/mailUtils');
    const loggerUtils = require('../../utils/loggerUtils')

    /**
     * Mongoose Schema for a document representing a single canopy user
     */
    const userSchema = new mongoose.Schema({
        id: {
            type: mongoose.SchemaTypes.Number,
            required: true
        },
        user: {
            type: mongoose.SchemaTypes.String,
            maxLength: 22,
            required: true,
        },
        pass: {
            type: mongoose.SchemaTypes.String,
            required: true
        },
        email: {
            type: mongoose.SchemaTypes.String,
            optional: true,
            default: ""
        },
        date: {
            type: mongoose.SchemaTypes.Date,
            required: true,
            default: new Date()
        },
        rank: {
            type: mongoose.SchemaTypes.String,
            required: true,
            enum: permissionModel.rankEnum,
            default: "user"
        },
        tokes: {
            type: mongoose.SchemaTypes.Map,
            required: true,
            default: new Map()
        },
        img: {
            type: mongoose.SchemaTypes.String,
            required: true,
            default: "/img/johnny.png"
        },
        bio: {
            type: mongoose.SchemaTypes.String,
            required: true,
            //Calculate max length by the validator max length and the size of an escaped character
            maxLength: 1000 * 6,
            default: "Bio not set!"
        },
        pronouns:{
            type: mongoose.SchemaTypes.String,
            optional: true,
            //Calculate max length by the validator max length and the size of an escaped character
            maxLength: 15 * 6,
            default: ""
        },
        signature: {
            type: mongoose.SchemaTypes.String,
            required: true,
            //Calculate max length by the validator max length and the size of an escaped character
            maxLength: 25 * 6,
            default: "Signature not set!"
        },
        highLevel: {
            type: mongoose.SchemaTypes.Number,
            required: true,
            min: 0,
            max: 10,
            default: 0
        },
        flair: {
            type: mongoose.SchemaTypes.ObjectID,
            default: null,
            ref: "flair"
        },
        //Not re-using the site-wide schema because post/pre save should call different functions
        emotes: [{
            name:{
                type: mongoose.SchemaTypes.String,
                required: true
            },
            link:{
                type: mongoose.SchemaTypes.String,
                required: true
            },
            type:{
                type: mongoose.SchemaTypes.String,
                required: true,
                enum: emoteModel.typeEnum,
                default: emoteModel.typeEnum[0]
            }
        }],
        recentIPs: [{
            ipHash: {
                type: mongoose.SchemaTypes.String,
                required: true
            },
            firstLog: {
                type: mongoose.SchemaTypes.Date,
                required: true,
                default: new Date()
            },
            lastLog: {
                type: mongoose.SchemaTypes.Date,
                required: true,
                default: new Date()
            }
        }],
        alts:[{
            type: mongoose.SchemaTypes.ObjectID,
            ref: "user"
        }],
        playlists: [playlistSchema]
    });

    //This is one of those places where you really DON'T want to use an arrow function over an anonymous one!
    /**
     * Pre-Save function for user document, handles password hashing, flair updates, emote refreshes, and kills sessions upon rank change
     */
    userSchema.pre('save', async function (next){

        //If the password was changed
        if(this.isModified("pass")){
            //Hash that sunnovabitch, no questions asked.
            this.pass = hashUtil.hashPassword(this.pass);
        }

        //If the flair was changed
        if(this.isModified("flair")){
            //Get flair properties
            await this.populate('flair');

            if(permissionModel.rankToNum(this.rank) < permissionModel.rankToNum(this.flair.rank)){
                throw loggerUtils.exceptionSmith(`User '${this.user}' does not have a high enough rank for flair '${this.flair.displayName}'!`, "unauthorized");
            }
        }

        //Ensure we don't have empty flair
        if(this.flair == null){
            const flairDB = await flairModel.findOne({});
            this.flair = flairDB._id;
        }

        //If rank was changed
        if(this.isModified("rank")){
            //force a full log-out
            await this.killAllSessions("Your site-wide rank has changed. Sign-in required.");
        }

        //if emotes where modified
        if(this.isModified('emotes')){
            //Get the active Channel object from the application side of the house
            server.channelManager.crawlConnections(this.user, (conn)=>{
                //Send out emotes to each one
                conn.sendPersonalEmotes(this);
            });
        }

        //All is good, continue on saving.
        next();
    });

    /**
     * Pre-Delete function for user accounts, drops connections and rips it self out from alt accounts
     */
    userSchema.post('deleteOne', {document: true}, async function (){
        //Kill any active sessions
        await this.killAllSessions("If you're seeing this, your account has been deleted. So long, and thanks for all the fish! <3");

        //Populate alts
        await this.populate('alts');

        //iterate through alts
        for(alt in this.alts){
            //Find the index of the alt entry for this user inside of the alt users array of alt users
            const altIndex = this.alts[alt].alts.indexOf(this._id);

            //splice the entry for this user out of the alt users array of alt users
            this.alts[alt].alts.splice(altIndex,1);

            //Save the alt user
            await this.alts[alt].save();
        }
    });

    //statics
    /**
     * Registers a new user account with given information
     * @param {Object} userObj - Object representing user to register, generated by the client
     * @param {String} ip - IP Address of connection registering the account
     */
    userSchema.statics.register = async function(userObj, ip){
        //Pull values from user object
        const {user, pass, passConfirm, email} = userObj;

        //Check password confirmation matches
        if(pass == passConfirm){
            //Look for a user (case insensitive)
            var userDB = await this.findOne({user: new RegExp(user, 'i')});

            //If the user is found or someones trying to impersonate tokeboi
            if(userDB || user.toLowerCase() == "tokebot"){
                throw loggerUtils.exceptionSmith("User name/email already taken!", "validation");
            }else{
                //Increment the user count, pulling the id to tattoo to the user
                const id = await statModel.incrementUserCount();

                //Create user document in the database
                const newUser = await this.create({id, user, pass});

                //Tattoo the hashed IP used to register to the new user
                await newUser.tattooIPRecord(ip);

                //if we submitted an email
                if(email != null){
                    const requestDB = await emailChangeModel.create({user: newUser._id, newEmail: email, ipHash: ip});

                    await mailUtil.sendAddressVerification(requestDB, newUser, email)
                }
            }
        }else{
            throw loggerUtils.exceptionSmith("Confirmation password doesn't match!", "validation");
        }
    }

    /**
     * Authenticates a username and password pair
     * @param {String} user - Username of account to login as
     * @param {String} pass - Password to authenticat account
     * @param {String} failLine - Line to paste into custom error upon login failure
     * @returns {Mongoose.Document} - User DB Document upon success
     */
    userSchema.statics.authenticate = async function(user, pass, failLine = "Bad Username or Password."){
        //check for missing pass
        if(!user || !pass){
            throw loggerUtils.exceptionSmith("Missing user/pass.", "validation");
        }

        //get the user if it exists
        const userDB = await this.findOne({ user:  new RegExp(user, 'i')});

        //if not scream and shout
        if(!userDB){
            badLogin();
        }

        //Check our password is correct
        if(userDB.checkPass(pass)){
            return userDB;
        }else{
            //if not scream and shout
            badLogin();
        }

        //standardize bad login response so it's unknown which is bad for security reasons.
        function badLogin(){
            throw loggerUtils.exceptionSmith(failLine, "unauthorized");
        }
    }

    /**
     * Gets profile by username
     * @param {String} user - name of user to find
     * @param {Boolean} includeEmail - Whether or not to include email in the final result
     * @returns {Object} A network-friendly browser-digestable Object representing a single user profile
     */
    userSchema.statics.findProfile = async function(user, includeEmail = false){
        //Catch null users
        if(user == null || user.user == null){
            return null;
        //If someone's looking for tokebot
        }else if(user.user.toLowerCase() == "tokebot"){
            //fake a profile hashtable for tokebot
            const profile = {
                id: -420,
                user: "Tokebot",
                date: (await statModel.getStats()).firstLaunch,
                tokes: await statModel.getTokeCommandCounts(),
                tokeCount: await statModel.getTokeCount(),
                img: "/img/johnny.png",
                signature: "!TOKE",
                bio: "!TOKE OR DIE!"
            };

            //return the faked profile
            return profile;
        }else{
            //find user
            const userDB = await this.findOne({user: user.user});

            //If we don't find a user just return a null profile
            if(userDB == null){
                return null
            }

            //return the profile
            return userDB.getProfile(includeEmail);
        }

    }

    /**
     * Tattoos a single toke callout to all the users within it
     * @param {Map} tokemap - Map containing list of users and the toke command they used to join the toke
     */
    userSchema.statics.tattooToke = function(tokemap){
        //For each toke, asynchronously:
        tokemap.forEach(async (toke, user) => {
            //get user
            const userDB = await this.findOne({user});

            //Check that the user exists (might come in handy for future treez.one integration?)
            if(userDB != null){
                var tokeCount = userDB.tokes.get(toke);

                //if this is the first time using this toke command
                if(tokeCount == null){
                    //set toke count to one
                    tokeCount = 1;
                //otherwise
                }else{
                    //increment tokecount
                    tokeCount++;
                }

                //Set the toke count for the specific command
                userDB.tokes.set(toke, tokeCount);

                //Save the user doc to the database
                await userDB.save();

                //Would rather do this inside of tokebot but then our boi would have to wait for DB or pass a nasty-looking callback function
                //Crawl through active connections
                server.channelManager.crawlConnections(userDB.user, (conn)=>{
                        //Update used toke list
                        conn.sendUsedTokes(userDB);
                });
            }
        });
    }

    /**
     * Acquires a list of the entire userbase
     * @param {Boolean} fullList - Determines whether or not we're listing rank and email
     * @returns {Array} Array of objects containing user metadata
     */
    userSchema.statics.getUserList = async function(fullList = false){
        var userList = [];
        //Get all of our users
        const users = await this.find({});

        //Return empty if we don't find nuthin'
        if(users == null){
            return [];
        }

        //For each user
        users.forEach((user)=>{
            //create a user object with limited properties (safe for public consumption)
            var userObj = {
                id: user.id,
                user: user.user,
                img: user.img,
                date: user.date
            }

            //Put together a spicier version for admins when told so (permission checks should happen before this is called)
            if(fullList){
                userObj.rank = user.rank,
                userObj.email = user.email
            }

            //Add user object to list
            userList.push(userObj);
        });

        //return the userlist
        return userList;
    }

    /**
     * Process and Deletes Aged IP Records
     */
    userSchema.statics.processAgedIPRecords = async function(){
        //Pull full userlist
        const users = await this.find({});

        //Not a fan of iterating through the DB but there doesn't seem to be a way to search for a doc based on the properties of it's subdoc
        for(let userIndex in users){
            //Pull user record from users by index
            const userDB = users[userIndex];
            //For every recent ip within the user
            for(let recordIndex in userDB.recentIPs){
                //Pull record from recent IPs by index
                const record = userDB.recentIPs[recordIndex];
                //Check how long it's been since we've last seen the IP
                const daysSinceLastUse = ((new Date() - record.lastLog) / (1000 * 60 * 60 * 24)).toFixed(1);

                //If it's been more than a week
                if(daysSinceLastUse >= 7){
                    //Splice out the IP record
                    userDB.recentIPs.splice(recordIndex, 1);
                    //No reason to wait on this since we're done with this user
                    await userDB.save();
                }
            }
        }
    }

    //methods
    /**
     * Checks password against a user document
     * @param {String} pass - Password to authenticate
     * @returns {Boolean} True if authenticated
     */
    userSchema.methods.checkPass = function(pass){
        return hashUtil.comparePassword(pass, this.pass)
    }

    /**
     * Lists authenticated sessions for a given user document
     * @returns {Promise} Promise containing an array of active user sessions for a given user
     */
    userSchema.methods.getAuthenticatedSessions = async function(){
        var returnArr = [];

        //retrieve active sessions (they really need to implement this shit async already)
        return new Promise((resolve) => {
            server.store.all((err, sessions) => {
                //You guys ever hear of a 'not my' problem? Fucking y33tskies lmao, better use a try/catch
                if(err){
                    throw err;

                }

                //crawl through active sessions
                sessions.forEach((session) => {
                    //Skip un-authed sessions
                    if(session.user != null){
                        //if a session matches the current user
                        if(session.user.id == this.id){
                            //we return it
                            returnArr.push(session);
                        }
                    }
                });

                resolve(returnArr);
            });
        });

    }

    /**
     * Generates a network-friendly browser-digestable profile object for a sepcific user document
     * @param {Boolean} includeEmail - Whether or not to include the email address in the complete profile object
     * @returns {Object} Network-Friendly Browser-Digestable object containing profile metadata
     */
    userSchema.methods.getProfile = function(includeEmail = false){
        //Create profile hashtable
        const profile = {
            id: this.id,
            user: this.user,
            date: this.date,
            tokes: this.tokes,
            tokeCount: this.getTokeCount(),
            img: this.img,
            signature: this.signature,
            pronouns: this.pronouns,
            bio: this.bio
        };

        //Include the email if we need to
        if(includeEmail){
            profile.email = this.email;
        }

        //return profile hashtable
        return profile;
    }

    /**
     * Gets list of profiles of alt accounts related to the given user document
     * @returns {Array} List of alternate profiles
     */
    userSchema.methods.getAltProfiles = async function(){
        //Create an empty list to hold alt profiles
        const profileList = [];

        //populate the users alt list
        await this.populate('alts');

        //For every alt for the current user
        for(let alt of this.alts){
            //get the alts profile and push it to the profile list
            profileList.push(alt.getProfile());
        }

        //return our generated profile list
        return profileList;
    }

    /**
     * Sets flair for current user documetn
     * @param {String} flair - flair to set
     * @returns {Mongoose.Document} returns found flair document from DB
     */
    userSchema.methods.setFlair = async function(flair){
        //Find flair by name
        const flairDB = await flairModel.findOne({name: flair});
        //Set the users flair ref to the found flairs _id
        this.flair = flairDB._id;
        //Save the user
        await this.save();
        //return the found flair
        return flairDB;
    }

    /**
     * Gets number of tokes for a given user document
     * @returns {Number} Number of tokes recorded for the given user document
     */
    userSchema.methods.getTokeCount = function(){
        //Set tokeCount to 0
        var tokeCount = 0;

        //For each toke command the user has used
        this.tokes.forEach((commandCount) => {
            //Add the count for that specific command to the total
            tokeCount += commandCount;
        });

        //Return the amount of tokes recorded
        return tokeCount;
    }

    /**
     * Gets number of emotes for a given user document
     * @returns {Array} Array of objects representing emotes for the given user document
     */
    userSchema.methods.getEmotes = function(){
        //Create an empty array to hold our emote list
        const emoteList = [];

        //For each channel emote
        this.emotes.forEach((emote) => {
            //Push an object with select information from the emote to the emote list
            emoteList.push({
                name: emote.name,
                link: emote.link,
                type: emote.type
            });
        });

        //return the emote list
        return emoteList;
    }

    /**
     * Deletes an emote from the user Document
     * @param {String} name - Name of emote to delete
     */
    userSchema.methods.deleteEmote = async function(name){
        //Get index by emote name
        const emoteIndex = this.emotes.findIndex(checkName);

        //Splice out found emote
        this.emotes.splice(emoteIndex, 1);

        //Save the user doc
        await this.save();

        function checkName(emote){
            //return emotes
            return emote.name == name;
        }
    }

    /**
     * Gets list of user playlists
     * @returns {Array} Array of objects represnting a playlist containing media objects
     */
    userSchema.methods.getPlaylists = function(){
        //Create an empty array to hold our emote list
        const playlists = [];

        //For each channel emote
        for(let playlist of this.playlists){
            //Push an object with select information from the emote to the emote list
            playlists.push(playlist.dehydrate());
        }

        //return the emote list
        return playlists;
    }

    /**
     * Iterates through user playlists and calls a given callback function against them
     * @param {Function} cb - Callback function to call against user playlists
     */
    userSchema.methods.playlistCrawl = function(cb){
        for(let listIndex in this.playlists){
            //Grab the associated playlist
            playlist = this.playlists[listIndex];

            //Call the callback with the playlist and list index as arguments
            cb(playlist, listIndex);
        }
    }

    /**
     * Returns playlist by name
     * @param {String} name -  Playlist to get by name
     * @returns {Object} Object representing the requested playlist
     */
    userSchema.methods.getPlaylistByName = function(name){
        //Create null value to hold our found playlist
        let foundPlaylist = null;

        //Crawl through active playlists
        this.playlistCrawl((playlist, listIndex) => {
            //If we found a match based on name
            if(playlist.name == name){
                //Keep it
                foundPlaylist = playlist;
                //Pass down the list index
                foundPlaylist.listIndex = listIndex;
            }
        });

        //return the given playlist
        return foundPlaylist;
    }

    /**
     * Deletes a playlist from the user document by name
     * @param {String} name - Name of playlist to delete
     */
    userSchema.methods.deletePlaylistByName = async function(name){
        //Find the playlist
        const playlist = this.getPlaylistByName(name);

        //splice out the given playlist
        this.playlists.splice(playlist.listIndex, 1);

        //save the channel document
        await this.save();
    }

    /**
     * Salts, Hashes and Tattoo's IP address to user document in a privacy respecting manner
     * @param {String} ip - Plaintext IP Address to Salt, Hash and Tattoo
     */
    userSchema.methods.tattooIPRecord = async function(ip){
        //Hash the users ip
        const ipHash = hashUtil.hashIP(ip);

        //Look for a pre-existing entry for this ipHash
        const foundIndex = this.recentIPs.findIndex(checkHash);

        //If there is no entry
        if(foundIndex == -1){
            //Pull the entire userlist
            //TODO: update query to only pull users with recentIPs, so we aren't looping through inactive users
            const users = await this.model().find({});

            //create record object
            const record = {
                ipHash: ipHash,
                firstLog: new Date(),
                lastLog: new Date()
            };

            //We should really start using for loops and stop acting like its 2008
            //Though to be quite honest this bit would be particularly brutal without them
            //For every user in the userlist
            for(let curUser of users){
                //Ensure we're not checking the user against itself
                if(curUser._id != this._id){
                    //For every IP record in the current user
                    for(let curRecord of curUser.recentIPs){
                        //If it matches the current ipHash
                        if(curRecord.ipHash == ipHash){
                            //Check if we've already marked the user as an alt
                            const foundAlt = this.alts.indexOf(curUser._id);

                            //If these accounts aren't already marked as alts
                            if(foundAlt == -1){
                                //Add found user to this users alt list
                                this.alts.push(curUser._id);

                                //add this user to found users alt list
                                curUser.alts.push(this._id);

                                //Save changes to the found user, this user will save at the end of the function
                                await curUser.save();
                            }
                        }
                    }
                }
            }

            //Pop it into place
            this.recentIPs.push(record);

            //Save the user doc
            await this.save();
        //Otherwise, if we already have a record for this IP
        }else{
            //Update the last logged date for the found record
            this.recentIPs[foundIndex].lastLog = new Date();

            //Save the user doc
            await this.save();
        }

        //Look for matching ip record
        function checkHash(ipRecord){
            //return matching records
            return ipRecord.ipHash == ipHash;
        }
    }

    //note: if you gotta call this from a request authenticated by it's user, make sure to kill that session first!
    /**
     * Kills all sessions for a given user
     * @param {String} reason - Reason to kill user sessions
     */
    userSchema.methods.killAllSessions = async function(reason = "A full log-out from all devices was requested for your account."){
        //get authenticated sessions
        var sessions = await this.getAuthenticatedSessions();

        //crawl through and kill all sessions
        sessions.forEach((session) => {
            server.store.destroy(session.seshid);
        });

        //Tell the application side of the house to kick the user out as well
        server.channelManager.kickConnections(this.user, reason);
    }

    /**
     * Changes password for a given user document
     * @param {Object} passChange - passChange object handed down from Browser
     */
    userSchema.methods.changePassword = async function(passChange){
        if(this.checkPass(passChange.oldPass)){
            if(passChange.newPass == passChange.confirmPass){
                //Note: We don't have to worry about hashing here because the schema is written to do it auto-magically
                this.pass = passChange.newPass;

                //Save our password
                await this.save();

                //Kill all authed sessions for security purposes
                await this.killAllSessions("Your password has been reset.");
            }else{
                //confirmation pass doesn't match
                throw loggerUtils.exceptionSmith("Mismatched confirmation password!", "validation");
            }
        }else{
            //Old password wrong
            throw loggerUtils.exceptionSmith("Incorrect Password!", "validation");
        }

    }

    /**
     * Checks another user document against this one to see if they're alts
     * @param {Mongoose.Document} userDB - User document to check for alts against
     * @returns {Boolean} True if alt
     */
    userSchema.methods.altCheck = async function(userDB){
        //Found alt flag
        let foundAlt = false;

        for(alt in this.alts){
            //If we found a match
            if(this.alts[alt]._id.toString() == userDB._id.toString()){
                foundAlt = true;
            }
        }

        //return the results
        return foundAlt;
    }

    /**
     * Nukes user account from the face of the planet upon said user's request
     * @param {String} pass - Password to authenticate against before deleting
     */
    userSchema.methods.nuke = async function(pass){
        //Check we have a confirmation password
        if(pass == "" || pass == null){
            //scream and shout
            throw loggerUtils.exceptionSmith("No confirmation password!", "validation");
        }

        //Check that the password is correct
        if(this.checkPass(pass)){
            //delete the user
            var oldUser = await this.deleteOne();
        }else{
            //complain about a bad pass
            throw loggerUtils.exceptionSmith("Bad pass.", "unauthorized");
        }
    }

    module.exports.userModel = mongoose.model("user", userSchema);