base_State.js

import { RouteParams } from "./RouteParams.js";

/**
 * State class. Parent state class. Extend this class for your state logic.
 *
 * @example
 * Create a state called MyState with is executed when the url 'my-state' is called. When executed,
 * it prints 'Hello from MyState' to the console.
 *
 * class MyState extends State
 * {
 *     asnyc enter()
 *     {
 *         console.log( "Hello from MyState" );
 *     }
 * }
 */
class State
{
    /**
     * ID of state. Should be an unique identifier. If not set it will be auto-generated.
     * @type {string|null}
     */
    static ID = null;

    /**
     * Route(s) which trigger this state
     * @type {string|array}
     */
    static ROUTE = "/";

    /**
     *
     * @param {App} app - App instance
     * @param {RouteParams=} routeParams - Current route params
     * @param {State=} parentState - Parent state for sub-states
     */
    constructor( app, routeParams, parentState = null )
    {
        this.app = app;
        this.routeParams = null === routeParams ? new RouteParams() : routeParams;
        this.parentState = parentState;
        this.subStateManager = null;
        this.currentSubState = null;
        this._stateData = null;
        this._eventListeners = [];
        this._intervals = [];
        this._timeouts = [];
        this._disposed = false;
    }

    /**
     * Return current ID
     *
     * @returns {string}
     */
    getId()
    {
        return this.constructor.ID;
    }

    /**
     * Called before entering state.
     * @returns {boolean}
     */
    canEnter()
    {
        return true;
    }

    /**
     * Called before exiting state.
     * Return false to prevent state transition.
     * Also checks if sub-states can exit.
     * @returns {boolean} - True to allow exit, false to prevent exit
     */
    canExit()
    {
        // Check if current sub-state can exit
        if (this.currentSubState && !this.currentSubState.canExit()) {
            return false;
        }
        
        // Override in subclasses for custom logic
        return this.onCanExit();
    }

    /**
     * Override this method for custom exit validation logic
     * @returns {boolean} - True to allow exit, false to prevent exit
     */
    onCanExit()
    {
        return true;
    }

    /**
     * Called when canEnter() function returns false.
     * @returns {string|null} - Return redirect route.
     */
    getRedirectUrl()
    {
        return null;
    }

    /**
     * Called when entering scene and after canEnter() call returned true.
     * @returns {Promise<void>}
     */
    async enter()
    {
        await this.onEnter();
    }

    /**
     * Override this method for custom enter logic
     * @returns {Promise<void>}
     */
    async onEnter()
    {
        // Override in subclasses
    }

    /**
     * Called when exiting scene and after canExit() call return true.
     * Also handles sub-state cleanup.
     * @returns {Promise<void>}
     */
    async exit()
    {
        // Exit sub-state first if exists
        if (this.currentSubState) {
            await this.currentSubState.exit();
            await this.currentSubState.dispose();
            this.currentSubState = null;
        }

        // Clear sub-state manager current state
        if (this.subStateManager) {
            this.subStateManager.currentState = null;
        }

        await this.onExit();
    }

    /**
     * Override this method for custom exit logic
     * @returns {Promise<void>}
     */
    async onExit()
    {
        // Override in subclasses
    }

    getParams()
    {
        return this.routeParams.getParams();
    }

    getParam( key, defaultValue = null )
    {
        return this.routeParams.getParam( key, defaultValue );
    }

    getQueries()
    {
        return this.routeParams.getQueries();
    }

    getQuery( key, defaultValue = null )
    {
        return this.routeParams.getQuery( key, defaultValue );
    }

    /**
     * Set data for this state (used for data transfer between states)
     * @param {*} data - Data to set
     */
    setStateData( data )
    {
        this._stateData = data;
    }

    /**
     * Get data for this state
     * @returns {*} - State data
     */
    getStateData()
    {
        return this._stateData;
    }

    /**
     * Create a sub-state manager for nested states
     * @returns {Promise<StateManager>} Sub-state manager instance
     */
    async createSubStateManager()
    {
        if (!this.subStateManager) {
            // Import StateManager here to avoid circular dependency
            const { StateManager } = await import('../core/StateManager.js');
            this.subStateManager = new StateManager(this.app, this);
        }
        return this.subStateManager;
    }

