// application
const PIO_AUTHOR = 'VisionMise';
const PIO_VERSION = '0.2.1';
const PIO_COPYRIGHT = `Copyright (c) 2026 ${PIO_AUTHOR}. All rights reserved.`;

// configuration
const VERBOSE = true;

// State Management
const SAVE_DEBOUNCE_MS = 300;

// color palette
export const PIO_BACKGROUND = '#24252c';
export const PIO_INPUT_BACKGROUND = '#2c2e36';
export const PIO_COLOR = '#d5daee';
export const PIO_DARK = '#72737e';
export const PIO_WHITE_DARK = '#9b9ca2';
export const PIO_ACCENT = '#9ea5ca';

// timestamp for performance measurement
const START = performance.now();

// STYLE
const TRANSITION_DURATION_MS = 1300;

// state type definition
type state = [string | null, boolean | null, number | null, string[] | null, string | null, string | null] | null;

// stateful element type definition
type statfulElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | (HTMLElement & { contentEditable: 'true' }) | (HTMLElement & { content: 'true' });

class Application {

    // #region Members

    // root file system handle for storing configuration and state
    private rootFileSystemHandle: FileSystemDirectoryHandle | null = null;

    // mutation observer for tracking state changes
    private observer: MutationObserver | null = null;

    // flag to indicate if the application is ready
    private isReady: boolean = false;

    // flag to enable verbose logging
    private isVerbose: boolean = VERBOSE;

    // pending write timers keyed by state id
    private pendingSaves: Map<string, number> = new Map();



    public get ready(): boolean {
        return this.isReady;
    }

    public get verbose(): boolean {
        return this.isVerbose;
    }

    // #endregion


    // #region Constructor

    /**
        * Creates the application instance and kicks off initialization.
     */
    constructor() {

        // header
        this.header();

        // initialize application
        this.init();

        // expose globally
        // @ts-ignore global variable for accessing the application instance in the console
        (window as Window & { pio: Application })['pio'] = this;
    }

    // #endregion


    // #region Initialization

    /**
     * Injects the opiniated stylesheet into the document head.
     * @return {Promise<void>} Resolves after the style is appended.
     */
    private async initOpiniatedStyle(): Promise<void> {

        
        // create a style element and set its content to the stylesheet text
        const style = document.createElement('style');
        style.textContent = await this.stylesheet();
        
        // define transition style for smooth fade-in effect
        const transitionStyle = `background-color ${TRANSITION_DURATION_MS}ms ease, opacity ${TRANSITION_DURATION_MS}ms ease`;

        // initialize app overlay
        const overlay:HTMLElement = this.initAppOverlay();

        // trigger fade-in transition for document
        document.body.style.transition = transitionStyle;

        // set background color
        const inputs = document.querySelectorAll('input, textarea, select');
        inputs.forEach(input => (input as HTMLElement).style.backgroundColor = PIO_INPUT_BACKGROUND);

        // inject style into document head to apply styles
        document.head.appendChild(style);

        setTimeout(() => {
            overlay.style.opacity = '0';

            if (document.body.hasAttribute('hidden')) {
                document.body.removeAttribute('hidden');
            }
        }, TRANSITION_DURATION_MS);

        // remove transition div after transition completes
        setTimeout(() => document.documentElement.removeChild(overlay), TRANSITION_DURATION_MS * 3);

    }

    private initAppOverlay(): HTMLElement {
        
        // define transition style for smooth fade-in effect
        const transitionStyle = `background-color ${TRANSITION_DURATION_MS}ms ease, opacity ${TRANSITION_DURATION_MS}ms ease`;

        // Build an overlay
        const overlay:HTMLElement = document.createElement('div');
        const overlayStyle: CSSStyleDeclaration = overlay.style;
        overlayStyle.position = 'fixed';
        overlayStyle.display = 'flex';
        overlayStyle.alignItems = 'center';
        overlayStyle.justifyContent = 'center';
        overlayStyle.backgroundColor = PIO_BACKGROUND;
        overlayStyle.opacity = '1';
        overlayStyle.top = '0';
        overlayStyle.left = '0';
        overlayStyle.width = '100%';
        overlayStyle.height = '100%';
        overlayStyle.pointerEvents = 'none';
        overlayStyle.zIndex = '9999';
        overlayStyle.transition = transitionStyle;
        overlay.innerHTML = `<div style="text-shadow: 0px 0px 10px ${PIO_WHITE_DARK}; margin-top:-25%; color: ${PIO_COLOR}; font-family: sans-serif; text-align: center;"><h1>Loading</h1></div>`;
            
        document.documentElement.appendChild(overlay);
        return overlay;
    }


