/**
   Implements a basic script module loading API similar to
   require.js (http://requirejs.org).

   In short, this module acts as a loader for arbitrary other content,
   be it s2 script code, raw file content, or "virtual" (non-file)
   content plugged in by the user. Its usage is modelled very much of
   off requirejs, and it will look familiar to anyone who has used
   that library.

   See the API docs in this module for more details. For even more
   details (or other details, in any case), see this public Google
   Doc:

   https://docs.google.com/document/d/14gRP4f-WWgWNS64KM_BI7YjQqBLl4WT3jIrmNmFefYU/view

   Example usage:

   Assume the files r1.s2 and r2.s2 live somewhere in this module's
   search path and that both return an Object with a foo() method.
   This is how we might import and use them:

   @code
   const R = import('path/to/require.s2');

   R(['r1', 'r2'], proc(r1, r2){
     r1.counter = r2.counter = 1;
     print(__FLC,r1);
     print(__FLC,r2);
     r1.foo();
     r2.foo();
     ++r1.counter;
     ++r2.counter;
   });

   R(['r2', 'r1'], proc(r2, r1){
     assert 2 === r2.counter;
     assert 2 === r1.counter;
     // ^^^ because imported script results are cached
   });
   @endcode

*/
affirm s2.fs;
affirm s2.Buffer;
affirm typeinfo(isfunction s2.getenv);
affirm typeinfo(isfunction s2.fs.realpath);
affirm typeinfo(isfunction s2.Buffer.readFile);
const  /* saves some var lookup time */
s2 = s2,
realpath = s2.fs.realpath,
getenv = s2.getenv,
cliFlags = (s2.ARGV ? s2.ARGV.flags : 0) ||| {prototype:null}
;

const importFileBuffer = s2.Buffer.readFile;
const importFileText = proc(fn) using(importFileBuffer) {
    return importFileBuffer(fn).takeString();
};
/**
   If a string-type CLI flag OR environment variable (in that
   order) with the given name is found, this function returns an
   array of its contents, tokenized using
   s2.PathFinder.tokenizePath(). If no flag/environment variable
   is found, or a CLI flag with a non-string value is found,
   undefined is returned.

   Interpretation of the arguments is as follows:

   - If only 1 argument is provided, this routine looks for both a
   CLI flag and environment var (in that order) with that name.

   - If 2 arguments are provided, the 1st is checked as a CLI flag
   and the 2nd as an environment variable (in that order).
*/
const pathFromEnv = proc(f,e=f){
    const p = F[f] ||| E(e);
    return typeinfo(isstring p)
        ? P.tokenizePath(p)
        : undefined;
} using {
    E: getenv,
    P: s2.PathFinder,
    F: cliFlags
};

/**
   Internal utility to convert almost-URL-encoded plugin options
   to some value form. Treats non-existent values (===undefined)
   as a boolean true flag.
*/
const convertQValue = proc(v){
    undefined === v && return true;
    'true' === v && return true;
    'false' === v && return false;
    'null' === v && return null;
    return 0.parseNumber(v) ?: v;
};

/**
   Module-internal cache of import() responses.
*/
const modCache = new s2.Hash(0.nthPrime(15));

/**
   The main "require" module object.
*/
const mod = {
    __typename: 'require.s2',
    /**
       Used for doing file lookups. Its search path and extensions
       get continually swapped out by require() and friends to use
       whatever search path the current context requires.
    */
    pf: new s2.PathFinder(),
    pathFromEnv,
    /* Exposed for corner-case use by plugins. */
    modCache
};