    /**
     * Switch to a sub-state
     * @param {string} subStateId - Sub-state ID to switch to
     * @param {*} stateData - Data to pass to sub-state
     * @returns {Promise<boolean>}
     */
    async switchToSubState( subStateId, stateData = null )
    {
        if (!this.subStateManager) {
            throw new Error('No sub-state manager exists. Call createSubStateManager() first.');
        }

        const subStateInstance = this.subStateManager.create(subStateId, this.routeParams);
        if (!subStateInstance) {
            throw new Error(`Sub-state ${subStateId} could not be created.`);
        }

        if (stateData !== null) {
            subStateInstance.setStateData(stateData);
        }

        const success = await this.subStateManager.switchTo(subStateInstance);
        if (success) {
            this.currentSubState = subStateInstance;
        }
        
        return success;
    }

    /**
     * Get current sub-state
     * @returns {State|null}
     */
    getCurrentSubState()
    {
        return this.currentSubState;
    }

    /**
     * Check if this state has sub-states
     * @returns {boolean}
     */
    hasSubStates()
    {
        return this.subStateManager !== null;
    }

    /**
     * Check if this state has an active sub-state
     * @returns {boolean}
     */
    hasActiveSubState()
    {
        return this.currentSubState !== null;
    }

    /**
     * Get the root state (top-level parent)
     * @returns {State}
     */
    getRootState()
    {
        let current = this;
        while (current.parentState) {
            current = current.parentState;
        }
        return current;
    }

    /**
     * Get state path (hierarchy chain)
     * @returns {string[]} Array of state IDs from root to current
     */
    getStatePath()
    {
        const path = [];
        let current = this;
        
        while (current) {
            path.unshift(current.getId());
            current = current.parentState;
        }
        
        return path;
    }

    /**
     * Get full state path including current sub-state
     * @returns {string[]} Complete path including sub-states
     */
    getFullStatePath()
    {
        const path = this.getStatePath();
        
        if (this.currentSubState) {
            const subPath = this.currentSubState.getFullStatePath();
            // Remove the first element (this state) from subPath to avoid duplication
            path.push(...subPath.slice(1));
        }
        
        return path;
    }

    /**
     * Find a state in the hierarchy by ID
     * @param {string} stateId - State ID to find
     * @param {Set} visited - Set of already visited states (internal use)
     * @returns {State|null} Found state or null
     */
    findStateInHierarchy( stateId, visited = new Set() )
    {
        // Prevent infinite recursion
        if (visited.has(this)) {
            return null;
        }
        visited.add(this);
        
        // Check this state
        if (this.getId() === stateId) {
            return this;
        }
        
        // Check current sub-state
        if (this.currentSubState && !visited.has(this.currentSubState)) {
            const found = this.currentSubState.findStateInHierarchy(stateId, visited);
            if (found) {
                return found;
            }
        }
        
        // Check parent (only if we haven't already visited it)
        if (this.parentState && !visited.has(this.parentState)) {
            return this.parentState.findStateInHierarchy(stateId, visited);
        }
        
        return null;
    }

    /**
     * Add event listener with automatic cleanup tracking
     * @param {EventTarget} target - Event target (element, window, document, etc.)
     * @param {string} type - Event type
     * @param {Function} listener - Event listener function
     * @param {boolean|object} options - Event listener options
     */
    addEventListener( target, type, listener, options = false )
    {
        if (this._disposed) {
            console.warn('Cannot add event listener to disposed state');
            return;
        }

        target.addEventListener(type, listener, options);
        this._eventListeners.push({ target, type, listener, options });
    }

    /**
     * Remove specific event listener
     * @param {EventTarget} target - Event target
     * @param {string} type - Event type
     * @param {Function} listener - Event listener function
     * @param {boolean|object} options - Event listener options
     */
    removeEventListener( target, type, listener, options = false )
    {
        target.removeEventListener(type, listener, options);
        
        const index = this._eventListeners.findIndex(item => 
            item.target === target && 
            item.type === type && 
            item.listener === listener
        );
        
        if (index !== -1) {
            this._eventListeners.splice(index, 1);
        }
    }