    /**
     * Initializes the persistent file system used for config and state.
     * @return {Promise<void>} Resolves after storage setup completes.
     */
    private async initFileSystem(): Promise<boolean> {

        // initialize file system
        // use local folder for storage
        const root: FileSystemDirectoryHandle = await navigator.storage.getDirectory();

        // create pio folder if it doesn't exist
        try {

            // get pio folder
            this.rootFileSystemHandle = await root.getDirectoryHandle('pio', { create: true });

        } catch (error) {

            // log error
            this.log(`Error initializing file system: ${error}`, true);

            // set ready
            this.isReady = false;

            // set root file system handle to null
            this.rootFileSystemHandle = null;

            // return
            return false;
        }

        try {

            // set to be persistent storage
            await navigator.storage.persist();

            this.log("Persistent storage initialized successfully.");

        } catch (error) {
            
            this.log("Could not get persistent storage. Falling back to temporary storage. Error: " + error, true);
        }

        return true;
    }


    /**
     * Sets up DOM observation and event listeners for state capture.
     * @return {void} No return value.
     */
    private initState(): void {

        // set observing state changes to document
        this.observer = this.mutationObserver();

        // observe changes to the document and save state on change
        this.observer.observe(document.body, { characterData: true, attributes: true, childList: true, subtree: true, attributeFilter: ['stateful'] });

        document.addEventListener('input', (event: Event) => this.handleStateChange(event), true);
        document.addEventListener('change', (event: Event) => this.handleStateChange(event), true);

        this.hydrateExistingState();
        this.log('State management initialized.');
    }




    /**
     * Runs the full application initialization sequence.
     * @return {Promise<void>} Resolves when initialization finishes.
     */
    private async init(): Promise<void> {

        // initialize opiniated style
        this.initOpiniatedStyle(); // non-blocking

        // initialize file system
        await this.initFileSystem();

        // initialize state
        this.initState();

        // set ready
        this.isReady = true;
    }


    // #endregion


    // #region Utilities

    /**
     * Prints the console banner with version and copyright.
     * @return {void} No return value.
     */
    private header(): void {
        // pio JS
        const brand: string = `Pio`;
        const version: string = `v${PIO_VERSION}`;
        const copyright: string = `${PIO_COPYRIGHT}`;
        console.log(`%c${brand} %c${version}\n%c${copyright}`, `padding-top:20px;font-size:16px;color: ${PIO_COLOR}`, `font-size:8px;color: ${PIO_DARK}`, `margin-bottom:20px;font-size:8px;color: ${PIO_WHITE_DARK}`);
    }


    /**
     * Logs a formatted message when verbose or priority is enabled.
     * @param {string} message Log message text.
     * @param {boolean} [priority=false] Force log even when not verbose.
     * @return {void} No return value.
     */
    public log(message: string, priority: boolean = false): void {
        if (!VERBOSE && !priority) return;
        const timestamp: string = `${((performance.now() - START) / 1000).toFixed(4)}s`;
        const date: string = new Date().toLocaleString();
        console.log(`%c⟫ ${message}\n%cPio v${PIO_VERSION}   %c${timestamp} ⏱ ${date}`, `padding-top:6px;margin-top:4px;font-size:12px;color: ${PIO_ACCENT}`, `margin-left:14px;font-size:8px;color: ${PIO_DARK}`, `padding-top:1px;font-size:9px;color: ${PIO_COLOR}`,);
    }


    /**
     * Loads the optional stylesheet, falling back to a default string.
     * @return {Promise<string>} Stylesheet text to inject.
     */
    private async stylesheet(): Promise<string> {

        // default styles
        const cssStyle: string = `html,body{background:${PIO_BACKGROUND};color:${PIO_COLOR};font-family:sans-serif}*{box-sizing:border-box}input,textarea,select{background:${PIO_INPUT_BACKGROUND};color:${PIO_COLOR};border:none;padding:8px;border-radius:4px;font-family:sans-serif}`;

        // attempt to load stylesheet
        const css: Response = await fetch('pio.css');

        // if stylesheet is missing, log and return default styles
        if (!css.ok) {
            this.log('The Pio stylesheet was not found so we loaded cat loaded to cry about it: 😿', true);
            return cssStyle;
        }

        this.log('Stylesheet loaded successfully. Opiniated styles applied.');
        return await css.text();
    }

