class_app.js

import util from 'util'
import Bajo from './bajo.js'
import Base from './base.js'
import Cache from './cache.js'
import Tools from './tools.js'
import Plugin from './plugin.js'
import { outmatchNs, parseObject, lib, runAsApplet } from './_helper.js'
import { fileURLToPath } from 'url'

const { camelCase, isPlainObject, get, reverse, map, last, without, set } = lib._
const { pascalCase } = lib.aneka
let unknownLangWarning = false

function getCallerFilename () {
  const originalFunc = Error.prepareStackTrace
  let callerfile

  try {
    const err = new Error()
    Error.prepareStackTrace = (_, stack) => stack
    const currentfile = err.stack.shift().getFileName()

    while (err.stack.length) {
      callerfile = err.stack.shift().getFileName()
      if (currentfile !== callerfile) break
    }
  } catch (e) {}

  Error.prepareStackTrace = originalFunc
  return callerfile
}

/**
 * @typedef {Object} TAppEnv
 * @property {string} dev=development
 * @property {string} prod=production
 * @see App
 */

/**
 * App class. This is the root. This is where all plugins call it home.
 *
 * @class
 */
class App {
  /**
   * @param {Object} [options] - App options.
   * @param {string} [options.cwd] - Set current working directory. Defaults to the script directory.
   * @param {string[]} [options.plugins] - Array of plugins to load. If provided, it override the list in ```package.json``` and ```.plugins``` file.
   * @param {Object} [options.config] - Plugin's config object. If provided, plugin configs will no longer be read from its config files.
   */
  constructor (options = {}) {
    /**
     * Copy of provided options.
     *
     * @type {Object}
     */
    this.options = options

    /**
     * Your main namespace. And yes, you suppose to NOT CHANGE this.
     *
     * @memberof App
     * @constant {string}
     * @default 'main'
     */
    this.mainNs = 'main'

    /**
     * App environments.
     *
     * @memberof App
     * @constant {TAppEnv}
     */
    this.envs = { dev: 'development', prod: 'production' }

    /**
     * Date/time when your app start.
     *
     * @type {Date}
     */
    this.runAt = new Date()

    /**
     * Applets container.
     *
     * @type {Array}
     */
    this.applets = []

    /**
     * Plugin's package names container. This is the list of plugins to load. It is read from ```package.json``` and ```.plugins``` file by default, but you can override it by providing ```options.plugins``` at constructor.
     *
     * @type {Array}
     */
    this.pluginPkgs = options.plugins ?? []

    /**
     * @typedef {Object} TAppConfigHandler
     * @property {string} ext - File extension.
     * @property {function} [readHandler] - Function to call for reading.
     * @property {function} [writeHandler] - Function to call for writing.
     * @see App#configHandlers
     */

    /**
     * Config handlers.
     *
     * By default, there are two built-in handlers available: ```.js```
     * and ```.json```. Use plugins to add more, e.g {@link https://github.com/ardhi/bajo-config|bajo-config}
     * lets you to use ```.yaml/.yml``` and ```.toml```.
     *
     * @type {TAppConfigHandler[]}
     */
    this.configHandlers = []

    /**
     * Gives you direct access to the most commonly used 3rd party library in a Bajo based app.
     * No manual import necessary, always available, anywhere, anytime!
     *
     * Example:
     * ```javascript
     * const { camelCase, kebabCase } = this.app.lib._
     * console.log(camelCase('Elit commodo sit et aliqua'))
     * ```
     *
     * @type {TAppLib}
     */
    this.lib = lib
    this.lib.outmatchNs = outmatchNs.bind(this)
    this.lib.parseObject = parseObject.bind(this)

    /**
     * Instance of system log.
     *
     * @type {Log}
     */
    this.log = {}

    /**
     * All plugin's base class are saved here as key-value pairs with plugin name as its key.
     * The special key ```Base``` && ```Tools``` is for {@link Base} & {@link Tools} class so anytime you want to
     * create your own plugin, just use something like this:
     *
     * ```javascript
     * class MyPlugin extends this.app.baseClass.Base {
     *   ... your class
     * }
     */
    this.baseClass = { Base, Tools }

    /**
     * If app runs in applet mode, this will be the applet's name.
     *
     * @type {string}
     */
    this.applet = undefined

    /**
     * Program arguments.
     *
     * ```
     * $ node index.js arg1 arg2
     * ...
     * console.log(this.args) // it should print: ['arg1', 'arg2']
     * ```
     *
     * @type {string[]}
     * @see module:Lib.parseArgsArgv
     */
    this.args = []

    /**
     * Program options.
     *
     * - Dash (```-```) breaks the string into object keys
     * - While colon (```:```) is used as namespace separator. If no namespace found, it is saved under ```_``` key.
     *
     * Values are parsed automatically. See {@link https://github.com/ladjs/dotenv-parse-variables|dotenv-parse-variables}
     * for details.
     *
     * ```
     * $ node index.js --my-name-first=John --my-name-last=Doe --my-birthDay=secret --nameSpace:path-subPath=true
     * ...
     * // {
     * //   _: {
     * //    my: {
     * //       name: { first: 'John', last: 'Doe' },
     * //       birthDay: 'secret'
     * //     }
     * //   },
     * //   nameSpace: { path: { subPath: true } }
     * // }
     * ```
     *
     * @type {Object}
     * @see module:Lib.parseArgsArgv
     */
    this.argv = {}

    /**
     * Environment variables. Support dotenv (```.env```) file too!
     *
     * - Underscore (```_```) translates key to camel-cased one
     * - Double underscores (```__```) breaks the key into object keys
     * - While dot (```.```) is used as namespace separator. If no namespace found, it is saved under ```_``` key.
     *
     * Values are also parsed automatically using {@link https://github.com/ladjs/dotenv-parse-variables|dotenv-parse-variables}.
     *
     * Example:
     *
     * - ```MY_KEY=secret``` → ```{ _: { myKey: 'secret' } }```
     * - ```MY_KEY__SUB_KEY=supersecret``` → ```{ _: { myKey: { subKey: 'supersecret' } } }```
     * - ```MY_NS.MY_NAME=John``` → ```{ myNs: { myName: 'John' } }```
     *
     * @type {Object}
     * @see module:Lib.parseEnv
     */
    this.envVars = {}

    /**
     * Placeholder for boxen that will get imported from ```bajoCli``` later during boot process.
     */
    this.boxen = null

    this.cache = new Cache(this)

    if (!options.cwd) options.cwd = process.cwd()
    const l = last(process.argv)
    if (l.startsWith('--cwd')) {
      const parts = l.split('=')
      options.cwd = parts[1]
    }
    this.dir = this.lib.aneka.resolvePath(options.cwd)
    process.env.APPDIR = this.dir
  }

