'use strict'
/**
          ___  ____   __    ____   ___ 
         / __)(  _ \ /__\  (  _ \ / __)
      ___\__ \ )___//(__)\  )   /( (__ 
     (_______/(__) (__)(__)(_)\_) \___)  SPA Rational Code
                                         By Mike & Nike        
    
    This file is part of Sparc by Mike & Nike.
    Sparc is free software: 
    - you can redistribute it and/or modify it under the terms of the GNU General Public License,
    as published by the Free Software Foundation, either version 3 of the License, 
    or (at your option) any later version.
    - Sparc 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 General Public License for more details.
    Get your copy of the GNU General Public License at <https://www.gnu.org/licenses/>. 


 * @category Core
 * @hideconstructor
 * @tutorial app-01-overview
 */
class Sparc {
    /** 
     * Genaral app configuration, as soon as it is loaded  
     * @tutorial app-02-configuration
     * */
    config = {};
    /** Classes definitions for MVC + Sparc libs follow the rule script-name = instantiable class name */
    LoadedClasses = {};
    /** Thirdparties and other libs don't necessarily have a class, or this naming conv. then store path only. */
    LoadedScripts = [];
    /** @type User */
    User = null
    /** @type Assets */
    Assets = null
    /** @type Router */
    Router = null
    /** @type Events */
    events = null
    /** @type Array<Object> */
    latestErrors = [];


    /**
     * 
     * @param {*} ref 
     * @param {*} cls 
     * @param {*} replace 
     */
    registerClass(ref, cls, replace=false) {
        if(replace || (!this.LoadedClasses.hasOwnProperty(ref))){
            this.LoadedClasses[ref] = cls;
        } else {
            console.warn('Attempting to register already defined class ', ref);
        }
    }

    /**
     * 
     */
    onBootReady() {
        console.log('Sparc boot loaded...');
    
        // Instances shortcuts
        this.User = new this.LoadedClasses.User(); // This is User base class (no login)
    
        // Static classes shortcuts
        this.Assets = Assets; 
    
        // General app config are loaded here
        this.Assets.loadJson({   'name': 'config.json',
                                'path': '/app/config/',
                            }).then(this.onConfigLoaded.bind(this)); 
        // Instances shortcuts
        this.events = new this.LoadedClasses.Events(); // This is User base class (no login)
        this.addEvent = this.events.addEvent.bind(this.events);
    }

    /**
     * 
     */
    onWindowReady() {
        // MVC not yet loaded here, see remark below about loading base classes.
        Loader.loadScripts({ 'basepath':'/core/libs/',
                            'scripts': ['Router', 'Logger', 'Assets','Events'], 
                            'dependencies':{ 'Router': ['../baseClasses/User']  },  
                        }).then(this.onBootReady.bind(this));
    }

    /**
     * 
     * @param {*} config 
     */
    onConfigLoaded(config){
        console.log('App config loaded...');
        if(!config) {
            console.error('Could not load SPARC config file !?');
            return;
        }
        this.config = config;
        if(('logger' in config) && ('enabled' in config.logger) && (config.logger.enabled) 
            && ('levels' in config.logger) && ('postUrl' in config.logger)){
            window.console = this.logger(window.console, config.logger);
        }
    
        this.Assets.defaults = config.assets;
    
        if( ('userLib' in config) && ('className' in config.userLib) && (config.userLib.className!='')) {
            Loader.loadScripts({ 'basepath':'/app/libs/',
                'scripts': [config.userLib.className], 
                'dependencies':{ },  // Dependencies of the User class from config ?
            }).then(()=>{ 
                this.User = new this.LoadedClasses.User(); // Re-instantiate extnded class
                // Upon creation, we are ourselves, but it might change later. (no OTS yet) Philosophical, isn't it :D
                this.currentUser = this.User; 
                this.onUserClassReady(); 
            });
        } else {
            this.onUserClassReady();
        }
    }