    // #endregion


    // #region State Management


    private mutationObserver(): MutationObserver {
        return new MutationObserver(mutations => this.observeState(mutations));
    }


    private observeState(mutations: MutationRecord[]): void {
        
        for (const mutation of mutations) {

            // skip unallowed tags
            if (mutation.target instanceof Element && !this.isAllowedTag(mutation.target.tagName)) continue;

            // get parent element of mutation target for use in characterData mutations
            const parent = mutation.target.parentElement;

            // get mutation target for use in attribute mutations
            const target = mutation.target;

            switch (mutation.type) {

                case 'childList':
                    // if the mutation target is a stateful element with content attribute, save its innerHTML
                    if (target instanceof Element && this.isStatefulElement(target) && 
                        target.hasAttribute('content') && target.getAttribute('content') === 'true') {
                        this.queueSave(target);
                    }

                    if (!mutation.addedNodes.length) continue;

                    // for each added node, check if it's a stateful element or contains stateful elements and hydrate them
                    mutation.addedNodes.forEach(node => {

                        // if the added node is not an element, we can't check for stateful attribute or query for stateful children, so we return early
                        if (!(node instanceof Element)) return;

                        // check if the added node is stateful or contains stateful elements and hydrate them
                        const candidates: Element[] = [node, ...Array.from(node.querySelectorAll('[stateful]'))];

                        // for each candidate element, check if it's stateful and hydrate it
                        candidates.forEach(candidate => {

                            // skip non-stateful elements
                            if (!this.isStatefulElement(candidate)) return;

                            // candidate is a stateful element, so we hydrate it
                            this.hydrateElement(candidate);
                        });
                    });

                    break;

                case 'attributes':
                    if (mutation.attributeName !== 'stateful') continue;

                    if (!(target instanceof Element)) continue;
                    if (!this.isStatefulElement(target)) continue;

                    this.hydrateElement(target);
                    break;

                case "characterData":
                    if (!parent) continue;
                    if (!this.isStatefulElement(parent)) continue;
                    this.queueSave(parent);
                    
                    break;

            }
            
        }


    }


    private isAllowedTag(tagName: string): boolean {

        // skip tags like
        // IMG, SVG, CANVAS, VIDEO, AUDIO, IFRAME, OBJECT, EMBED, etc. 
        // that are unlikely to contain stateful content and would
        // be expensive to observe for mutations

        const allowedTags = [
            // form elements
            'INPUT', 'TEXTAREA', 'SELECT', 'FORM', 'LABEL', 'BUTTON', 'FIELDSET',
            // semantic containers
            'DIV', 'ARTICLE', 'SECTION', 'HEADER', 'FOOTER', 'ASIDE', 'MAIN', 'NAV',
            // headings
            'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
            // text content
            'P', 'SPAN', 'BLOCKQUOTE', 'PRE', 'CODE',
            // media
            'FIGURE', 'FIGCAPTION',
            // interactive elements
            'DETAILS', 'SUMMARY',
            // tables
            'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TH', 'TD', 'TR',
            // lists
            'UL', 'OL', 'LI', 'DL', 'DT', 'DD',
            // links
            'A',
            // other
            'BODY', 'CONTENT'
        ];

        // allow all tags if the list is empty
        if (allowedTags.length === 0) return true;

        // check if the tag name is in the allowed list
        return allowedTags.includes(tagName.toUpperCase());
    }

    private handleStateChange(event: Event): void {
        const target = event.target;
        if (!(target instanceof Element)) return;
        if (!this.isStatefulElement(target)) return;
        this.queueSave(target);
    }


    private hydrateExistingState(): void {
        const elements = Array.from(document.querySelectorAll('[stateful]'));
        elements.forEach(element => {
            if (!this.isStatefulElement(element)) return;
            this.hydrateElement(element);
        });
    }


    private isTrackableElement(element: Element): element is statfulElement {
        return element instanceof HTMLInputElement 
        || element instanceof HTMLTextAreaElement 
        || element instanceof HTMLSelectElement
        || (element.hasAttribute('contenteditable') && element.getAttribute('contenteditable') === 'true')
        || (element.hasAttribute('content') && element.getAttribute('content') === 'true')
        && element.hasAttribute('stateful');
    }