  /**
   * Add and save plugin and it's base class definition (if provided).
   *
   * @method
   * @param {TPlugin} plugin - A valid bajo plugin.
   * @param {Object} [baseClass] - Base class definition.
   */
  addPlugin = (plugin, baseClass) => {
    if (this[plugin.ns]) throw new Error(`Plugin '${plugin.ns}' added already`)
    this[plugin.ns] = plugin
    if (baseClass) this.baseClass[pascalCase(plugin.ns)] = baseClass
  }

  /**
   * Get all loaded plugin namespaces.
   *
   * @method
   * @returns {string[]}
   */
  getAllNs = () => {
    return this.pluginPkgs.map(pkg => camelCase(pkg))
  }

  /**
   * Get loaded plugins.
   *
   * @method
   * @param {string[]} [nss] - Array of namespaces. If empty, it returns all loaded plugins.
   * @returns {TPlugin[]}
   */
  getPlugins = (nss) => {
    const allNs = nss ?? this.getAllNs()
    return allNs.map(ns => this[ns])
  }

  /**
   * Get all plugins loaded plugins.
   *
   * @method
   * @returns {TPlugin[]}
   */
  getAllPlugins = () => {
    return this.getPlugins()
  }

  /**
   * Get plugin by its namespace.
   *
   * @method
   * @param {string} name - Plugin name/namespace or alias.
   * @param {boolean} [silent] - If ```true```, silently return undefined even on error.
   * @returns {Object} Plugin object.
   */
  getPlugin = (name, silent) => {
    if (!this[name]) {
      // alias?
      let plugin
      for (const key in this) {
        const item = this[key]
        if (item instanceof Plugin && (item.alias === name || item.pkgName === name)) {
          plugin = item
          break
        }
      }
      if (!plugin) {
        if (silent) return false
        throw this.bajo.error('pluginWithNameAliasNotLoaded%s', name)
      }
      name = plugin.ns
    }
    return this[name]
  }

  /**
   * Get plugin data directory
   *
   * @method
   * @param {string} name - Plugin name (namespace) or alias.
   * @param {boolean} [ensureDir=true] - Set ```true``` (default) to ensure directory is existed.
   * @returns {string}
   */
  getPluginDataDir = (name, ensureDir = true) => {
    const { fs } = this.lib
    const plugin = this.getPlugin(name)
    const dir = `${this.bajo.dir.data}/plugins/${plugin.ns}`
    if (ensureDir) fs.ensureDirSync(dir)
    return dir
  }