    /**
     * 
     */
    onUserClassReady(){
        this.User.checkAuthenticated(()=>{
            console.log('back from checkAuthenticated')
            if(this.User.isAuthenticated) {
                console.log('authenticated OK')
                window.onbeforeunload = () => "Do you really want to leave this application ?";
    
                // Loading base Classes, who can now use app.config.
                // If later we do multi-app then one config per app, and baseclass instantiated with its own config.
                Loader.loadScripts({ 
                    'basepath':'/core/baseClasses/',
                    'scripts': [
                        'Model', 
                        'View', 
                        'MasterController'
                    ], // Look momy: sounds just like MVC ;-)
                    'dependencies': {
                        'MasterController': ['Controller']
                    },  // Dependencies of the base classes ?
                }).then(this.loadLibraries.bind(this));
    
                // Now load & start messageBus (must be authenticated, but can by done aside of loading the MVC core)
                if((this.config.messageBus.enabled) && ("Worker" in window) && ('WebSocket' in window)) {
                    Loader.loadScripts({    'basepath':'/core/libs/', 
                                            'scripts': ['MessageBus'], 
                                        }).then(() => {
                                            this.MessageBus = new this.LoadedClasses.MessageBus(this.config.messageBus, this.User.getMessageBusUserInfo()); 
                                        });
                } else if(this.config.messageBus.enabled){
                    console.warn("Could not register MessageBus worker: Browser too old ?");
                }
            } else {
                console.log('User is not Authenticated, redirect to Login page');
                this.User.gotoLogin();
            }  
        });  
    }

    /**
     * 
     */
    loadLibraries(){
        // Loading custom libs & intermediary classes
        let allPromises = [];
        let lib, libclass;
        if('libsBaseClasses' in this.config.router){
            for(lib of this.config.router.libsBaseClasses) {
                if(Array.isArray(lib.onlyIfClasses) && !lib.onlyIfClasses.every(cls => (cls in this.LoadedClasses))) continue
                libclass = lib.classes[0]; // Let all assets be dependencies of the first script (if many)
                Loader.AssetsDependencies[libclass] = lib.assets;       
                allPromises.push(   Loader.loadScripts({ 
                                        'basepath':'/app/libs'+lib.path,
                                        'scripts': lib.classes, 
                                        'dependencies':  lib.dependencies,
                                    })
                                );
            }
        }
        Promise.allSettled(allPromises).then(this.onMVCReady.bind(this));
    }

    /**
     * 
     */
    onMVCReady() {
        // Now that we are authenticated, we have the config and the MVC
        // load the top routes and then route !
        app.events.trigger('core.mvcReady')
        this.Assets.loadJson({ 'name': 'baseRoutes.json', 'path': '/app/config/' })
        .then(
            (topRoutes) =>{
                if(!this.Router) {
                    //console.log('Top routes loaded...', app.config);
                    var routerOptions = {
                        'defaults' : this.config.router,
                        'routes': topRoutes,
                        'callback': () =>{ } // Called when *routed* (not when instanciated) 
                    };
                    if('getRolesFrom' in this.config.router){ //Role can be an array of string or string representing a function
                        var isCallable = Function([],"return(typeof("+this.config.router.getRolesFrom+")=='function');") 
                        if(isCallable()) routerOptions['roles'] = Function([],"return("+this.config.router.getRolesFrom+"());");
                        else routerOptions['roles'] = this.config.router.getRolesFrom;
                    } else routerOptions['roles']='';
                    this.Router = new Router(routerOptions);
                }
                this.Router.route(); 
            } 
        );
    }
}

// Let the browser cache boot loader components or not ? (true in prod or if you don't work on core)
const bootBrowserCache = false; 

/* Here is Sparc bootstrap, depends only on Loader core class defined below */
if(typeof(app)=='undefined') var app = new Sparc();

window.onload = app.onWindowReady.bind(app);



/**
      ___   _____    __    ____  ____  ____ 
     (   ) (  _  )  /__\  (  _ \( ___)(  _ \
      ) (_  )(_)(  /(__)\  )(_) ))__)  )   /
     (____)(_____)(__)(__)(____/(____)(_)\_) for SPARC
                                             By Mike & Nike
    This static class contains the mechanics for loading javascripts. <br>
    It will load any needed dependancies for you (with the help of the dependancies file) <br>
    It can be called just boot-load you application and all its dependancies , <br>
    and/or, alternatively, you can load widgets yourself anytime.

 * @static
 * @hideconstructor
 * @category Core
 * @todo found some direct reference to the app global var which is a bit sloppy
 */
class Loader {
    static Dependencies = {};
    static LoadedDepFiles = [];
    static AssetsDependencies = {};

