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

/**
 * Class contianing client-side message post-processing code
 */
class chatPostprocessor{
    /**
     * Instantiates a new Chat Post-Processor object
     * @param {channel} client - Parent client Management Object
     */
    constructor(client){
        /**
         * Parent Client Management Object
         */
        this.client = client;
    }

    /**
     * Post-Processes a single message from the server and returns a presntable DOM Node
     * @param {Node} chatEntry - Chat entry generated by initial chatBox method
     * @param {Object} rawData - Raw data from server
     * @returns {Node} Post-Processed Chat Entry
     */
    postprocess(rawData, pm = false){
        //Create empty array to hold filter spans
        this.filterSpans = [];
        //Set raw message data
        this.rawData = rawData;
        //Set current chat nodes
        this.buildEntry(pm);

        //Split the chat message into an array of objects representing each word/chunk
        this.splitMessage();

        //Process Qoutes
        this.processQoute();

        //Re-Hydrate and Inject links and embedded media into un-processed placeholders
        this.processLinks();

        //Inject clickable command examples
        this.processCommandExamples();

        //Inject clickable channel names
        this.processChannelNames();

        //Inject clickable usernames
        this.processUsernames();

        //Detect inline spoilers
        this.processSpoilers();

        //Detect inline strikethrough
        this.processStrikethrough();

        //Detect inline bold text
        this.processBold();

        //Detect inline italics
        this.processItalics();

        //Inject whitespace into long ass-words
        this.addWhitespace();

        //Handle non-standard chat types
        this.handleChatType();
                
        //Inject the pre-processed chat hyper-text into the chatEntry node
        this.injectBody();

        //Return the pre-processed node
        return this.chatEntry;
    }

    buildEntry(pm){
        const classSuffix = pm ? 'pm-panel-sesh' : 'chat';
        const classSuffixAlt = pm ? classSuffix : 'chat-panel';
        //Create chat-entry span
        this.chatEntry = document.createElement('span');
        this.chatEntry.classList.add(`${classSuffixAlt}-buffer`,`${classSuffix}-entry`,`${classSuffix}-entry-${this.rawData.user}`);

        //Create high-level label
        var highLevel = document.createElement('p');
        highLevel.classList.add(`${classSuffixAlt}-buffer`,`${classSuffix}-entry-high-level`,"high-level");
        highLevel.textContent = utils.unescapeEntities(`${this.rawData.highLevel}`);
        this.chatEntry.appendChild(highLevel);

        //If we're not using classic flair
        if(this.rawData.flair != "classic"){
            //Use flair
            var flair = `flair-${this.rawData.flair}`;
        //Otherwise
        }else{
            //Pull user's assigned color from the color map
            var flair =  this.client.userList.colorMap.get(this.rawData.user);
        }

        //Create username label
        var userLabel = document.createElement('p');
        userLabel.classList.add(`${classSuffixAlt}-buffer`, `${classSuffix}-entry-username`, );
        
        //Create color span
        var flairSpan = document.createElement('span');
        flairSpan.classList.add(`${classSuffix}-entry-flair-span`, flair);
        flairSpan.innerHTML = this.rawData.user;

        //Inject flair span into user label before the colon
        userLabel.innerHTML = `${flairSpan.outerHTML}: `;

        //Append user label
        this.chatEntry.appendChild(userLabel);

        //Create chat body
        this.chatBody = document.createElement('p');
        this.chatBody.classList.add(`${classSuffixAlt}-buffer`,`${classSuffix}-entry-body`);

        //Append chat body to chat entry
        this.chatEntry.appendChild(this.chatBody);
    }

    /**
     * Splits message into an array of Word Objects for further processing
     */
    splitMessage(){
        //Create an empty array to hold the body
        this.messageArray = [];

        //Unescape any sanatized char codes as we use .textContent for double-safety, and to prevent splitting of char codes
        //Split string by word-boundries on words and non-word boundries around whitespace, 
        //with negative lookaheads to exclude file seperators so we don't split link placeholders, dashes so we dont split usernames and other things, and accented characters to keep those from splitting boundries too 
        //Also split by any invisble whitespace as a crutch to handle mushed links/emotes
        //If we can one day figure out how to split non-repeating special chars instead of special chars with whitespace, that would be perf, unfortunately my brain hasn't rotted enough to understand regex like that just yet.
        const splitString = utils.unescapeEntities(this.rawData.msg).split(/(?<!-)(?<!␜)(?=\w)\b|(?!-|[\u00C0-\u017F])(?<=\w)\b|(?=\s)\B|(?<=\s)\B|​/g);

        //for each word in the splitstring
        splitString.forEach((string) => {
            //create a word object
            const wordObj = {
                string: string,
                filterClasses: [],
                type: "word"
            }

            //Add it to our body array
            this.messageArray.push(wordObj);
        });
    }

