/*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 which represents Canopy Chat Box UI
 */
class chatBox{
    /**
     * Instantiates a new Chat Box object
     * @param {channel} client - Parent client Management Object
     */
    constructor(client){
        /**
         * Parent Client Management Object
         */
        this.client = client

        /**
         * Whether or not chat-size should be locked to current media aspect ratio
         */
        this.aspectLock = true;

        /**
         * Whether or not the chat box should auto-scroll on new chat
         */
        this.autoScroll = true;

        /**
         * Whether or not the screen is currently in portrait mode
         */
        this.portrait = false;

        /**
         * Chat-Width Minimum while sized to media Aspect-Ratio
         */
        this.chatWidthMinimum = localStorage.getItem('chatWidthMin') / 100;

        /**
         * Chat Buffer Scroll Top on last scroll
         */
        this.lastPos = 0;

        /**
         * Height of Chat Buffer on last scroll
         */
        this.lastHeight = 0;

        /**
         * Width of Chat Buffer on last scroll
         */
        this.lastWidth = 0;

        /**
         * Click-Dragger Object for handling dynamic chat/video split re-sizing
         */
        this.clickDragger = new canopyUXUtils.clickDragger("#chat-panel-drag-handle", "#chat-panel-div");
        
        /**
         * Command Pre-Processor Object
         */
        this.commandPreprocessor = new commandPreprocessor(client);

        /**
         * Chat Post-Processor Object
         */
        this.chatPostprocessor = new chatPostprocessor(client);

        //Element Nodes
        /**
         * Channel Div
         */
        this.channelDiv = document.querySelector("#channel-flexbox");

        /**
         * Chat Panel Container Div
         */
        this.chatPanel = document.querySelector("#chat-panel-div");

        /**
         * High Level Selector
         */
        this.highSelect = document.querySelector("#chat-panel-high-level-select");

        /**
         * Flair Selector
         */
        this.flairSelect = document.querySelector("#chat-panel-flair-select");

        /**
         * Chat Buffer Div
         */
        this.chatBuffer = document.querySelector("#chat-panel-buffer-div");

        /**
         * Chat Prompt
         */
        this.chatPrompt = document.querySelector("#chat-panel-prompt");

        /**
         * Auto-Complete Placeholder
         */
        this.autocompletePlaceholder = document.querySelector("#chat-panel-prompt-autocomplete-filler");

        /**
         * Auto-Complete Display
         */
        this.autocompleteDisplay = document.querySelector("#chat-panel-prompt-autocomplete-display");

        /**
         * Settings Panel Icon
         */
        this.settingsIcon = document.querySelector("#chat-panel-settings-icon");

        /**
         * Admin Panel Icon
         */
        this.adminIcon = document.querySelector("#chat-panel-admin-icon");

        /**
         * Emote Icon
         */
        this.emoteIcon = document.querySelector("#chat-panel-emote-icon");

        /**
         * Send Chat/Command Button
         */
        this.sendButton = document.querySelector("#chat-panel-send-button");

        /**
         * Aspect Lock Icon
         * Seems weird to stick this in here, but the split is dictated by chat width :P
         */
        this.aspectLockIcon = document.querySelector("#media-panel-aspect-lock-icon");

        /**
         * Hide Chat Icon
         */
        this.hideChatIcon = document.querySelector("#chat-panel-div-hide");

        /**
         * Show Chat Icon
         */
        this.showChatIcon = document.querySelector("#media-panel-show-chat-icon");

        /**
         * re-occuring auto-scroll call
         * cope for the fact that postprocessedMessage objects in renderMessage aren't throwing load events
         */
        this.scrollInterval = setInterval(this.handleAutoScroll.bind(this), 200);

        //Setup functions
        this.setupInput();
        this.defineListeners();
        this.sizeToAspect();
    }