    private isStatefulElement(element: Element): element is statfulElement {
        return this.isTrackableElement(element);
    }


    private stateKeyForElement(element: HTMLElement): string {

        // uid buffer
        let uid:string = '';

        // check for existing key attribute to avoid unnecessary computation
        const existingKey = element.getAttribute('data-pio-key');
        if (existingKey) return existingKey;

        // if the element has an ID, we can use that as a unique identifier
        if (element.id) {
            uid = `${element.tagName.toLowerCase()}#${element.id}`;
        } else {
            uid = `${element.tagName.toLowerCase()}@${this.elementPath(element)}`;
        }

        // encode the UID to create a compact key
        const key:string = btoa(uid);

        // store key as attribute on element for future reference
        element.setAttribute('data-pio-key', key);

        // return key
        return key;
    }


    private stateFileNameForElement(element: HTMLElement): string {
        return encodeURIComponent(this.stateKeyForElement(element) + '.pio.state');
    }


    private stateFileNameForRadioGroup(name: string): string {

        // create a unique identifier for the radio group based on its name
        const uid:string = `radio#${name}`;

        // encode the UID to create a compact key
        const key:string = btoa(uid);

        // return the encoded key as the state file name for this radio group
        return encodeURIComponent(`${key}.pio.state`);
    }


    private stateIdForElement(element: statfulElement ): string {
        if (element instanceof HTMLInputElement && element.type === 'radio' && element.name) {
            return this.stateFileNameForRadioGroup(element.name);
        }

        return this.stateFileNameForElement(element);
    }


    private elementPath(element: Element): string {
        const parts: string[] = [];
        let current: Element | null = element;

        while (current && current !== document.body) {
            if (current.id) {
                parts.unshift(`#${current.id}`);
                break;
            }

            const parent: Element | null = current.parentElement;
            if (!parent) break;

            const siblings: Element[] = Array.from(parent.children).filter((child) => child.tagName === current!.tagName) as Element[];
            const index = siblings.indexOf(current) + 1;
            parts.unshift(`${current.tagName.toLowerCase()}:nth-of-type(${index})`);
            current = parent;
        }

        return parts.join('>');
    }


    private async saveState(node: statfulElement ): Promise<void> {

        if (!this.rootFileSystemHandle) {
            this.log('File system not initialized. Cannot save state.', true);
            return;
        }

        // get or create state file handle
        let stateFileHandle: FileSystemFileHandle;

        // determine state file name based on element type and attributes
        const stateId: string = this.stateIdForElement(node);

        // get the node value
        const nodeValue = 'value' in node ? node.value : null;

        try {
            stateFileHandle = await this.rootFileSystemHandle.getFileHandle(stateId, { create: true });
        } catch (error) {
            this.log(`Error accessing state file: ${error}`, true);
            return;
        }

        // create state object
        const selectedValues = node instanceof HTMLSelectElement && node.multiple
            ? Array.from(node.selectedOptions).map(option => option.value)
            : null;

        const radioGroupValue = node instanceof HTMLInputElement && node.type === 'radio' && node.name && node.checked
            ? nodeValue
            : null;

        
        const storedValue = node instanceof HTMLInputElement && node.type === 'radio'
            ? null
            : nodeValue;

        const content = node instanceof HTMLElement && node.isContentEditable
            ? node.innerHTML
            : node.hasAttribute('content') && node.getAttribute('content') === 'true'
                ? node.innerHTML
                : null;

        const state: string = JSON.stringify([
            'value' in node ? storedValue : null,
            'checked' in node ? node.checked : null,
            'selectedIndex' in node ? node.selectedIndex : null,
            selectedValues,
            radioGroupValue,
            content
        ], null, 2);

        // write updated state back to file
        try {
            const writable = await stateFileHandle.createWritable();
            await writable.write(state);
            await writable.close();

        } catch (error) {
            this.log(`Error writing state file: ${error}`, true);
        }

    }