    /**
     * Injects word objects into chat-entry as proper DOM Nodes
     */
    injectBody(){
        //Create an empty array to hold the objects to inject
        const injectionArray = [];

        //For each word object
        this.messageArray.forEach((wordObj) => {
            if(wordObj.type == 'word'){
                //Create span node
                const span = document.createElement('span');

                //Set span filter classes
                span.classList.add(...wordObj.filterClasses);

                //Set span text
                span.textContent = wordObj.string;
                
                //Inject node into array
                injectionArray.push(span);
            }else if(wordObj.type == 'link'){
                //Create a link node from our link
                const link = document.createElement('a');
                link.classList.add('chat-link', ...wordObj.filterClasses);
                link.href = wordObj.link;
                link.target = "_blank";
                //Use textContent to be safe since links can't be escaped serverside
                link.textContent = wordObj.link;

                //Append node to chatBody
                combineNode(wordObj, link);
            }else if(wordObj.type == 'deadLink'){
                //Create a text span node from our link
                const badLink = document.createElement('a');
                badLink.classList.add('chat-dead-link', 'danger-link', ...wordObj.filterClasses);
                badLink.href = wordObj.link;
                badLink.target = "_blank";
                //Use textContent to be safe since links can't be escaped serverside
                badLink.textContent = wordObj.link;

                //Append node to chatBody
                combineNode(wordObj, badLink);
            }else if(wordObj.type == 'malformedLink'){
                //Create a text span node from our link
                const malformedLink = document.createElement('span');
                malformedLink.classList.add('chat-malformed-link', ...wordObj.filterClasses);
                //Use textContent to be safe since links can't be escaped (this is why we don't just add it using injectString)
                //arguably we could sanatize malformed links serverside since they're never actually used as links
                malformedLink.textContent = wordObj.link;

                //Append node to chatBody
                combineNode(wordObj, malformedLink);
            }else if(wordObj.type == 'image'){
                //Create an img node from our link
                const img = document.createElement('img');
                img.classList.add('chat-img', ...wordObj.filterClasses);
                img.src = wordObj.link;

                //Look for an emote by link since emotes are tx'd as bare links
                const emote = this.client.chatBox.commandPreprocessor.getEmoteByLink(wordObj.link);

                //If this is a known emote
                if(emote != null){
                    //Set the hover text to the emote's name
                    img.title = `[${emote.name}]`;
                }

                //Append node to chatBody
                combineNode(wordObj, img);
            }else if(wordObj.type == 'video'){
                //Create a video node from our link
                const vid = document.createElement('video');
                vid.classList.add('chat-video', ...wordObj.filterClasses);
                vid.src = wordObj.link;
                vid.controls = false;
                vid.autoplay = true;
                vid.loop = true;
                vid.muted = true;

                //Look for an emote by link since emotes are tx'd as bare links
                const emote = this.client.chatBox.commandPreprocessor.getEmoteByLink(wordObj.link);

                //If this is a known emote
                if(emote != null){
                    //Set the hover text to the emote's name
                    vid.title = `[${emote.name}]`;
                }

                combineNode(wordObj, vid);
            }else if(wordObj.type == 'command'){
                //Create link node
                const link = document.createElement('a');
                //Set class
                link.classList.add('chat-link', ...wordObj.filterClasses);
                //Set href and inner text
                link.href = "javascript:";
                link.textContent = wordObj.command;

                //Add chatbox functionality
                link.addEventListener('click', () => {this.client.chatBox.transmit(wordObj.command)});

                //We don't have to worry about injecting this into whitespace since there shouldn't be any here.
                injectionArray.push(link);
            }else if(wordObj.type == "username"){
                //Create link node
                const link = document.createElement('a');
                //set class
                link.classList.add(wordObj.color, ...wordObj.filterClasses);
                //Set href and inner text
                link.href = "javascript:";
                link.textContent = wordObj.string;

                //add chatbox functionality
                link.addEventListener('click', () => {this.client.chatBox.chatPrompt.value += `${wordObj.string} `});

                //We don't have to worry about injecting this into whitespace since there shouldn't be any here.
                injectionArray.push(link);
            }else if(wordObj.type == "channel"){
                //Create link node
                const link = document.createElement('a');
                //set class
                link.classList.add('chat-link', ...wordObj.filterClasses);
                //Set href and inner text
                link.href = `/c/${wordObj.chan}`;
                link.target = "_blank"
                link.textContent = wordObj.string;

                //We don't have to worry about injecting this into whitespace since there shouldn't be any here.
                injectionArray.push(link);
            }else{
                console.warn("Unknown chat postprocessor word type:");
                console.warn(wordObj);
            }

        });

        //For each item found in the injection array
        for(let itemIndex in injectionArray){
            const item = injectionArray[itemIndex];

            //Currently this doesnt support multiple overlapping span-type filters
            //not a huge deal since we only have once (spoiler)
            //All others can be applied per-node without any usability side effects
            const curFilter = this.filterSpans.filter(filterFilters)[0];
            let appendBody = this.chatBody;

            //If we have a filter span
            if(curFilter != null){
                //If we're beggining the array
                if(itemIndex == curFilter.index[0]){
                    //Create the span
                    appendBody = document.createElement('span');
                    //Label it for what it is
                    appendBody.classList.add(curFilter.class);
                    //Add it to the chat body
                    this.chatBody.appendChild(appendBody);
                //Otherwise
                }else{
                    //Use the existing span
                    appendBody = (this.chatBody.children[this.chatBody.children.length - 1]);
                }
            }

            //Append the node to our chat body
            appendBody.appendChild(item);

            function filterFilters(filter){
                //If the index is within the filter span
                return filter.index[0] <= itemIndex && filter.index[1] >= itemIndex;
            }
        }

        //Like string.replace except it actually injects the node so we can keep things like event handlers
        function combineNode(wordObj, node, placeholder = '␜'){
            //Split string by the placeholder so we can keep surrounding whitespace
            const splitWord = wordObj.string.split(placeholder, 2);

            //Create combined node
            const combinedSpan = document.createElement('span');

            //Add the first part of the text
            combinedSpan.textContent = splitWord[0];

            //Add in the requestd node
            combinedSpan.appendChild(node);

            //Finish it off with the last bit of text
            combinedSpan.insertAdjacentText('beforeend', splitWord[1]);

            //Add to injection array as three nested items to keep arrays lined up
            injectionArray.push(combinedSpan);
        } 
    }