  /**
   * Resolve file path from:
   *
   * - local/absolute file
   * - TNsPath (```myPlugin:/path/to/file.txt```)
   * - file under node_modules, e.g. ```myPlugin:node_modules/some-package/file.txt```
   *
   * @method
   * @param {string} file - File path, see above for supported types.
   * @returns {string} Resolved file path.
   */
  getPluginFile = (file) => {
    const { currentLoc } = this.lib.aneka
    const { fs } = this.lib
    const { trim } = this.lib._
    if (!this) return file
    if (file[0] === '.') file = `${currentLoc(import.meta).dir}/${trim(file.slice(1), '/')}`
    if (file.includes(':')) {
      if (file.slice(1, 2) === ':') return file // windows fs
      const { ns, path } = this.bajo.breakNsPath(file, false)
      if (ns !== 'file' && this && this[ns] && ns.length > 1) {
        file = `${this[ns].dir.pkg}${path}`
        if (path.startsWith('node_modules/')) {
          file = `${this[ns].dir.pkg}/${path}`
          if (!fs.existsSync(file)) file = `${this[ns].dir.pkg}/../${path.slice('node_modules/'.length)}`
        }
      }
    }
    return file
  }

  /**
   * Dumping variable on screen. Like ```console.log``` with configurable options. Useful for quick debugging and testing. You can also use it to dump variables in production without worrying about performance because it is using Bajo's built-in cache to store the result of util's inspect, so it will only be processed once for each unique variable.
   *
   * Any argument passed to this method will be displayed on screen.
   * If the last argument is a boolean ```true```, app will quit rightaway after dumping.
   *
   * If you have ```bajoCli``` plugin installed, variables will be displayed in a nice box using ```boxen``` package.
   * Otherwise, it will fallback to ```console.log``` with util's inspect result.
   *
   * To have more control on how the variable is displayed, you can set options in Bajo's config under ```dump``` key.
   * See {@link Bajo#config} for details.
   *
   * @method
   * @param  {...any} args - Variables to dump.
   */
  dump = (...args) => {
    let caller = getCallerFilename()
    caller = caller ? fileURLToPath(caller) : 'Unavailable'
    const opts = last(args)
    const terminate = isPlainObject(opts) && opts.abort
    if (terminate) args.pop()
    const value = args.length === 1 ? args[0] : args
    const options = { ...this.bajo.config.dump }
    if (this.boxen) {
      const result = util.inspect(value, options)
      const opts = { ...this.bajo.config.dump.frame }
      if (options.caller) opts.title = `Caller: ${caller}`
      console.log(this.boxen(result, opts))
    } else {
      const result = util.inspect(options.caller ? [caller, value] : value, options)
      console.log(result)
    }
    if (terminate) process.exit('1')
  }

  /**
   * Boot process:
   *
   * - Parsing {@link module:Lib.parseArgsArgv|program arguments, options} and {@link module:Lib.parseEnv|environment values}
   * - Create {@link Bajo|Bajo} instance & initialize it
   * - {@link module:Helper/Bajo.runAsApplet|Run in applet mode} if ```-a``` or ```--applet``` is given
   *
   * After boot process is completed, event ```bajo:afterBootCompleted``` is emitted.
   *
   * If app mode is ```applet```, it runs your choosen applet instead.
   *
   * @method
   * @async
   * @returns {App}
   * @fires bajo:afterBootCompleted
   */
  boot = async () => {
    this.bajo = new Bajo(this)
    const hooks = (this.options.hooks ?? []).map(item => {
      item.src = item.src ?? 'bajo'
      return item
    })
    this.bajo.hooks.push(...hooks)
    delete this.options.hooks
    // argv/args/env
    const { parseArgsArgv, parseEnv, secToHms } = this.lib.aneka
    const { parseObject } = this.lib
    const { argv, args } = await parseArgsArgv({ cwd: this.options.cwd })

    this.args = args
    this.argv = parseObject(argv, { parseValue: true })
    this.envVars = parseObject(parseEnv(), { parseValue: true })
    if (get(this, 'envVars._.env') === '[object Object]') set(this, 'envVars._.env', 'dev')
    this.applet = this.envVars._.applet ?? this.argv._.applet
    await this.bajo.runHook('bajo:beforeBoot')
    await this.bajo.init()
    if (this.bajoCli) this.boxen = await this.bajo.importPkg('bajoCli:boxen')
    // cache
    this.cache.purge()
    setInterval(this.cache.purge, this.bajo.config.cache.purgeIntvDur)
    // boot complete
    const elapsed = new Date() - this.runAt
    this.bajo.log.debug('bootCompleted%s', secToHms(elapsed, true))
    /**
     * Run after boot process is completed
     *
     * @global
     * @event bajo:afterBootComplete
     * @see {@tutorial hook}
     * @see App#boot
     */
    await this.bajo.runHook('bajo:afterBoot')
    if (this.applet) await runAsApplet.call(this.bajo)
    return this
  }