    private async loadState(stateId: string): Promise<state> {

        if (!this.rootFileSystemHandle) {
            this.log('File system not initialized. Cannot load state.', true);
            return null;
        }

        // get state file handle
        let stateFileHandle: FileSystemFileHandle;
        try {
            stateFileHandle = await this.rootFileSystemHandle.getFileHandle(stateId, { create: false });
        } catch (error) {
            if (error instanceof DOMException && error.name === 'NotFoundError') {
                return null;
            }
            this.log(`Error accessing state file: ${error}`, true);
            return null;
        }

        // read state file
        try {
            const file = await stateFileHandle.getFile();
            const contents = await file.text();

            // parse state
            const parsed = JSON.parse(contents);
            if (!Array.isArray(parsed)) {
                return null;
            }

            let value: string | null = null;
            let checked: boolean | null = null;
            let selectedIndex: number | null = null;
            let selectedValues: string[] | null = null;
            let radioGroupValue: string | null = null;
            let content: string | null = null;

            if (parsed.length === 7) {
                [, value, checked, selectedIndex, selectedValues, radioGroupValue, content] = parsed;
            } else if (parsed.length === 6) {
                [value, checked, selectedIndex, selectedValues, radioGroupValue, content] = parsed;
            } else {
                return null;
            }

            // return state
            return [
                value ?? null,
                checked ?? null,
                selectedIndex ?? null,
                selectedValues ?? null,
                radioGroupValue ?? null,
                content ?? null
            ];

        } catch (error) {
            if (error instanceof DOMException && error.name === 'NotFoundError') {
                return null;
            }
            this.log(`Error reading state file: ${error}`, true);
            return null;
        }
    }


    private async removeState(stateId: string) {

        if (!this.rootFileSystemHandle) {
            this.log('File system not initialized. Cannot remove state.', true);
            return;
        }

        // remove state file
        try {
            await this.rootFileSystemHandle.removeEntry(stateId);
            this.log('State file removed successfully.');
        } catch (error) {
            this.log(`Error removing state file: ${error}`, true);
        }

    }


    private async hydrateElement(element: HTMLElement) {
        const stateId = this.isTrackableElement(element)
            ? this.stateIdForElement(element)
            : this.stateFileNameForElement(element);

        // load state for this node
        const state: state = await this.loadState(stateId);
        if (!state) return;
        

        // apply state to node
        try {

            // destructure state array
            let [value, checked, selectedIndex, selectedValues, radioGroupValue, content] = state;

            // if this is not a trackable element, we won't be able to apply state so we return early
            if (!this.isTrackableElement(element)) return;

            // input elements
            if (element instanceof HTMLInputElement) {

                // radio
                if (element.type === 'radio' && element.name) {
                    if (radioGroupValue === null && checked === true && value) {
                        radioGroupValue = value;
                    }

                    if (radioGroupValue !== null) {
                        element.checked = element.value === radioGroupValue;
                    }

                // checkboxes
                } else if (checked !== null) {
                    element.checked = checked;
                }

                // other input types
                if (value !== null) {
                    element.value = value;
                }

            // select and textarea
            } else if (value !== null) {
                if ('value' in element) {
                    element.value = value;
                }
            }

            // select elements with multiple selection
            if (element instanceof HTMLSelectElement) {

                // if we have selected values and this is a multi-select, apply the selected values
                if (element.multiple && selectedValues && selectedValues.length > 0) {

                    // get options and apply selected state based on saved selected values
                    Array.from(element.options).forEach(option => {

                        // get option value (use text if value is empty)
                        const optionValue = option.value || option.text;
                        option.selected = selectedValues.includes(optionValue);

                    });

                // if we have a saved selected index and it's valid, apply it
                } else if (selectedIndex !== null) {
                    element.selectedIndex = selectedIndex;
                }

            // contenteditable elements
            } else if (element.isContentEditable && content !== null) {

                // apply saved content to contenteditable element
                element.innerHTML = content;

            } else if (element.hasAttribute('content') && element.getAttribute('content') === 'true' && content !== null) {

                // apply saved content to element with content attribute
                element.innerHTML = content;

            }


        } catch (error) {
            this.log(`Error parsing Document state: ${error}`, true);
        }
    }


    private queueSave(element: statfulElement): void {
        const stateId = this.stateIdForElement(element);
        const existingTimer = this.pendingSaves.get(stateId);
        if (existingTimer !== undefined) {
            clearTimeout(existingTimer);
        }

        const timer = setTimeout(() => {
            this.pendingSaves.delete(stateId);
            void this.saveState(element);
        }, SAVE_DEBOUNCE_MS);

        this.pendingSaves.set(stateId, timer as unknown as number);
    }

    // #endregion



}


// #region Procedural Code

// create app
export const pio: Application = new Application();

// #endregion