    /**
     * Defines input-related event listeners
     */
    setupInput(){
        //Chat bar
        this.chatPrompt.addEventListener("keydown", this.send.bind(this));
        this.chatPrompt.addEventListener("keydown", this.tabComplete.bind(this));
        this.chatPrompt.addEventListener("input", this.displayAutocomplete.bind(this));
        this.autocompleteDisplay.addEventListener("click", this.tabComplete.bind(this));
        this.sendButton.addEventListener("click", this.send.bind(this));
        this.settingsIcon.addEventListener("click", ()=>{this.client.cPanel.setActivePanel(new settingsPanel(client))});
        this.adminIcon.addEventListener("click", ()=>{this.client.cPanel.setActivePanel(new queuePanel(client))});
        this.emoteIcon.addEventListener("click", ()=>{this.client.cPanel.setActivePanel(new emotePanel(client))});

        //Header icons
        this.aspectLockIcon.addEventListener("click", this.lockAspect.bind(this));
        this.showChatIcon.addEventListener("click", ()=>{this.toggleUI()});
        this.hideChatIcon.addEventListener("click", ()=>{this.toggleUI()});
        this.highSelect.addEventListener("change", this.setHighLevel.bind(this));
        this.flairSelect.addEventListener("change", this.setFlair.bind(this));

        //Clickdragger/Resize
        this.clickDragger.handle.addEventListener("mousedown", this.unlockAspect.bind(this));
        this.clickDragger.handle.addEventListener("clickdrag", this.handleAutoScroll.bind(this));
        window.addEventListener("resize", this.resizeAspect.bind(this));

        //chatbuffer
        this.chatBuffer.addEventListener('scroll', this.scrollHandler.bind(this));
    }

    /**
     * Defines network-related event listners
     */
    defineListeners(){
        this.client.socket.on("chatMessage", this.displayChat.bind(this));
        this.client.socket.on("clearChat", this.clearChat.bind(this));
    }

    /**
     * Clears chat on command from server
     * @param {Object} data - Data from server
     */
    clearChat(data){
        //If we where passed a user to check
        if(data.user != null){
            var clearedChats = document.querySelectorAll(`.chat-entry-${data.user}`);
        }else{
            var clearedChats = document.querySelectorAll('.chat-entry');
        }

        //For each chat found
        clearedChats.forEach((chat) => {
            //fuckin' nukem!
            chat.remove();
        });
    }

    /**
     * Receives, Post-Processes, and Displays chat messages from server
     * @param {Object} data De-hydrated chat object from server
     */
    displayChat(data){
        //Append the post-processed chat-body to the chat buffer
        this.chatBuffer.appendChild(this.chatPostprocessor.postprocess(data));

        //Set size to aspect on launch
        this.resizeAspect();
    }

    /**
     * Concatenate Text into Chat Prompt
     * @param {String} text - Text to Concatenate
     */
    catChat(text){
        this.chatPrompt.value += text;
        this.displayAutocomplete();
    }

    /**
     * Calls a toke command out with a specified user
     * @param {String} user - User to toke with
     */
    tokeWith(user){
        this.transmit(user == this.client.user.user ? "!toke up fuckers" : `!toke up ${user}`);
    }

    /**
     * Pre-processes and sends text from chat prompt to server
     * @param {Event} event - Event passed down from Event Handler
     */
    send(event){
        if((!event || !event.key || event.key == "Enter") && this.chatPrompt.value){
            //Transmit the chat
            this.transmit(this.chatPrompt.value);

            //Clear our prompt and autocomplete nodes
            this.chatPrompt.value = "";
            this.autocompletePlaceholder.innerHTML = '';
            this.autocompleteDisplay.innerHTML = '';
        }
    }

    transmit(msg){
        //Pre-process chat string
        const preprocessedChat = this.commandPreprocessor.preprocess(msg);

        //If we passed through pre-processing
        if(preprocessedChat != false){
            //Send pre-processed chat data off to server
            this.client.socket.emit("chatMessage", preprocessedChat);
        }
    }

    /**
     * Displays auto-complete text against current prompt input
     * @param {Event} event - Event passed down from Event Handler
     */
    displayAutocomplete(event){
        //Find current match
        const match = this.checkAutocomplete();


        //Set placeholder to space out the autocomplete display
        this.autocompletePlaceholder.innerText = this.chatPrompt.value.replaceAll(" ","\u00A0");
        //Set the autocomplete display
        this.autocompleteDisplay.innerText = match.match.replace(match.word, '');
    }