    /**
     * @private
     */
    static resolveDeps(imps, deps){ 
        const leveler = (levels, root, l, trail=[]) => { 
            if(deps.hasOwnProperty(root)){
                for(var dep of deps[root]){
                    if(!levels.hasOwnProperty(dep)) levels[dep] = -1;
                    if(levels[dep]<l) levels[dep]=l;
                    if(trail.indexOf(dep)<0){ // detect circular dependency to avoid infinite recursion
                        let curtrail = [...trail];
                        trail.push(dep);
                        leveler(levels, dep , l+1, trail);
                        trail = curtrail;
                    } else console.error('Circular depencency with '+dep, trail);
                }
            }
        }
        var levels={};
        // Build the dep tree of things we need, noting the depth.
        for(var imp of imps) { leveler(levels, imp, 1); } // Level starts at 1, zero reserved for roots
        // Add requested imports as last, unless already with higher priority (tree shaking)
        for(var imp of imps) { if(!levels.hasOwnProperty(imp)) levels[imp] = 0; }
        // Group by levels, as their loading can be paralellized (whereas on level must wait on the prev. one)        
        var name, level; var byLevel = {};
        for([name, level] of  Object.entries(levels)) { 
            if(!byLevel.hasOwnProperty(level)) { byLevel[level]=[]; } 
            byLevel[level].push(name);
        }
        return(byLevel);
    }

    /**
     * @private
     */
    static Import(scriptNames, deps, importIdPrfx, basePath = './'){ // Will start with real import (_import) only when / if dependencies are loaded.
        var CurrentImportChain = {};
        if(typeof(deps)=='string') {  // deps is a path to a json file containing the dep tree
            if( (deps!='') && (Loader.LoadedDepFiles.indexOf(deps)<0) ){
                Loader.DependenciesLoader = new Promise((resolve, reject) => {
                    fetch(basePath+deps, { headers: { "Content-Type": "application/json; charset=utf-8" }})
                    .then(res => res.json())
                    .then(response => { 
                        Loader.Dependencies = { ...Loader.Dependencies, ...response}; // Merge with known deps
                        Loader.LoadedDepFiles.push(deps);
                        resolve();
                    })
                    .catch(err => { console.error('Error loading dependencies'); Loader.Dependencies = {}; reject(); });
                });            
            } else {
                Loader.DependenciesLoader = new Promise((resolve, reject) => {  resolve(); }); //Already loaded, nothing to do here
            } 
        } else if(typeof(deps)=='object') { // deps is the dep-tree object
            Loader.DependenciesLoader = new Promise((resolve, reject) => { 
                Loader.Dependencies = { ...Loader.Dependencies, ...deps }; // Merge with known deps
                resolve(); // already ready ! ;-)
            });
        } else { console.error('Bad dependencies !?'); return(false); }
        // Prepare a synchronous chain of asynchronous loads
        Loader.DependenciesLoader.then(()=>{
            var byLevelImps = Loader.resolveDeps(scriptNames, Loader.Dependencies);
            var total = Object.values(byLevelImps).reduce((tot,v)=> (tot+v.length), 0);
            document.dispatchEvent(new CustomEvent("LoaderProgressAddTodo", {'detail': { 'importID':importIdPrfx, 'addValue': total }})); 
            for(var key in Object.getOwnPropertyNames(byLevelImps).sort().reverse()){
                CurrentImportChain[importIdPrfx+'_'+key] = {'imps': byLevelImps[key], 'path':basePath };
            }
            var firstImportId = importIdPrfx+'_'+key;
            document.addEventListener('LoaderAllReady', function(evt) { // Next async imports in the sync chain
                var nextImpNb = evt.detail.substr(0,evt.detail.lastIndexOf('_')+1)+(parseInt(evt.detail.substr(evt.detail.lastIndexOf('_')+1))-1);
                if(evt.detail != importIdPrfx+'_0'){
                    if(CurrentImportChain.hasOwnProperty(nextImpNb)) {
                        Loader._import(CurrentImportChain[nextImpNb]['imps'], nextImpNb,CurrentImportChain[nextImpNb]['path']);
                    }  
                } 
            });
            // First load all assets
            Loader.LoadAssetDependencies(byLevelImps).then( () => {
                // Start the synchronous chain
                Loader._import(CurrentImportChain[firstImportId]['imps'], firstImportId,CurrentImportChain[firstImportId]['path']); 
            });
        });
    }