    /**
     * Processes qouted text in chat
     */
    processQoute(){
        //If the message starts off with '>'
        if(this.messageArray[0].string[0] == '>'){
            this.chatBody.classList.add("qoute");
        }
    }

    /**
     * Processes clickable command examples in chat
     */
    processCommandExamples(){
        //for each word object in the body
        this.messageArray.forEach((wordObj, wordIndex) => {
            //if the word object hasn't been pre-processed elsewhere
            if(wordObj.type == "word"){
                //Get last char of current word
                const lastChar = wordObj.string[wordObj.string.length - 1];

                //if the last char is !
                if(lastChar == '!' || lastChar == '/'){
                    //get next word
                    const nextWord = this.messageArray[wordIndex + 1];
                    //if we have another word
                    if(nextWord != null){
                        const command = lastChar + nextWord.string;
                        //Take out the command marker
                        this.messageArray[wordIndex].string = wordObj.string.slice(0,-1);

                        const commandObj = {
                            type: "command",
                            string: nextWord.string,
                            filterClasses: [],
                            command: command
                        }

                        this.messageArray[wordIndex + 1] = commandObj;
                    }
                }
            }
        });
    }

    /**
     * Processes clickable channel names in chat
     */
    processChannelNames(){
        //for each word object in the body
        this.messageArray.forEach((wordObj, wordIndex) => {
            //if the word object hasn't been pre-processed elsewhere
            if(wordObj.type == "word"){
                //Get last char of current word with slashes pounds
                const lastChar = wordObj.string[wordObj.string.length - 1];
                const secondLastChar = wordObj.string[wordObj.string.length - 2];

                //if the last char is # and the second to last char isn't & or # (avoid spoilers)
                if(lastChar == '#' && secondLastChar != '#'){
                    //get next word
                    const nextWord = this.messageArray[wordIndex + 1];
                    //if we have another word
                    if(nextWord != null){
                        //Take out the chan marker
                        this.messageArray[wordIndex].string = wordObj.string.slice(0,-1);

                        const commandObj = {
                            type: "channel",
                            string: lastChar + nextWord.string,
                            filterClasses: [],
                            chan: nextWord.string
                        }

                        this.messageArray[wordIndex + 1] = commandObj;
                    }
                }
            }
        });
    }