    /**
     * Called upon tab-complete
     * @param {Event} event - Event passed down from Event Handler
     */
    tabComplete(event){
        //If we hit tab or this isn't a keyboard event
        if(event.key == "Tab" || event.key == null){
            //Prevent default action
            event.preventDefault();

            //return focus to the chat prompt
            this.chatPrompt.focus();

            //Grab autocompletion match
            const match = this.checkAutocomplete();

            //If we have a match
            if(match.match != ''){
                //Autocomplete the current word
                this.chatPrompt.value += match.match.replace(match.word, '');

                //Clear out the autocomplete display
                this.autocompleteDisplay.innerHTML = '';
            }
        }
    }

    /**
     * Checks string input against auto-complete dictionary to generate the best guess as to what the user is typing
     * @param {String} input - Current input from Chat Prompt
     * @returns {Object} Object containing word we where handed and the match we found
     */
    checkAutocomplete(input = this.chatPrompt.value){
        //Rebuild this fucker every time because it really doesn't take that much compute power and emotes/used tokes change
        //Worst case we could store it persistantly and update as needed but I think that might be much
        const dictionary = this.commandPreprocessor.buildAutocompleteDictionary();       

        //Split our input by whitespace
        const splitInput = input.split(/\s/g);
        //Get the current word we're working on
        const word = splitInput[splitInput.length - 1];
        let matches = [];


        //Run through dictionary sets
        for(let set of Object.keys(dictionary)){
            //Go through the current definitions of the current dictionary set
            //I went with a for loop instead of a filter beacuse I wanted to pull the processed definition with pre/postfix
            //and also directly push it into a shared array :P
            for(let cmd of dictionary[set].cmds){

                //Append the proper prefix/postfix to the current command
                const definition = (`${dictionary[set].prefix}${cmd[0]}${dictionary[set].postfix}`);

                //if definition starts with the current word and the command is enabled
                if((word == '' ? false : definition.indexOf(word) == 0) && cmd[1]){
                    //Add definition to match list
                    matches.push(definition);
                }
            }
        }

        //If we found jack shit
        if(matches.length == 0){
            //Return jack shit
            return {
                match: '',
                word
            };
        //If we got something
        }else{
            //return our top match
            return {
                match: matches[0],
                word
            };

        }
    }

    /**
     * Handles initial client meta-data dump from server upon connection
     * @param {Object} data - Data dump from server
     */
    handleClientInfo(data){
        this.updateFlairSelect(data.flairList, data.user.flair);
        this.updateHighSelect(data.user.highLevel);

        //If the chatbox is empty
        if(this.chatBuffer.childElementCount <= 0){
            //For each chat held in the chat buffer
            for(let chat of data.chatBuffer){
                //Display the chat
                this.displayChat(chat);
            }
        }

    }

    /**
     * Sets user high-level
     * @param {Event} event - Event passed down from Event Handler
     */
    setHighLevel(event){
        const highLevel = event.target.value;

        this.client.socket.emit("setHighLevel", {highLevel});
    }

    /**
     * Sets user flair
     * @param {Event} event - Event passed down from Event Handler
     */
    setFlair(event){
        const flair = event.target.value;

        this.client.socket.emit("setFlair", {flair});
    }

    /**
     * Handles High-Level updates from the server
     * @param {Number} highLevel - High Level to Set
     */
    updateHighSelect(highLevel){
        this.highSelect.value = highLevel;
    }

    /**
     * Handles flair updates from the server
     * @param {Array} fliarList - List of flairs to put into flair select
     * @param {String} fliar - Flair to set
     */
    updateFlairSelect(flairList, flair){
        //clear current flair select
        this.flairSelect.innerHTML = "";

        //For each flair in flairlist
        flairList.forEach((flair) => {
            //Create an option
            var flairOption = document.createElement('option');
            //Set the name and innerHTML
            flairOption.value = flair.name;
            flairOption.textContent = utils.unescapeEntities(flair.displayName);

            //Append it to the select
            this.flairSelect.appendChild(flairOption);
        });

        //Set the selected flair in the UI
        this.flairSelect.value = flair;
        //Re-style the UI, do this in two seperate steps in-case we're running for the first time and have nothing to replace.
        this.flairSelect.className = this.flairSelect.className.replace(/flair-\S*/, "");
        this.flairSelect.classList.add(`flair-${flair}`);
    }