    /**
     * 
     * @param {*} byLevelImps 
     * @returns {Array<Promise>}
     */
    static LoadAssetDependencies(byLevelImps){
        let allImports = [];
        for(var level in byLevelImps) allImports=[...allImports, ...byLevelImps[level]];
        allImports = Array.from(new Set(allImports)); //remove dups
        allImports = allImports.map(this.path2ClassName);
        let allPromises = [];
        for(var classname of allImports){            
            for(var assetType in Loader.AssetsDependencies[classname]){
                if(!Array.isArray(Loader.AssetsDependencies[classname][assetType])) {
                    console.warn(`Bad config: Asset dependencies for ${classname} - ${assetType} is not an array !?`)
                    continue;
                }
                for(var assetObj of Loader.AssetsDependencies[classname][assetType]){  
                    if((typeof(assetObj) != 'object') || (!('name' in assetObj))) {
                        console.warn(`Bad config: One asset dependency for ${classname} - ${assetType} has no name !?`)
                        continue;
                    }
                    switch(assetType){
                        case 'fonts':   allPromises.push(app.Assets.loadFont(assetObj));
                                        break;
                        case 'styles':  allPromises.push(app.Assets.loadCss(assetObj));           
                                        break;
                        case 'html':    allPromises.push(app.Assets.loadHtml(assetObj));  
                                        break;
                        case 'views':   assetObj.path = '/app/views/'; 
                                        allPromises.push(app.Assets.loadHtml(assetObj));
                                        break;                                
                        case 'images':  allPromises.push(app.Assets.getImage(assetObj));    
                                        break;
                        case 'json':    allPromises.push(app.Assets.loadJson(assetObj));    
                                        break;               
                        case 'sfx':     allPromises.push(app.Assets.loadSound(assetObj));    
                                        break;               
                    }
                }
            }
        }
        return(Promise.allSettled(allPromises));
    }

    /**
     * 
     * @param {*} src 
     * @returns {Promise}
     */
    static loadScript(src) { 
        return new Promise((resolve, reject) => {
            const script = document.createElement('script')
            script.type = src.split('?')[0].endsWith('.module.js')? 'module' : 'text/javascript'
            script.onload = resolve
            script.onerror = reject
            script.src = src
            document.head.append(script)
        });
    }

    /**
    * @private
    */
   static path2ClassName(scriptName){
        scriptName = scriptName.substr(scriptName.lastIndexOf('/')+1);
        if(scriptName.indexOf('.')==-1) scriptName+='.'; // also pure scriptname or path without extension compatible !
        return(scriptName.substr(0,scriptName.lastIndexOf('.')));
   }

    /**
    * @private
    */
    static url2Path(url){
        var x = new URL(url);
        return(x.pathname);
    }

   /**
    * @private
    */
    static _import(names, importID, basePath){ // Here, all imports are made asynchronously, in parallel.
        var allLoaded = true;
        var nbLeft = names.length;
        for(var name of names){ 
            var className = this.path2ClassName(name);
            if(name.substring(0,4)!='http') var scriptFp = (basePath + '/' + name).replace(/\/+/g,'/')+'.js';
            else var scriptFp = name;
            if( (!(className in app.LoadedClasses)) && (app.LoadedScripts.indexOf(scriptFp)<0) ) {
                allLoaded = false;
                if( !bootBrowserCache ) scriptFp += '?'+importID;
                Loader.loadScript(scriptFp)
                    .then((result) => { 
                        var scriptName= this.url2Path(result.currentTarget.src); 
                        // MCV classes and internal ones put themselves in app.loadedClasses, 
                        // Non-Sparc scripts don't so save their path.
                        if(  ('router' in app.config) &&
                            !(scriptName.startsWith(app.config.router.controllersPath)) &&
                            !(scriptName.startsWith(app.config.router.modelsPath)) &&
                            !(scriptName.startsWith(app.config.router.viewsPath)) &&
                            !(scriptName.startsWith('/core/'))
                        ) app.LoadedScripts.push(scriptName);
                        var className = this.path2ClassName(result.currentTarget.src);
                        document.dispatchEvent(new CustomEvent('Loader'+className+'Ready', { 'detail': importID}));
                        document.dispatchEvent(new CustomEvent("LoaderProgressIncrement", {'detail': { 'importID': importID, 'success': true }})); 
                        nbLeft--;
                        if(nbLeft<=0){                                                     
                            document.dispatchEvent(new CustomEvent("LoaderAllReady", { 'detail': importID })); 
                        }
                    })
                    .catch((error) => {
                        if((typeof(error)!='undefined') && (typeof(error.currentTarget)!='undefined')){
                            var scriptName= error.currentTarget.src;
                            var className = this.path2ClassName(error.currentTarget.src);
                            console.error('Could not import Class '+className);
                            document.dispatchEvent(new CustomEvent("LoaderError", { 'detail': {'importID': importID, 'className': className }}));
                        } else console.error('Script loading error '+error);
                        document.dispatchEvent(new CustomEvent("LoaderProgressIncrement",  {'detail': { 'importID': importID, 'success': false }}));  
                        nbLeft--;
                    });
            } else nbLeft--;
        }
        if(allLoaded){
            for(var name of names) document.dispatchEvent(new CustomEvent('Class'+name+'Ready', { 'detail':importID }));            
            document.dispatchEvent(new CustomEvent("LoaderAllReady", { 'detail':importID }));
        }
    }