/**
   This "default" plugin (A) acts as the default
   implementation for fetching require()'d files, (B) is
   where the interface for other plugins is documented.
*/
const PluginModel ={
    /**
       Clear the prototype - we don't really need it for
       this object.
    */
    prototype: undefined,
    /** If true, require() does not search for the filename it
        is given. (It is assumed to be some form of virtual
        unique identifier, and may be empty.)
    */
    isVirtual: false,
    
    /**
       cacheIt tells require whether or not to cache the results
       of requests to this plugin. If it is true, then this.load()
       will only be called for the first request, and its result
       will be returned on subsequent requests. EXCEPTION: if
       a plugin is called with arguments (see load()) the cache
       is ignored/bypassed.

       The default is expected (by client code) to always be true
       for the default plugin!
    */
    cacheIt: true,

    /**
       For file-based plugins, prefix specifies the search
       directories (array of strings, excluding any trailing slash
       on the directory parts unless they refer to the root
       directory).
    */
    prefix: pathFromEnv('s2.require.path', 'S2_REQUIRE_PATH') ||| ['.'],
    /**
       For file-based plugins, suffix specifies the search
       extensions (array of strings, including any '.' part,
       e.g. use ".foo" instead of "foo" unless you're searching
       for partial extensions).
    */
    suffix: pathFromEnv('s2.require.extensions', 'S2_REQUIRE_EXTENSIONS') ||| ['.s2'],

    /**
       Called by require() to "load" a given module.  load() must
       accept a module name, "load" that module, for a given
       definition of "load", and return its contents or
       result. (It need not actually load anything from anywhere,
       much less a file: it might simply return a predefined
       object or other value.)

       In the call context, 'this' will be this plugin
       configuration object.

       The first argument is the "name" part of the string passed
       to require.

       The second argument part is either undefined or an object:
       if the file string contains '?', anything after the ? is
       assumed to be encoded in the form a=b&c=d... (URL-like, but
       _without_ the URL encoding), and that gets transformed into
       an Object of key/value pairs before passing them to this
       function (the default value, if no "=" is provided, is boolean
       true). HOWEVER: passing any arguments after '?' will
       cause caching to be bypassed, because the arguments presumably
       change how the plugin works. Note that if nothing follows
       the '?' then no options object is created.
       
       Caveats regarding the opt parameter:

       1) all of the values in the opt object will originally be
       of type string, but numeric-looking strings and the strings
       ("true", "false", "null") get converted to their
       script-native type.

       2) Only one level of opt object is supported, not nested
       objects, arrays, etc.
    */
    load: proc(f/*, opt*/) using({
        b: s2.Buffer.readFile,
        r: mod
    }) {
        return b(f).evalContents(f,{requireS2: r});
    }
    /* Reminder to self: we cannot simply alias to s2.import() because
       any extra non-string arguments would be passed to it (doing the
       wrong thing). Reminder #2: adding comments outside of function
       bodies, instead of if them, uses less memory, since they don't
       get allocated as part of the function body. That's especially
       significant when the length of the comments outweigh the rest
       of the source, as in this case. */
};

/**
   The funkiness we do with 'this' vs 'mod' in may places places
   below is so that this stuff still works when clients copy the
   returned module into a property of another object. i.e. the following
   usages are equivalent:

   var x = mod;
   obj.x = mod;
   x([...],...);
   obj.x([...],...);

   Both calls (because of these extra bindings) use the same "this"
   inside the call (the local 'mod' symbol), which is important for
   identical/correct semantics in both uses.
*/

/**
   All the plugins are stored in this object, and any number may
   potentially be loaded (and added here) via client-side use.
*/
mod.plugins = {
    /**
       The name 'default' is magic and assumes certain
       plugin-level defaults. It also provides the default .prefix
       and .suffix properties for other non-isVirtual (file-using)
       plugins (used only if a given plugin does not define them
       itself).
    */
    default: PluginModel,
    /**
       Works just like the default plugin but bypasses
       the cache.
    */
    nocache: {
        cacheIt: false,
        load: PluginModel.load
    },
    /** Loads file content as a string. */
    text: {
        cacheIt: false,
        // prefix: uses the defaults
        suffix: ['.txt', '.s2', '.html'],
        load: importFileText
    },
    /** Loads file content as a buffer. */
    buffer: {
        cacheIt: false,
        // prefix: uses the defaults
        suffix: ['.txt', '.s2', '.html'],
        load: importFileBuffer
    }

    /**
       Demonstration of a "virtual" plugin (one which does not
       use files).
       
       virtualDemo:{
       isVirtual: true,
       cacheIt: true, // not strictly necessary, plugin-dependent
       load: proc f(fn,opt){
       print("Example of a 'virtual' handler. Arguments:",argv);
       return opt ||| this;
       }
       }
    */

};

/**
   Installs one or more plugins into this object.

   If called with an initial string arugment then:

   - Adds a new plugin. name must be a string and pluginObj must
   be an object which follows the PluginModel interface. Returns
   pluginObj on success.

   If called with a non-string argument then:

   - name is assumed to be a container. Each of its properties is
   assumed to be a module definition, and each one gets installed
   via a call back into this function, passing it each key and
   value. Returns this object.

   Throws on error.
*/
mod.addPlugin = proc callee(name, pluginObj){
    if(name && !pluginObj && typeinfo(iscontainer name)){
        foreach(name=>k,v) callee(k,v);
        return this;
    }
    affirm 'string' === typeinfo(name name);
    affirm name /* name must be non-empty */;
    affirm typeinfo(iscontainer pluginObj) && typeinfo(iscallable pluginObj.load);
    mod.plugins[name] = pluginObj;
    return mod;
} using(mod);

