/**
     ____   _____  __  __  ____  ____  ____ 
    (  _ \ (  _  )(  )(  )(_  _)( ___)(  _ \
     )  _/  )(_)(  )(__)(   )(   )__)  )   /
    (_)\_) (_____)(______) (__) (____)(_)\_)  for SPARC
                                              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/>. 
 * This class contains the SPA router mechanics.<br>
 * Once instanciated with a set of top-level routes, just launch instance.route().<br>
 * 
 * @category Core
 * @subcategory Libraries
*/

class Router {

    /**
     * @typedef RouteNode
     * @property {string} url
     * @property {string} role
     * @property {string} controller
     * @property {string} [method]        
     */

    /**
     * 
     * @param {object} args - object defining top-level routes
     * @param {object} args.routes - object defining top-level routes
     * @param {string|function} args.role - user role, used for selecting most apropriate route (if function: called at each re-routing)
     * @param {function} args.callback - Called when everything a route has been executed
     * @param {string} args.origin - [Experimental] - when router is not master of the domain root : process only URLs that begin with it.
     * @param {string} args.controllersPath - Location of application's scripts
     * @param {object} args.defaults - default values
     */    
    constructor(args) { 
        this.defaults = args.defaults;
        
        if(args.hasOwnProperty('controllersPath') && (args.controllersPath!='')) this.defaults.controllersPath = args.controllersPath;
        if(args.hasOwnProperty('modelsPath') && (args.modelsPath!='')) this.defaults.modelsPath = args.modelsPath;
        if(args.hasOwnProperty('viewsPath') && (args.viewsPath!='')) this.defaults.viewsPath = args.viewsPath;
        this.defaults.origin = args.hasOwnProperty('origin') && (args.origin!='') ? args.origin : (new URL(document.location)).origin;
        
        this.ControllerInstances = {};
        this.ControllerConfigs = {};
        this.httpErrorRoute = null;
        this.routes = this.cleanupRoutes(args.routes);
        this.roles = args.roles;
        this.callback = args.callback;
        this.routeReady = false;
        this.merged_ctrl_routes = [];
        this.currentRoute = null;

        document.addEventListener('click', this.onGlobalClick.bind(this));
        window.addEventListener('popstate', this.bckFwdInterceptor.bind(this));
    }

    onGlobalClick(event) {
        let target = event.target; 
        let notfound = true;
        while(target && target.tagName && (target.tagName.toUpperCase() != 'BODY') && notfound){
            if(target.tagName.toUpperCase() == 'A'){ 
                notfound = false; 
                this.linksInterceptor(target, event); 
            } else target = target.parentNode;
        }
    }

    bckFwdInterceptor(e) { this.route(); }

    linksInterceptor(target, e){
        if(target.hasAttribute('noroute')) return
        if(target.href.substr(0,this.defaults.origin.length) == this.defaults.origin) { //internal
            e.preventDefault()
            history.pushState({}, '',  document.location)
            history.replaceState(null, '', this.cleanupUrl(target.href).pathname)
            this.route()
        }        
    }

    cleanupUrl(target) {
        let url = new URL(target || document.location, this.defaults.origin);
        url.pathname = url.pathname.replace(/\/+/g,'/');
        return(url);
    }