    /**
    * Load a list of scripts.
    * @static
    * @param {string} basepath - base path (something like /sccripts/)
    * @param {Array<string>} scripts - array of scripts to load. to load a module instead of a JS, just end the filename with '.module.js) like: "mysuperlib.module.js"
    * @param {(string|object)}  dependencies - Last minute Dependancies :tree object ( like {'scriptName': ['deps1', 'dep2,...]} ) or path to its json file.
    * @param {function} callback - Called when everything is loaded
    * @returns {Promise} A promise resolved whn-en all is loaded
    */
    static loadScripts(args){  
        // Start by filtering out scripts we already have
        if(!args.refresh) {
            args.scripts = args.scripts.filter((sname)=>{
                var className = this.path2ClassName(sname);
                return(!(className in app.LoadedClasses) && (!(sname in app.LoadedScripts)));
            });
            if(args.scripts.length==0) { 
                if( (args.hasOwnProperty('callback')) && (typeof(args.callback)=='function') ){
                    args.callback({});
                }
                return(new Promise((resolve) => { resolve(); }));
            }
        }
        var myImportID = crypto.randomUUID(); 
        if(!args.hasOwnProperty('dependencies')) args.dependencies = [];
        if(!args.hasOwnProperty('basepath')) args.basepath = '';
        return(new Promise((resolve, fail) => {
                document.addEventListener('LoaderAllReady', (evt) => { if(evt.detail==myImportID+'_0') resolve(); } );
                document.addEventListener('LoaderError', (evt) => { if(evt.detail==myImportID+'_0') fail(); } );
                Loader.Import(args.scripts, args.dependencies, myImportID, args.basepath);
            })
        );
    }


    /**
    * Load a list of views.
    * @static
    * @param {array} Views - array of Views to load 
    * @returns {string} The unique ID of the import
    */
    static async loadViews(views){
        // Start with views templates
        let allPromises = []
        let templates = []
        let scripts = [] 
        let dependencies = {}
        for(var dep of views){
            if(typeof(dep) == 'string') {// just add view as dependency of controller, with correct path
                templates.push(dep+'.html')
                scripts.push(dep)
            } else if(typeof(dep) == 'object'){ // This view depends on other views                    
                scripts.push(dep.view);
                dependencies[dep.view]=dep.dependencies//.map(x=>'/app/views/'+x)
                templates.push(dep.view+'.html')
            }
        }

        for(let view of templates){
            let assetObj = { path: '/app/views/', name: view }
            allPromises.push(app.Assets.loadHtml(assetObj));
        }
        await Promise.allSettled(allPromises);

        // Finish with views scripts
        return(this.loadScripts({
            basepath:'/app/views/',
            scripts: scripts,
            dependencies: dependencies,
        }));
    }

}

/**
 * POLYFILLS
 */

if(typeof(crypto.randomUUID)!='function'){
    crypto.randomUUID = () => {   
        var buf = new Uint8Array(14);
        crypto.getRandomValues(buf);
        var uuid = Array.from(buf, byte => ('0' + (byte & 0xFF).toString(16)).slice(-2)).join('');
        return(
            uuid.substring(0,8) + '-' + 
            uuid.substring(10,4) + '-' + 
            uuid.substring(14,4) + '-' + 
            uuid.substring(18,4) + '-' + 
            uuid.substring(22)
        );
                            }
}

if(typeof(typeof(Promise.allSettled))!='function'){
    Promise.allSettled = ((promises) => Promise.all(
        promises.map(p => p
            .then(value => ({
                status: "fulfilled",
                value
            }))
            .catch(reason => ({
                status: "rejected",
                reason
            }))
        )
    ));
}