    /**
     * Processes clickable username callouts in chat
     */
    processUsernames(){
        //for each word object in the body
        this.messageArray.forEach((wordObj, wordIndex) => {
            //if the word object hasn't been pre-processed elsewhere
            if(wordObj.type == "word"){
                //Check for user and get their color
                const color = this.client.userList.colorMap.get(wordObj.string);

                //If the current word is the username of a connected user
                if(color != null){
                    //Mark it as so
                    this.messageArray[wordIndex].type = "username";
                    //Store their color
                    this.messageArray[wordIndex].color = color;
                }
            }
        });
    }

    /**
     * Injects invisible whitespace in long-ass words to prevent fucking up the chat buffer size
     */
    addWhitespace(){
        //for each word object in the body
        this.messageArray.forEach((wordObj, wordIndex) => {
            //if the word object hasn't been pre-processed elsewhere
            if(wordObj.type == "word"){
                //Create an empty array to hold our word
                var wordArray = [];
                //For each character in the string of the current word object
                this.messageArray[wordIndex].string.split("").forEach((char, charIndex) => {
                    //push the current character to the wordArray
                    wordArray.push(char);
                    //After eight characters
                    if(charIndex > 8){
                        //Push an invisible line-break character between every character
                        wordArray.push("​");
                    }

                });

                //Join the wordArray into a single string, and use it to set the current wordObject's string
                this.messageArray[wordIndex].string = wordArray.join("");
            }
        });
    }

    /**
     * Searches for text in-between a specific delimiter and runs a given callback against it
     * 
     * Internal command used by several text filters to prevent code re-writes
     * @param {String} delimiter - delimiter to search string by
     * @param {Function} cb - Callback function to run against found strings
     * @returns {Array} - list of found instances of filter
     */
    processFilter(delimiter, cb){
        //Create empty array to hold spoilers (keep this seperate at first for internal function use)
        const foundFilters = [];
        //Spoiler detection stage
        //For each word object in the message array
        main: for(let wordIndex = 0; wordIndex < this.messageArray.length; wordIndex++){
            //Get the current word object
            const wordObj = this.messageArray[wordIndex];
            
            //If its a regular word and contains '##'
            if(wordObj.type == 'word' && wordObj.string.match(utils.escapeRegex(delimiter))){

                //Crawl through detected spoilers
                for(let spoiler of foundFilters){
                    //If the current word object is part of a detected spoiler
                    if(wordIndex == spoiler[0] || wordIndex == spoiler[1]){
                        //ignore it and continue on to the next word object
                        continue main;
                    }
                }

                //Crawl throw word objects after the current one
                for(let endIndex = (wordIndex + 1); endIndex < this.messageArray.length; endIndex++){
                    //Get the current end object
                    const endObj = this.messageArray[endIndex];

                    //If its a regular word and contains '##'
                    if(endObj.type == 'word' && endObj.string.match(utils.escapeRegex(delimiter))){                   
                        //Setup the found filter array
                        const foundFilter = [wordIndex, endIndex];

                        //Scrape out delimiters
                        wordObj.string = wordObj.string.replaceAll(delimiter,'');
                        endObj.string = endObj.string.replaceAll(delimiter,'');

                        //Add it to the list of detected filters
                        foundFilters.push(foundFilter);

                        //Run the filter callback
                        cb(foundFilter)

                        //Break the nested end-detection loop
                        break;
                    }
                }
            }
        }

        return foundFilters;
    }