/**
   Searches for a plugin script by using this.plugins.default's
   search path and the name ("plugins/"+name). Returns undefined
   if not found, else the result of s2.import()'ing that file. It
   does not check if the plugin is already installed, but installs
   (or overwrites) it into mod.plugins[name].
*/
mod.searchAndInstallPlugin = proc(name){
    mod.pf.prefix = mod.plugins.default.prefix;
    mod.pf.suffix = mod.plugins.default.suffix;
    const fn = mod.pf.search('plugins/'+name);
    return fn
        ? mod.plugins[name] = ((const requireS2=mod), import(false,R(fn)))
        : undefined;
} using {mod, R: realpath},

/**
   If the given plugin name is already installed, it is returned,
   otherwise it is sought for, installed, and returned. An
   exception is thrown if it cannot be found or if installing it
   fails.
*/
mod.getPlugin = proc(name) using(mod) {
    return mod.plugins[name] ||| mod.searchAndInstallPlugin(name);
},

/**
   Attempts to resolve a file name using a given plugin's search
   path. basename is the unresolved name of the file to search for
   and forPlugin is either a plugin object or the name of a
   plugin. The search path/extensions use are those of the given
   plugin or (if that plugin has none), the default plugin. If the
   given plugin has the isVirtual flag, no search is performed and
   the undefined value is returned.
*/
mod.resolveFilename = proc(basename,forPlugin='default') using(mod){
    const pConf = typeinfo(isobject forPlugin) ? forPlugin : mod.getPlugin(forPlugin);
    affirm typeinfo(isobject pConf);
    const pf = (pConf.isVirtual ? undefined : mod.pf) ||| return;
    pf.prefix = pConf.prefix ||| mod.plugins.default.prefix;
    pf.suffix = pConf.suffix ||| mod.plugins.default.suffix;
    return pf.search(basename,0);
};