    /**
     * Locks chat-size to aspect ratio of media
     * @param {Event} event - Event passed down from Event Handler
     */
    lockAspect(event){
        //prevent the user from breaking shit :P
        if(this.chatPanel.style.display != "none"){
            this.aspectLock = true;
            this.aspectLockIcon.style.display = "none";
            this.sizeToAspect();
        }
    }

    /**
     * Un-locks chat-size to aspect ratio of media
     * @param {Event} event - Event passed down from Event Handler
     */
    unlockAspect(event){
        //Disable aspect lock
        this.aspectLock = false;

        //Show aspect lock icon
        this.aspectLockIcon.style.display = "inline";
    }

    /**
     * Re-sizes chat back to aspect ratio on window re-size when chat box is aspect locked
     * Also prevents horizontal scroll-bars from chat/window resizing
     * @param {Event} event - Event passed down from Event Handler
     */
    resizeAspect(event){
        const playerHidden = this.client.player.playerDiv.style.display == "none";

        //If window is taller than wide and not in portrait mode, or vice-versa
        if(this.portrait != (window.innerWidth <= window.innerHeight)){
            //Toggle portrait mode
            this.togglePortrait();
        }

        //If the aspect is locked/the window is portrait and the player isn't hidden
        if((this.aspectLock || this.portrait) && !playerHidden){
            this.sizeToAspect();
        //Otherwise
        }else{
            //Fix the clickDragger on userlist
            this.client.userList.clickDragger.fixCutoff();
        }

        //Autoscroll chat in-case we fucked it up
        this.handleAutoScroll();
    }

    /**
     * Re-sizes chat box relative to media aspect ratio
     */
    sizeToAspect(){
        //If the chat panel is visible
        if(this.chatPanel.style.display != "none"){
            //If our window width is more than or equal to window height (not portrait mode)
            if(!this.portrait){
                //Get target video width by multiplying media ratio by window height
                var targetVidWidth = this.client.player.getRatio() * this.chatPanel.getBoundingClientRect().height;
                //Get target chat width my subtracting target media width from total window width
                const targetChatWidth = window.innerWidth - targetVidWidth;
                //This should be changeable in settings later on, for now it defaults to 20%
                const limit = window.innerWidth * this.chatWidthMinimum;

                //Set width to target or 20vw depending on whether or not we've hit the width limit
                this.chatPanel.style.flexBasis = targetChatWidth > limit ? `${targetChatWidth}px` : `${this.chatWidthMinimum * 100}vw`;

                //Fix busted layout
                var pageBreak = document.body.scrollWidth - document.body.getBoundingClientRect().width;
                this.chatPanel.style.flexBasis = `${this.chatPanel.getBoundingClientRect().width + pageBreak}px`;
            }else{
                //Calculate target video height from media aspect ratio and window width
                var targetVidHeight =  window.innerWidth / this.client.player.getRatio();
                //Calculate target chat height from the difference between the channel div height and the target video height
                var targetChatHeight = this.channelDiv.getBoundingClientRect().height - targetVidHeight;

                //Set div heights accordingly
                this.client.player.playerDiv.style.height = `${targetVidHeight}px`;
                this.chatPanel.style.height = `${targetChatHeight}px`;
            }

            //This sometimes gets called before userList ahs been initiated :p
            if(this.client.userList != null){
                this.client.userList.clickDragger.fixCutoff();
            }
        } 
    }

    togglePortrait(){
        //If our window width is more than or equal to window height (not portrait mode), or portrait mode is on while its supposed to be disabled
        if(window.innerWidth >= window.innerHeight || (localStorage.getItem("disablePortrait") == 'true' && this.portrait)){
            //Disable portrait CSS modifiers
            this.channelDiv.style.flexDirection = "row";
            this.clickDragger.enabled = true;
            this.chatPanel.style.width = "";
            this.client.player.playerDiv.style.height = "";
            this.chatPanel.style.height = "";

            //Disable portrait behavior modifiers
            this.portrait = false;

            //resize player in-case of empty flex basis
            this.resizeAspect();

        //Otherwise, if portrait mode is enabled
        }else if(localStorage.getItem("disablePortrait") != 'true'){
            //Modify CSS for portrait displays
            this.channelDiv.style.flexDirection = "column";
            this.clickDragger.enabled = false;
            this.chatPanel.style.width = "100%";
            this.chatPanel.style.flexBasis = "";

            //Enable portrait behavior modifiers
            this.portrait = true;

            //resize player to correct height
            this.resizeAspect();
        }
    }