  /**
   * Terminate the app and back to console.
   *
   * @method
   * @param {string} [signal=SIGINT] - Signal to send.
   */
  exit = (signal = 'SIGINT') => {
    if (signal === true) process.exit('1')
    process.kill(process.pid, signal)
  }

  /**
   * Load internationalization & languages files for particular plugin.
   *
   * @method
   * @param {string} ns - Plugin name.
   */
  loadIntl = (ns) => {
    const { fs } = this.lib

    this[ns].intl = {}
    for (const l of this.bajo.config.intl.supported) {
      this[ns].intl[l] = {}
      const path = `${this[ns].dir.pkg}/extend/bajo/intl/${l}.json`
      if (!fs.existsSync(path)) continue
      const trans = fs.readFileSync(path, 'utf8')
      try {
        this[ns].intl[l] = JSON.parse(trans)
      } catch (err) {}
    }
  }

  _prepTrans = (ns, text, params) => {
    const { fallback, supported } = this.bajo.config.intl
    const { isSet } = this.lib.aneka
    if (!text) {
      text = ns
      ns = 'bajo'
    }
    const opts = last(params)
    let lang = this.bajo.config.lang
    if (isPlainObject(opts)) {
      params.pop()
      if (opts.lang) lang = opts.lang
    }
    if (!unknownLangWarning && !supported.includes(lang)) {
      unknownLangWarning = true
      this.bajo.log.warn(`Unsupported language, fallback to '${fallback}'`)
    }
    const plugins = reverse(without([...this.getAllNs()], ns))
    plugins.unshift(ns)
    plugins.push('bajo')
    let trans
    for (const p of plugins) {
      const store = get(this, `${p}.intl.${lang}`, {})
      trans = get(store, text)
      if (isSet(trans)) break
    }
    if (!isSet(trans)) {
      for (const p of plugins) {
        const store = get(this, `${p}.intl.${fallback}`, {})
        trans = get(store, text)
        if (isSet(trans)) break
      }
    }
    return { ns, text, lang, params, plugins, trans }
  }

  /**
   * Translate text and interpolate with given ```args```.
   *
   * There is a shortcut to this method attached on all plugins. You'll normally call that shorcut
   * instead of this method, because it is bound to plugin's name already
   *
   * ```javascript
   * ... within your main plugin
   * const translated = this.app.t('main', 'My cute cat is %s', 'purring')
   * // or
   * const translated = this.t('My cute cat is %s', 'purring')
   * ```
   * @method
   * @param {string} ns - Namespace.
   * @param {string} text - Text to translate.
   * @param  {...any} params - Arguments.
   * @returns {string}
   */
  t = (ns, text, ...params) => {
    const { formatText, isSet } = this.lib.aneka
    const { isArray, last } = this.lib._
    const { join } = this.bajo
    let { text: newText, trans, params: args } = this._prepTrans(ns, text, params)
    if (!isSet(trans)) trans = newText
    const lang = isPlainObject(last(args)) ? last(args).lang : undefined
    for (const idx in args) {
      const arg = args[idx]
      if (isArray(arg)) args[idx] = join(arg, { lang })
    }
    return formatText(trans, ...args)
  }

  /**
   * Check whether translation text/key exists
   *
   * @method
   * @param {string} ns - Namespace.
   * @param {string} text - Text to translate.
   * @returns {boolean}
   */
  te = (ns, text, ...params) => {
    const { trans } = this._prepTrans(ns, text, params)
    return !!trans
  }

  /**
   * Helper method to list all supported config formats.
   *
   * @returns {string[]}
   */
  getConfigFormats = () => {
    return map(this.configHandlers, 'ext')
  }

  /**
   * Start a plugin.
   *
   * @param {string} ns - Plugin namespace.
   * @param  {...any} args - Arguments to pass to the plugin's start method.
   */
  startPlugin = (ns, ...args) => {
    this[ns].start(...args)
  }

  /**
   * Stop a plugin.
   *
   * @param {string} ns - Plugin namespace.
   * @param  {...any} args - Arguments to pass to the plugin's stop method.
   */
  stopPlugin = (ns, ...args) => {
    // Disabled for now, reserved for future use. It is not a good idea to stop a plugin because other plugins might be dependent on it.
    // this[ns].stop(...args)
  }
}

export default App