    route(forcedUrl, forcedParams) {       
        if(forcedUrl) history.replaceState(null, null, forcedUrl);
      
        let routeinfo = this.getRoute(forcedUrl, forcedParams || {});

        let [currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, exturl] = routeinfo
        if(exturl && exturl.match(/\w{2,6}:\/\//)) {
            if(params)  exturl += '?' +(new URLSearchParams(params).toString());
            document.location = exturl;
            this.routeReady = true;
            return;
        } else if(exturl && (exturl != '')) { // Internal redirect
            history.replaceState(null, '', exturl);
            this.route();
        } else {         
            this.execRoute(...routeinfo);
        } 
    }

    execRoute(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, exturl){
        this.routeReady = false;
        
        // Start by making sure we have the MasterController (aka "mc") loaded, if not, first, load it!
        let mcPath = app.config.router.controllersPath;
        let mcLoader;
        
        if(!(app.config.router.masterController in app.LoadedClasses)){
            mcLoader = app.LoadedClasses.Assets.loadJson({   
                'path': mcPath,
                'name': app.config.router.masterController+'.json', 
            }).then((tcConfig) => {
                if(typeof(tcConfig)!='undefined') {                    
                    tcConfig.routes=[]; // no subroutes for the masterController (use baseRoutes instead)
                    // Avoid controllerConfigLoaded (made for normal ctrl, but merge config directly)
                    this.mergeControllerConfig(currentRoute, mcPath, app.config.router.masterController, null, {} , tcConfig);
                    // return the autoLoadController Promise, so the "then" below depends on ctrl loaded (not config loaded)
                    return(this.autoLoadController(currentRoute,  mcPath, app.config.router.masterController, null, {}));
                } else {
                    console.error('Problem loading Master-Controller ! (could not load its config)');
                    return(new Promise((resolve,fail) => { fail(); }));
                }
            });
        } else { mcLoader = new Promise((resolve) => { resolve(); }) }
        
        mcLoader.then( () => {
            // Get controller config file (add controllersPath only to json, for the script it is done by getRoute)
            app.LoadedClasses.Assets.loadJson({   
                'path': ctrlPath,
                'name': ctrlClass+'.json', 
            })
            .then( (config) => { 
                if(typeof(config)!='undefined') {
                    this.controllerConfigLoaded(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, exturl, config);
                } else {
                    let err = 'Could not load configuration of controller '+ctrlClass+' !!';
                    this.redirectToHttpErrorRoute(err);
                }
            });
        });
    }

    controllerConfigLoaded(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, exturl, config) {      
        config = config || {  // Cool attitude if config not found or not clean json 
            routes:[], 
            dependencies:[], 
            AssetsDependencies:{} 
        }; 

        this.mergeControllerConfig(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, config);
        
        // Recompute route now that there are some sub-routes, if something changed we need to recurse
        // If nothing changed, then we're good to load, instanciate & call !
        let newCurrentRoute, newCtrlPath, newCtrlClass, newCtrlMethod, newParams;
        [newCurrentRoute, newCtrlPath, newCtrlClass, newCtrlMethod, newParams, exturl] = this.getRoute(currentRoute.forcedUrl, currentRoute.forcedParams);
    
        if(exturl) { //Do not recur if we stumbbled on a exturl, just go to it
            this.route(exturl, null) 
        } else if(newCurrentRoute.url!=currentRoute.url){
            this.execRoute(newCurrentRoute, newCtrlPath, newCtrlClass, newCtrlMethod, newParams, exturl);
        } else { // the else makes sure we autoLoad only at the bottom-level of the recursion
            if(typeof(this.ControllerInstances[ctrlClass])=='object') { 
                //  Non-persistent controllers were once were here, but not usefull, removed
                this.launchControllerMethod(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params);                    
            } else { // Unknown ctrl : load it !
                this.autoLoadController(newCurrentRoute,  newCtrlPath, newCtrlClass, newCtrlMethod, newParams);
            }
        }
    }    

    mergeControllerConfig(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, config){       
        this.ControllerConfigs[ctrlClass] = config

        // Carefull not to remerge twice the same config (otherwise url concats will go bzurg)
        if(this.merged_ctrl_routes.indexOf(ctrlClass)>-1) return;        
        this.merged_ctrl_routes.push(ctrlClass);

        // Add sub routes, except for masterctrl
        if((ctrlClass!= app.config.router.masterController) || ('routes' in config)){
            let newroutes = this.cleanupRoutes(config.routes);
            for(let newroute of newroutes){
                if(currentRoute.url=='!defaultroute') continue; 
                let fullurl = (currentRoute.url+'/'+newroute.url).replace(/\/+/g,'/');
                newroute.url = fullurl;
                this.routes.push(newroute);                         
            }
        }
      
        if(!Loader.Dependencies.hasOwnProperty(ctrlClass)) Loader.Dependencies[ctrlClass] = [];
        // Add controller dependencies (not views, not)
        if('controllerDependencies' in config){
            for(var script of config.controllerDependencies){
                if(script.substring(0,4)!='http') Loader.Dependencies[ctrlClass].push(('/app/'+script).replace(/\/+/g,'/'))
                else Loader.Dependencies[ctrlClass].push(script)
            }            
        }

        // add models as controller dependencies with adapted path
        if('models' in config){
            for(var dep of config.models){
                if(typeof(dep) == 'string') {// just add model as dependency of controller, with correct path
                    let model = (this.defaults.modelsPath+'/'+dep).replace(/\/+/g,'/');
                    Loader.Dependencies[ctrlClass].push(model);
                } else if(typeof(dep) == 'object'){ // This model depends on other models                  
                    let model = (this.defaults.modelsPath+'/'+dep.model).replace(/\/+/g,'/');
                    // add model as dependency of controller
                    Loader.Dependencies[ctrlClass].push(model);
                    // add sub-dependencies without dups & with correct path
                    if(!Loader.Dependencies.hasOwnProperty(model)) Loader.Dependencies[model] = []; 
                    for(let submodel of dep.dependencies){
                        if(Loader.Dependencies[model].indexOf(submodel)<0) {
                            Loader.Dependencies[model].push((this.defaults.modelsPath+'/'+submodel).replace(/\/+/g,'/'));
                        }
                    }
                }
            }            
        }

        // add views as controller dependencies with adapted path
        if('views' in config){
            for(var dep of config.views){
                if(typeof(dep) == 'string') {// just add view as dependency of controller, with correct path
                    let view = (this.defaults.viewsPath+'/'+dep).replace(/\/+/g,'/');
                    Loader.Dependencies[ctrlClass].push(view);
                } else if(typeof(dep) == 'object'){ // This view depends on other views                    
                    let view = (this.defaults.viewsPath+'/'+dep.view).replace(/\/+/g,'/');
                    // add view as dependency of controller
                    Loader.Dependencies[ctrlClass].push(view);
                    // add sub-dependencies without dups & with correct path
                    if(!Loader.Dependencies.hasOwnProperty(view)) Loader.Dependencies[view] = []; 
                    for(let subview of dep.dependencies){
                        if(Loader.Dependencies[view].indexOf(subview)<0) {
                            Loader.Dependencies[view].push((this.defaults.viewsPath+'/'+subview).replace(/\/+/g,'/'));
                        }
                    }
                }
            }
        } 

        // Remove dups (maybe there was existing stuff before we got here)
        Loader.Dependencies[ctrlClass] = Array.from(new Set(Loader.Dependencies[ctrlClass])); 
        Loader.AssetsDependencies[ctrlClass] = config.assets;
    }

    autoLoadController(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params){
        let scriptFp = (ctrlPath+'/'+ctrlClass).replace(/\/+/g,'/');
        let deps = {}; deps[scriptFp] = Loader.Dependencies[ctrlClass];
        return (
            Loader.loadScripts({ 
                'scripts':[scriptFp], 
                'dependencies':deps,
            }).then(
                () => { 
                    this.controllerLoaded(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params) 
                },
                (error) => { // Loading of the route failed => Try to reroute to defaultroute
                    let err = 'Loading of the class "'+ctrlClass+'" Failed !';
                    console.error(err);
                    if(currentRoute != this.httpErrorRoute) this.redirectToHttpErrorRoute(err);
                    this.routeReady = true;
                }
            )
        );                      
    }

    registerController(ctrlClass){
        try { 
            this.ControllerInstances[ctrlClass] = new app.LoadedClasses[ctrlClass](); 
        } catch(e) { console.error(`"${e}" instantiating controller ${ctrlClass} `); } ;
    }

    controllerLoaded(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params){
        
        this.registerController(ctrlClass);
        
        if(ctrlClass != app.config.router.masterController) {
            let mTemplate
            if('template' in currentRoute) 
                mTemplate = currentRoute.template;
            else 
                mTemplate = app.config.router.defaultMasterTemplate;
            
            // Launch the useTemplate and wait for it to be ready before launching the method.
            this.ControllerInstances[app.config.router.masterController].useTemplate(mTemplate).then(
                () => {
                    this.launchControllerMethod(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params);
                }
            );
        } 
    }

    launchControllerMethod(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params){
        if((ctrlClass in this.ControllerInstances) && (typeof(this.ControllerInstances[ctrlClass][ctrlMethod])=='function')) {
            this.currentRoute = currentRoute
            this.currentRoute.realUrl = new URL(document.location.href).pathname
            this.ControllerInstances[ctrlClass][ctrlMethod]({
                'currentRoute': currentRoute,                    
                'params': params
            });
            this.routeReady = true;
            if(typeof(this.callback)=='function') this.callback(ctrlClass,ctrlMethod,params);
        } else {
            let err;
            if(ctrlClass in this.ControllerInstances){
                err = `Could not find method "${ctrlMethod}" in class "${ctrlClass}"`;
            } else {
                err = `Controller "${ctrlClass}" not instantiated !`;
            }
            console.error(err);
            if(currentRoute != this.httpErrorRoute) 
                this.redirectToHttpErrorRoute(err);
            this.routeReady = true;
        }       
    }

    redirectToHttpErrorRoute(err=''){
        let ctrlPath = this.httpErrorRoute.controller.substr(0,this.httpErrorRoute.controller.lastIndexOf('/')+1);
        ctrlPath = (this.defaults.controllersPath+'/'+ctrlPath).replace(/\/+/g,'/');        
        let ctrlClass = this.httpErrorRoute.controller.substr(this.httpErrorRoute.controller.lastIndexOf('/')+1);
        let ctrlMethod =this.httpErrorRoute.method;
        app.LoadedClasses.Assets.loadJson({   
            'path': ctrlPath,
            'name': ctrlClass+'.json', 
        }).then( (config) => {
            this.mergeControllerConfig(this.httpErrorRoute, ctrlPath, ctrlClass, ctrlMethod, {'error' : err}, config)
            this.autoLoadController(this.httpErrorRoute, ctrlPath, ctrlClass, ctrlMethod, {'error' : err});
        });
    }

    getRoute(forcedUrl, forcedParams) {
        let pathname =  this.cleanupUrl(forcedUrl).pathname;
        let bestroute = {'score':0, 'route':{}, 'parts':'', 'extractedParams':{}, 'gibberish':'', idx:0 };
        let idx = 0;
        let myRoles = (typeof(this.roles)=='function') ? this.roles() : this.roles ;   
        for(let route of this.routes){   
            route['forcedUrl'] = forcedUrl;
            route['forcedParams'] = forcedParams;
            let intersect = route['role'].filter(v => (myRoles.indexOf(v)>-1));
            if((route['role']!='*') && (intersect.length==0)) continue;
            
            let [score, parts, paramvals, gibberish] = this.matchUrl(pathname, route);
            
            // make sure a role specific route wins over a '*' role route (if already match, thus score>0, not to interfere with defaultroute).
            if((score>0) && (route['role']!='*') && (intersect.length>0)) {
                score++;
            }

            // Best route wins, and when exaequo, childe-route (embed) wins over a parent-route
            if( (score > bestroute.score) || ((score == bestroute.score) && (idx > bestroute.idx)) ) { 
                bestroute = {'score':score, 'route':route, 'parts':parts, 'extractedParams':paramvals, 'gibberish':gibberish, idx:idx };
            }
            idx++;
        }

        if(bestroute['score']==0){ // no match => return default route
            if(this.httpErrorRoute) 
                bestroute = {   
                    'score': 0, 
                    'route': this.httpErrorRoute,
                    'parts': [], 
                    'extractedParams': {'error' : 'No route found for this URL!'} ,
                    'gibberish': pathname,
                    'idx': 0
                }; 
            else { 
                console.error('No matching route for this url, and no default route defined!'); 
                return([null,'', '', '', {}]);
            }
        }

        let ctrlPath, ctrlClass, ctrlMethod, methParams, exturl;
        if(bestroute.route.hasOwnProperty('controller')){ // Normal MVC route
            ctrlPath = bestroute.route.controller.substr(0,bestroute.route.controller.lastIndexOf('/')+1);
            ctrlPath = (this.defaults.controllersPath+'/'+ctrlPath).replace(/\/+/g,'/');
            ctrlClass = bestroute.route.controller.substr(bestroute.route.controller.lastIndexOf('/')+1);
            if(bestroute.route.hasOwnProperty('method') && (bestroute.route.method!='')){ // predefined Method in config
                ctrlMethod =bestroute.route.method;
            } else { // no predefined Method, 
                // unused stuff in url ? use last url part 
                if(bestroute.score >= 2) ctrlMethod = bestroute.parts[bestroute.parts.length-1];
                else ctrlMethod = 'index'; // nothing left in url ? default to index
            }
    
            if(!bestroute.route.hasOwnProperty('extractedParams')) bestroute.params = {};
            methParams = {...bestroute.extractedParams, ...bestroute.route.params, ...forcedParams};
            exturl = '';
        } else { // External route         
            exturl = bestroute.route.exturl;          
        }
        return([bestroute.route, ctrlPath, ctrlClass, ctrlMethod, methParams, exturl]);
    }

    cleanupRoutes(routes){
        const regex = /[\/:][^\/:]+/g;
        const paramregex = /^:(\w+)(\(.*\))?/;
        const pathregex = /^[-\/\w]+$/;
        const roleregex = /^[-\w]+$/;
        let cleanroutes = [];
        for(let route of routes){
            let keepit = true;

            if(!route.hasOwnProperty('url')) {
                console.warn('Missing url in route definition, route ignored ! ',route);
                keepit = false;
            } else {
                if(route.url!='!defaultroute'){
                    let routeparts = route.url.match(regex);
                    if(routeparts===null) routeparts = ['/']; //Happens for empty of slah-only url; (or maybe cataclysmic ones)
                    for(let routefrag of routeparts) {
                        if(routefrag.charAt(0)=='/'){
                            if(!(routefrag.match(pathregex))) {
                                console.warn('Bad url in route definition (forbidden character in path), route ignored ! ',route);
                                keepit = false;
                            }
                        } else {
                            if(!(routefrag.match(paramregex))) {
                                console.warn('Bad url in route definition (forbidden character in param), route ignored ! ',route);
                                keepit = false;
                            }
                        }
                    }
                } else {
                    this.httpErrorRoute = route;
                }
            }

            if(!route.hasOwnProperty('role')) {
                console.warn('Missing role in route definition, route ignored ! ',route);
                keepit = false;
            } else {
                if(typeof(route.role)=='string') route.role = [route.role];
                if(route.role!='*'){
                    for(let role of route.role){ // also works if string (letter by letter but ok)
                        if(!(role.match(roleregex))) {
                            console.warn('Bad role in route definition (forbidden character), route ignored ! ',route);
                            keepit = false;
                        }                
                    }
                }
            }

            if( (!route.hasOwnProperty('controller')) && (!route.hasOwnProperty('exturl')) ) {
                console.warn('Missing one of controller / exturl in route definition, route ignored ! ',route);
                keepit = false;
            } else if(route.hasOwnProperty('exturl')) {
                // don't touch it !
            } else if(!(route.controller.match(pathregex))) {
                    console.warn('Bad controller path in route definition (forbidden character), route ignored ! ',route);
                    keepit = false;
            }
            if(keepit) cleanroutes.push(route);
        }
        return(cleanroutes);
    }

    matchUrl(urlpath, route){ 
        const regex = /[\/:][^\/:]+/g;
        const paramregex = /:(\w+)(\(.*\))?/;

        if((urlpath=='/') && (route.url=='/')) return([1, '/', {}, []]);

        let urlpaths = urlpath.substr(1).split('/');
        let nbmatch = 0;  
        let gibberish = '';
        let routeparts;
        routeparts = route.url.match(regex);
        let parts = []; let params = {}; 
        if(!routeparts) return([0, [], {}, '']);
      
        for(let routefrag of routeparts) {
            if(nbmatch>(urlpaths.length-1)) {
                return([0, [], {}, '']);
            }
            if(routefrag.charAt(0)=='/'){ // url frag
                if(routefrag!='/') routefrag = routefrag.substr(1);
                if(routefrag!=urlpaths[nbmatch]){
                    return([0, [], {}, '']);
                } 
                parts.push(urlpaths[nbmatch]);
            } else { // param
                if(urlpaths[nbmatch]=='') return([0, [], {}, '']); // no '//' where param expected
                let parampattern = routefrag.match(paramregex);
                if(!parampattern) {
                    console.warn('Bad parameter syntax in routes definition : '+routefrag+'(ignored)');
                } else {
                    let [x, parname, parvalidation] = parampattern
                    if(parvalidation){ //Validate with given regex
                        let pvrx = RegExp('^'+parvalidation.substr(1, parvalidation.length-2)+'$');
                        if(urlpaths[nbmatch].match(pvrx)) params[parname] = urlpaths[nbmatch];
                        else return([0, [], {}, '']); // parameter with missed validation
                    } else {
                         params[parname] = urlpaths[nbmatch]; // take without validation
                    }
                }
            }
            nbmatch++;    
        }

        let pointer;
        // Still have stuff in url, and no predefined method, so take meth from url
        if( (nbmatch<urlpaths.length) && ((!route.hasOwnProperty('method') || (route.method==''))) && (urlpaths[nbmatch]!='') ){ 
            parts.push(urlpaths[nbmatch]);
            pointer = nbmatch+1; // Pointer should advance as we've taken this part of the url, but does not count in score !
        } else pointer = nbmatch;

        if(pointer<urlpaths.length){
            gibberish = urlpaths.splice(pointer).join('/'); 
        } 
        return([nbmatch, parts, params, gibberish]);
    }

    makelink(ctrl, method, params={}){
        let myRoles = (typeof(this.roles)=='function') ? this.roles() : this.roles ;
        for(let route of this.routes){
            let intersect = route['role'].filter(v => (myRoles.indexOf(v)>-1));
            if((route.role!='*') && (intersect.length==0)) continue

            if(('/'+route.controller).replace(/\/+/g,'/')!=('/'+ctrl).replace(/\/+/g,'/')) continue
            let url=route.url;            
            //Check if params and route params match
            let parok=true; 
            let rparams = route.url.match(/:\w+/g);
            if(rparams){
                let re;
                for(let rparam of rparams){
                    rparam=rparam.substr(1);
                    if(!params.hasOwnProperty(rparam)) {
                        parok=false;
                        break;
                    }
                    re = RegExp(':'+rparam+'(?!\w)','g');
                    url = url.replace(re, '/'+params[rparam]);
                }
            }
            if(!parok) continue;

            if(!route.hasOwnProperty('method')){
                url+=('/'+method).replace(/\/+/g,'/');
            }
            return(url);
        }
        return('');
    }
}
app.registerClass('Router', Router);