    /**
     * Toggles Chat Box UX
     * @param {Boolean} show - Whether or not to show Chat Box UX
     */
    toggleUI(show = !this.chatPanel.checkVisibility()){
        if(show){
            this.chatPanel.style.display = "flex";
            this.showChatIcon.style.display = "none";
            this.client.player.hideVideoIcon.style.display = "flex";
            this.client.userList.clickDragger.fixCutoff();
        }else{
            this.chatPanel.style.display = "none";
            this.showChatIcon.style.display = "flex";
            this.client.player.hideVideoIcon.style.display = "none";
        }
    }

    /**
     * Handles Video Toggling
     * @param {Boolean} show - Whether or not the video is currently being hidden
     */
    handleVideoToggle(show){
        //If we're enabling the video
        if(show){
            //Show hide chat icon
            this.hideChatIcon.style.display = "flex";

            //Re-enable the click dragger
            this.clickDragger.enabled = true;

            //Lock the chat to aspect ratio of the video, to make sure the chat width isn't breaking shit
            this.lockAspect();
        //If we're disabling the video
        }else{
            //Hide hide hide hide hide hide chat icon
            this.hideChatIcon.style.display = "none";

            //Need to clear the width from the split, or else it doesn't display properly
            this.chatPanel.style.flexBasis = "100%";

            //Disable the click dragger
            this.clickDragger.enabled = false;
        }
    }

    /**
     * Handles scrolling within the chat buffer
     * @param {Event} event - Event passed down from Event Handler
     */
    scrollHandler(event){
        //If we're just starting out
        if(this.lastPos == 0){
            //Set last pos for the first time
            this.lastPos = this.chatBuffer.scrollTop;
        }

        //Calculate scroll delta
        const deltaY = this.chatBuffer.scrollTop - this.lastPos;

        //Grab visible bounding rect so we don't have to do it again (can't use offset because someone might zoom in :P)
        const bufferRect = this.chatBuffer.getBoundingClientRect();
        const bufferHeight = Math.round(bufferRect.height);
        const bufferWidth = Math.round(bufferRect.width);

        //If last height was unset
        if(this.lastHeight == 0){
            //Set it based on buffer Height
            this.lastHeight = bufferHeight;
        }

        //if last width is unset
        if(this.lastWidth == 0){
            //Set it based on buffer width
            this.lastWidth = bufferWidth;
        }

        //If we're scrolling up
        if(deltaY < 0){
            //If we have room to scroll, and we didn't resize
            if(this.chatBuffer.scrollHeight > bufferHeight && (this.lastWidth == bufferWidth && this.lastHeight == bufferHeight)){
                //Disable auto scrolling
                this.autoScroll = false;
            }else{
                this.handleAutoScroll();
            }
        //Otherwise if the difference between the chat buffers scroll height and offset height is equal to the scroll top
        //(Because it is scrolled all the way down)
        }else if((this.chatBuffer.scrollHeight - bufferHeight) == this.chatBuffer.scrollTop){
            this.autoScroll = true;
        }

        //Set last post/size for next the run
        this.lastPos = this.chatBuffer.scrollTop;
        this.lastHeight = bufferHeight;
        this.lastWidth = bufferWidth;
    }

    /**
     * Auto-scrolls chat buffer when new chats are entered.
     */
    handleAutoScroll(){
        //If autoscroll is enabled
        if(this.autoScroll){
            //Set chatBuffer scrollTop to the difference between scrollHeight and buffer height (scroll to the bottom)
            this.chatBuffer.scrollTop = this.chatBuffer.scrollHeight - Math.round(this.chatBuffer.getBoundingClientRect().height);
        }
    }
}