    /**
     * Processes in-line spoilers
     */
    processSpoilers(){
        //Process spoilers using '##' delimiter
        this.processFilter('##', (foundSpoiler)=>{
            //For each found spoiler add it to the list of found filter spans
            this.filterSpans.push({class: "spoiler", index: [foundSpoiler[0] + 1, foundSpoiler[1] - 1], delimiters: [foundSpoiler[0], foundSpoiler[1]]});
        });
    }

    /**
     * Processes in-line Strike-through
     */
    processStrikethrough(){
        //Process strikethrough's using '~~' delimiter
        this.processFilter('~~', (foundStrikethrough)=>{
            for(let wordIndex = foundStrikethrough[0]; wordIndex < foundStrikethrough[1]; wordIndex++){
                this.messageArray[wordIndex].filterClasses.push("strikethrough");
            }
        })
    }

    /**
     * Processes in-line Bold/Strong text
     */
    processBold(){
        //Process strong text using '*' delimiter
        this.processFilter('**', (foundStrikethrough)=>{
            for(let wordIndex = foundStrikethrough[0]; wordIndex < foundStrikethrough[1]; wordIndex++){
                this.messageArray[wordIndex].filterClasses.push("bold");
            }
        })
    }

    /**
     * Processes in-line Italics
     */
    processItalics(){
        //Process italics using '__' delimiter
        this.processFilter('*', (foundStrikethrough)=>{
            for(let wordIndex = foundStrikethrough[0]; wordIndex < foundStrikethrough[1]; wordIndex++){
                this.messageArray[wordIndex].filterClasses.push("italics");
            }
        })
    }

    /**
     * Processes clickable links and embedded media
     */
    processLinks(){
        //If we don't have links
        if(this.rawData.links == null){
            //Don't bother
            return;
        }

        //For every link received in this message
        this.rawData.links.forEach((link, linkIndex) => {
            //For every word obj in the message array
            this.messageArray.forEach((wordObj, wordIndex) => {
                //Check current wordobj for link (placeholder may contain whitespace with it)
                if(wordObj.string.match(`␜${linkIndex}`)){
                    //Set current word object in the body array to the new link object
                    this.messageArray[wordIndex] = {
                        //Don't want to use a numbered placeholder to make this easier during body injection
                        //but we also don't want to clobber any surrounding whitespace
                        string: wordObj.string.replace(`␜${linkIndex}`, '␜'),
                        link: link.link,
                        type: link.type,
                        filterClasses: []
                    }
                }
            })
        });
    }

    /**
     * Marks chat nodes in-case of non-standard chat types
     */
    handleChatType(){
        if(this.rawData.type == "whisper"){
            //add whisper class
            this.chatBody.classList.add('whisper');
        }else if(this.rawData.type == "announcement"){
            //Squash the high-level
            this.chatEntry.querySelector('.high-level').remove();

            //Get the username and make it into an announcement title (little hacky but this *IS* postprocessing)
            const userNode = this.chatEntry.querySelector('.chat-entry-username');
            userNode.textContent = `${userNode.textContent.slice(0,-2)} Announcement`;

            //Add/remove relevant classes
            userNode.classList.remove('chat-entry-username');
            userNode.classList.add('announcement-title');
            this.chatBody.classList.add('announcement-body');
            this.chatEntry.classList.add('announcement');
        }else if(this.rawData.type == "toke"){
            //Squash the high-level
            this.chatEntry.querySelector('.high-level').remove();

            //remove the username
            this.chatEntry.querySelector('.chat-entry-username').remove();

            //Add toke/tokewhisper class
            this.chatBody.classList.add("toke");
        }else if(this.rawData.type == "tokewhisper"){
            //Squash the high-level
            this.chatEntry.querySelector('.high-level').remove();

            //remove the username
            this.chatEntry.querySelector('.chat-entry-username').remove();

            //Add toke/tokewhisper class
            this.chatBody.classList.add("tokewhisper","serverwhisper");
        }else if(this.rawData.type == "spoiler"){
            //Set whole-body spoiler
            this.chatBody.classList.add("spoiler");
        }else if(this.rawData.type == "strikethrough"){
            //Set whole-body spoiler
            this.chatBody.classList.add("strikethrough");
        }else if(this.rawData.type == "bold"){
            //Set whole-body spoiler
            this.chatBody.classList.add("bold");
        }else if(this.rawData.type == "italics"){
            //Set whole-body spoiler
            this.chatBody.classList.add("italics");
        }
    }
}