/**
   Given a base filename...

   1) If the given name does not contain a '!' character,
   it searches for an exact-match name in the cache. If it
   finds one, it returns that value.

   2) It searches for the file using the configured search
   paths/extensions (from this.plugins). If found, it is passed to
   the import() function specified for the import type (see
   below).

   By default this.plugins.default is used to search for and
   import the file. If a "special" type of name is provided to
   this function, though (meaning the base name looks like
   with "SOMETHING!basename"), then this.plugins[SOMETHING] is
   used (if set), which may change the caching behaviour and
   how the content of the file is interpreted.

   Depending on the configuration options, requests might get
   cached. Subsequent calls which expand to the same file name
   will return the same (cached) result value on each
   subsequent call.
*/
mod.import = proc(basename){
    affirm 'string' === typeinfo(name basename);
    if(basename.indexOf('!')<0 && const c = C#basename){
        return c;
    }
    var pluginName;
    /* Check for pluginName!... */
    if((var ndx = basename.indexOf('!'))>0){
        pluginName = basename.substr(0,ndx);
        basename = basename.substr(ndx+1);
    }
    pluginName || (pluginName ='default');
    /* Configuration for this plugin... */
    const pConf = mod.getPlugin(pluginName);
    pConf || throw "Could not load plugin '"+pluginName+"'.";
    const pf = pConf.isVirtual ? undefined : mod.pf /* PathFinder */;
    if(pf){
        // Set up/reset file lookup paths/suffixes...
        pf.prefix = pConf.prefix ||| mod.plugins.default.prefix;
        pf.suffix = pConf.suffix ||| mod.plugins.default.suffix;
    }
    /* Treat ?a=b&c=d... almost like a URL-encoded
       as query string, but without the URL encoding.
    */
    var qArgs, useCache = pConf.cacheIt;
    if(basename.indexOf('?')>=0){
        /* Parse args. If any are provided, bypass the cache. */
        const sp = basename.split('?',2);
        basename = sp.0;
        (qArgs = Q(sp.1)) && (useCache = false);
    }
    /* Find the file, if necessary... */
    var fn = pConf.isVirtual
        ? basename
        : (basename ? pf.search(basename, 0) : false)
        ||| throw "Plugin '%1$s' cannot find '%2$s' in search path %3$J with extensions %4$J.".
        applyFormat(pluginName, basename, pf.prefix, pf.suffix);
    // expand to the fully qualified path for non-virtual plugins...
    pConf.isVirtual || (fn = P(fn));
    //print(__FLC,"fn=",fn,"useCache=",useCache,"qArgs =",qArgs,pluginName,pConf);
    //print(__FLC,'pConf.cacheIt=',pConf.cacheIt,', fn=',fn);
    //print(__FLC,cache.toJSONString(2));
    const requireS2 = mod /* public API symbol, potentially
                             needed by anything which
                             uses anyPlugin.load() */;
    return useCache
        ? (const k =
                 (pluginName ? pluginName+'!'+fn : fn),
                 cacheCheck = (C # k))
        ? cacheCheck
        : C.insert(k, pConf.load(fn, qArgs))
    : pConf.load(fn, qArgs);
} using(mod, {
    C: modCache,
    P: realpath,
    Q: proc(str) using {q:convertQValue} {
        str || return str;
        var r;
        foreach(@str.split('&')=>v){
            const s = v.split('=',2);
            (r ||| (r = {prototype:null}))[s.0] = s.1 ? q(s.1) : (v.indexOf('=')>0 ? s.1 : true);
        };
        return r;
    }
});

/**
   For each base filename in the given array/tuple, this function
   calls this.import(basename) and appends the results to a new
   array.  Returns an array of the results to each import() call,
   the order of the array's elements being the same as the calls
   to import(). Throws if list is not an array or is empty or if
   loading any resources fails. Propagates any exceptions.
*/
mod.importList = function(list) using(mod) {
    affirm typeinfo(islist list) /* expecting an Array or Tuple */;
    affirm !list.isEmpty() /* expecting a non-empty list of module names */;
    const imps = [];
    foreach(@list=>v) imps.push(mod.import(v));
    return imps;
};

/**
   Imports a list of script modules and optionally calls a
   callback after loading all of them.

   list is an array of strings - script file base names to import.
   func is an optional function or code string which gets called
   resp. eval'd after importing all of the scripts. If it's a
   function, it is passed one argument for each entry in the list,
   in the same order they are imported. If it is a code string
   then it is eval'd in a scope with the array 'modules' defined
   to the resolved list of modules.

   In either case, it declares the const symbol requireS2 to be
   the require() module, so that these callbacks may recursively
   invoke require() via a call to requireS2(). (It is often useful
   to do so, it turns out, and this consolidates the convention
   across modules originally written for different code bases.)

   Returns the result of calling the function (if any) or the
   array of loaded modules (if passed no function/string).

   Example:

   assert 42 === thisObj(
   ['module1', 'module2'],
   function(mod1, mod2){
   print('result of module1 import:' mod1);
   print('result of module2 import:' mod2);
   return 42;
   }
   );


   _Potential_ TODOs:

   - If passed a non-string value where a name is expected, use it
   as-is as the result of the loading. i suspect we might have
   some interesting uses for that, but want a use case before trying
   it out.
*/
mod.require = function(list, func) using(mod) {
    func && affirm (typeinfo(iscallable func) || typeinfo(isstring func));
    list = mod.importList(list);
    func || return list;
    if(typeinfo(iscallable func)){
        const requireS2=mod, s2 = s2;
        return func.apply(func, list);
    }else{
        return func.evalContents('require() script',{
            s2: s2,
            requireS2: mod,
            modules: list
        });
    }
};

/**
   Installs a cached entry for the given module name, such that
   future calls to import() or require() which use that exact name
   will return the given result object. Note that the name need
   not be filesystem-friendly. e.g. "<my-identifier>" is perfectly
   legal. The only limitation is that it "really should not"
   contain an exclamation point, as that may confuse import()
   because that character is used to denote a plugin.
*/
mod.installModule = function(name, result) using(modCache){
    modCache.insert(name, result);
    return this;
};


// Try to determine some useful directories to search for scripts in...
if(typeinfo(isstring var d = cliFlags['s2.require.home'])){
    mod.home = realpath(d);
    mod.plugins.default.prefix.push( mod.home );
}
else if((d=(cliFlags['s2.home']|||getenv('S2_HOME')))
        && (d=realpath(d+'/require.d'))){
    mod.plugins.default.prefix.push( mod.home = d );
}
if((var d = __FILEDIR ? realpath(__FILEDIR) : 0)
   && (mod.home !== d)){
    mod.plugins.default.prefix.push( d );
    mod.home || (mod.home = d);
}
/* __FILEDIR may be a synthetic __FILE name
   (e.g. via eval or Buffer.evalContents()) */
mod.home || (mod.home="");

/* We set mod.require.home so that plugins can construct paths via
   requireS2.home. */

/* Make it so that call()ing this object calls mod.require() */
mod.prototype = mod.require /** Holy cow! We've just inherited our
                                own member function. */;
mod /* script result */;