    /**
     * Set interval with automatic cleanup tracking
     * @param {Function} callback - Callback function
     * @param {number} delay - Delay in milliseconds
     * @returns {number} Interval ID
     */
    setInterval( callback, delay )
    {
        if (this._disposed) {
            console.warn('Cannot set interval on disposed state');
            return null;
        }

        const intervalId = setInterval(callback, delay);
        this._intervals.push(intervalId);
        return intervalId;
    }

    /**
     * Clear specific interval
     * @param {number} intervalId - Interval ID to clear
     */
    clearInterval( intervalId )
    {
        clearInterval(intervalId);
        const index = this._intervals.indexOf(intervalId);
        if (index !== -1) {
            this._intervals.splice(index, 1);
        }
    }

    /**
     * Set timeout with automatic cleanup tracking
     * @param {Function} callback - Callback function
     * @param {number} delay - Delay in milliseconds
     * @returns {number} Timeout ID
     */
    setTimeout( callback, delay )
    {
        if (this._disposed) {
            console.warn('Cannot set timeout on disposed state');
            return null;
        }

        const timeoutId = setTimeout(callback, delay);
        this._timeouts.push(timeoutId);
        return timeoutId;
    }

    /**
     * Clear specific timeout
     * @param {number} timeoutId - Timeout ID to clear
     */
    clearTimeout( timeoutId )
    {
        clearTimeout(timeoutId);
        const index = this._timeouts.indexOf(timeoutId);
        if (index !== -1) {
            this._timeouts.splice(index, 1);
        }
    }

    /**
     * Check if state has been disposed
     * @returns {boolean} True if state is disposed
     */
    isDisposed()
    {
        return this._disposed;
    }

    /**
     * Dispose of the state and cleanup all resources
     * Called automatically by StateManager when exiting state
     * Handles hierarchical cleanup of sub-states
     * Override onDispose() for custom cleanup logic
     * @returns {Promise<void>}
     */
    async dispose()
    {
        if (this._disposed) {
            return;
        }

        // Dispose sub-states first (depth-first cleanup)
        if (this.currentSubState) {
            await this.currentSubState.dispose();
            this.currentSubState = null;
        }

        // Dispose all states in sub-manager
        if (this.subStateManager) {
            // Clean up any remaining states in the sub-state manager
            if (this.subStateManager.currentState && this.subStateManager.currentState !== this.currentSubState) {
                await this.subStateManager.currentState.dispose();
            }
            
            // Clean up the sub-state manager itself
            this.subStateManager.currentState = null;
            this.subStateManager = null;
        }

        // Call custom disposal logic
        try {
            await this.onDispose();
        } catch (error) {
            console.error('Error in onDispose():', error);
        }

        // Clean up event listeners
        for (const { target, type, listener, options } of this._eventListeners) {
            try {
                target.removeEventListener(type, listener, options);
            } catch (error) {
                console.warn('Error removing event listener:', error);
            }
        }
        this._eventListeners.length = 0;

        // Clear intervals
        for (const intervalId of this._intervals) {
            try {
                clearInterval(intervalId);
            } catch (error) {
                console.warn('Error clearing interval:', error);
            }
        }
        this._intervals.length = 0;

        // Clear timeouts
        for (const timeoutId of this._timeouts) {
            try {
                clearTimeout(timeoutId);
            } catch (error) {
                console.warn('Error clearing timeout:', error);
            }
        }
        this._timeouts.length = 0;

        // Nullify references
        this.app = null;
        this.routeParams = null;
        this.parentState = null;
        this._stateData = null;
        
        this._disposed = true;
    }

    /**
     * Override this method for custom cleanup logic
     * Called before automatic resource cleanup in dispose()
     * @returns {Promise<void>}
     */
    async onDispose()
    {
        // Override in subclasses for custom cleanup
    }

}

export { State };