const _ = require('lodash'),
  Moment = require('moment'),
  Mustache = require('mustache'),
  Flat = require('flat'),
  Path = require('path'),
  Tmp = require('tmp'),
  Util = require('util')

/**
 * シンプルな受動的オブジェクトを実現するクラス
 * 1件1件のログレコードなど
 */
class Asset {
  constructor(values = {}) {
    values = Object.assign(this.defaultValues(), values || {})
    Object.assign(this, values)
  }

  /**
   * デフォルト値となるオブジェクトを定義し、コンストラクタに渡すためのプレースホルダ
   * コンストラクタから参照されるため用意する。
   */
  defaultValues() {}
}

/**
 * 複雑な階層を持つか、Assetの集合を実現するクラス
 * 設定やコンテキストなど
 */
class Structure {
  constructor(values = {}) {
    values = this.constructor.defaultsDeep(values, this.defaultValues())
    this.values = values
  }

  static truthy(v) {
    if (_.isBoolean(v)) return v
    if (_.isString(v)) return !v.match(/^(false|no|off|0+|)$/i)
    return Boolean(v)
  }

  /**
   * パスを指定して構造体の値を取得
   * 存在しないパスを参照したときにエラーにさせないため用意
   * @param {string} path 
   * @param {*} defaultValue 
   */
  get(path, defaultValue) {
    return _.get(this.values, path, defaultValue)
  }

  /**
   * パスを指定して構造体の値を整数値として取得
   * 値の形式によりエラーにさせず、必ず整数値を返すために用意
   * @param {string} path 
   * @param {*} defaultValue 
   */
  getAsInt(path, defaultValue = NaN) {
    try {
      const res = this.get(path, defaultValue)
      const int = parseInt(res)
      return Number.isNaN(int) ? defaultValue : int
    } catch (ex) {
      return defaultValue
    }
  }

  /**
   * パスを指定して構造体の値をオブジェクトとして取得
   * 値の形式によりエラーにさせず、必ずオブジェクトを返すために用意
   * @param {string} path 
   * @param {*} defaultValue 
   */
  getAsObject(path, defaultValue = {}) {
    const res = this.get(path, defaultValue)
    if (_.isObject(res)) return res
    if (_.isObject(defaultValue)) return defaultValue
    return {}
  }

  /**
   * パスを指定して構造体の値を真偽値として取得
   * 値の形式によりエラーにさせず、必ず真偽値を返すために用意
   * @param {string} path 
   */
  getAsBool(path) {
    return this.constructor.truthy(this.get(path))
  }

  /**
   * パスを指定して構造体に値を設定
   * @param {string} path 
   * @param {*} value 
   */
  set(path, value) {
    _.set(this.values, path, value)
  }

  /**
   * オブジェクト値をdeep mergeする
   * @param {Object} values 
   */
  merge(values) {
    _.merge(this.values, values)
  }

  /**
   * オブジェクト値をdeep defaultsする
   * 頻出する処理だが、毎回lodashを必要とするのが手間なのでヘルパとして用意する
   * @param {Object} values 
   * @param {Object} defaults 
   */
  static defaultsDeep(values, defaults) {
    return _.defaultsDeep(values, defaults)
  }

  /**
   * mapping(KEY-VALUEペア)に基づき、valuesの値から自身に取り込む。
   * 環境変数からの設定値の設定を読み込むためのユーティリティ
   * @param {Object} mapping 
   * @param {Object} values 
   */
  mapping(mapping, values) {
    const keys = _.intersection(Object.keys(mapping), Object.keys(values))
    for (let key of keys) {
      const path = mapping[key]
      const value = values[key]
      if (_.isFunction(path)) {
        path(this, value)
      } else if (_.isObject(path)) {
        if (value) {
          for (let merge in path) {
            this.set(merge, path[merge])
          }
        }
      } else {
        this.set(path, value)
      }
    }
  }

  /**
   * デフォルト値となるオブジェクトを定義する
   * コンストラクタで利用するのでプレースホルダとして定義
   */
  defaultValues() {}
}

/**
 * 設定値クラス
 * 機能の拡張により階層化した方がよいためStructureから継承する
 */
class Config extends Structure {}

/**
 * コンテキストクラス
 * 一連の処理をいくつかのメソッドや
 * 機能の拡張により階層化した方がよいためStructureから継承する
 */
class Context extends Structure {
  constructor(values = {}) {
    super(values)
    this.tmps = []
  }

  /**
   * 一時ファイルの作成
   * @param {Object} opts 
   */
  newTmpFile(opts = {}) {
    if (opts.for) {
      opts.postfix = Path.extname(opts.for)
      delete opts.for
    }
    const tmp = Tmp.fileSync(opts)
    this.tmps.push(tmp)
    return tmp
  }

  /**
   * 一時ディレクトリの作成
   * @param {Object} opts 
   */
  newTmpDir(opts = {}) {
    const tmp = Tmp.dirSync(_.defaults(opts, {
      unsafeCleanup: true
    }))
    this.tmps.push(tmp)
    return tmp
  }

  /**
   * 終了処理としてtmpをすべてremoveCallbackする
   */
  async teardown() {
    // 一時ディレクトリの削除
    try {
      const tmps = this.tmps
      this.tmps = []
      for (let tmp of tmps) {
        tmp.removeCallback()
      }
    } catch (ex) {
      console.error(ex)
    }
  }
}

/**
 * ConfigとContextに基づき能動的に処理を実行するアクタークラス
 * Appしか継承しないが、他に能動的に動作するオブジェクトクラスを定義するときに備えて用意する
 */
class Actor {
  constructor(config, context) {
    this.config = config
    this.context = context
  }

  /**
   * createクラスメソッドが生成する設定値クラスを返す
   */
  static get configClass() { return Config }

  /**
   * createクラスメソッドが生成するコンテキストクラスを返す
   */
  static get contextClass() { return Context }

  /**
   * 設定値オブジェクトとコンテキストオブジェクト
   * @param {Object} configValues 
   * @param {Object} contextValues 
   */
  static async create(configValues = {}, contextValues = {}) {
    const config = new this.configClass(configValues),
      context = new this.contextClass(contextValues)

    return new this(config, context)
  }
}

/**
 * ログレコード
 * テンプレート、値、時刻、レベルなどをカプセル化し、テキストにフォーマットするところまで請け負う
 */
class LogRecord extends Asset {
  constructor(level, template, values) {
    super({ level: level.toUpperCase(), template, values })
  }

  defaultValues() {
    return {
      level: 'INFO',
      template: '',
      values: {},
      moment: Moment(),
    }
  }

  /**
   * templateとvaluesを元にMustacheでテキストをレンダリングする
   */
  format() {
    let text = Mustache.render(this.template, Flat.flatten(this.values))
    return text
  }
}

/**
 * ログ記録者クラス
 * プログラムに対し、ログ記録インターフェースを提供するために用意する。
 * 合わせて処理フェーズ管理(エラー発生のフェーズ管理や)
 * エラーが発生した処理フェーズの
 */
class Logger extends Structure {
  constructor(values = {}, options = {}) {
    super(values)
    this.logLevel = options.logLevel || this.constructor.defaultLogLevel
  }

  static inspect(obj) {
    return Util.inspect(obj, false, 10)
  }

  defaultValues() {
    return {
      level: 'info',
      phase: '',
    }
  }

  /**
   * ログレベルの名称と順位を示す
   * ログレベルの上下でフィルタリングをするため整数値との対応を保持
   */
  static get levels() {
    if (this._levels) return this._levels
    return this._levels = {
      TRACE: 100,
      DEBUG: 200,
      INFO: 300,
      WARN: 400,
      ERROR: 500,
      FATAL: 600,
    }
  }

  /**
   * デフォルトのログレベルを返す
   * このクラスを継承した後でデフォルトを変更する場合に備えて用意
   */
  static get defaultLogLevel() {
    return 'INFO'
  }

  /**
   * ログレベルに対応する順位を返す
   * @param {string} name 
   */
  static levelValueOf(name) {
    return this.levels[name] || this.levels[this.defaultLogLevel]
  }

  /**
   * ログレコード配列
   * クラスプロパティにしたのは、ロガーは複数のインスタンスが作成されるが最後にするときは1箇所にあって欲しいから。
   * 複数のインスタンスを生成するのは、並列処理が行われ、それぞれのロガーでデフォルトのプレフィックスやオブジェクト値を持てるように分離したいから。
   * 当初はロガーを親子構造をとれる設計にしたが、Lambda関数ではそこまでする必要がないためシンプルにグローバルな配列を用意することにした。
   */
  static get items() {
    if (this._items) return this._items
    return this._items = []
  }

  /**
   * 指定のログレベルより高い順位のレコード配列を返す
   * @param {string} level 
   */
  static queryItems(level) {
    const levelValue = this.levelValueOf(level || this.defaultLevel)
    return this.items.filter(log => this.levelValueOf(log.level) >= levelValue)
  }

  /**
   * ログレコードを配列に追加
   * @param {LogRecord} record 
   */
  static push(record) {
    this.items.push(record)
  }

  /**
   * ログレコード配列をクリア
   * テストでログをリセットするために用意
   */
  static reset() {
    this._items = []
  }

  /**
   * このロガーがレコードをフィルタリングするログレベル
   * このログレベル以上のレコードしか受け付けない
   */
  get logLevel() {
    if (this._logLevel) return this._logLevel
    return this._logLevel = this.constructor.defaultLogLevel
  }

  /**
   * このロガーがレコードをフィルタリングするログレベル
   */
  set logLevel(level) {
    this._logLevel = level
    delete this._logLevelValue
  }

  /**
   * このロガーがレコードをフィルタリングするログレベルの整数値
   */
  get logLevelValue() {
    if (this._logLevelValue) return this._logLevelValue
    return this._logLevelValue = this.constructor.levelValueOf(this.logLevel)
  }

  /**
   * このロガーがログを記録する処理フェーズ名
   * ログメッセージのプレフィックスとして利用するためのプロパティ
   * @param {string} name 
   */
  phase(name) {
    this.set('phase', name)
  }

  /**
   * コンソール出力向けにログレコードのメッセージを文字列にレンダリングする
   * コンソール出力向けにはフェーズや時刻(Lambdaでは不要)のようなプレフィックスが必要になるから独自のメソッドを用意する。
   * テストに備えて実際の出力とフォーマットで分離メソッドを分離した。
   * @param {LogRecord} record 
   */
  formatForConsole(record) {
    const cols = []
    cols.push(`[${record.level.toUpperCase()}]`)
    if (record.values.phase) cols.push(`<${record.values.phase}>`)
    cols.push(record.format())

    const formatted = cols.join(' ')
    return formatted
  }

  /**
   * コンソール出力を行う
   * テストに備えてformatと分離した。
   * Lambdaではconsoleメソッドによる違いがないので.logで出力し、エラーレベルをプレフィックスに付加する。
   * @param {LogRecord} record 
   */
  console(record) {
    if (this.constructor.silent) return
    console.log(this.formatForConsole(record))
  }

  /**
   * ログレコードを追加
   * 簡単な引数指定でレコードの作成から配列への追加まで行う。
   * INFO未満のレベルは基本的にログテキストが捨てられるため、第2引数は関数を受け取り遅延評価できるようにもした。
   * ロガーのvaluesは、ログレコードが持つデフォルト値として使うのでマージも行う。
   * @param {string} level 
   * @param {string|Function} template 
   * @param {Object} values 
   */
  log(level, template, values = {}) {
    level = level.toUpperCase()
    const levelValue = this.constructor.levelValueOf(level)
    if (levelValue < this.logLevelValue) return

    // templateが関数なら遅延評価する
    if (_.isFunction(template)) {
      const res = template()
      if (_.isArray(res)) {
        template = res.shift() || ''
        values = res.shift() || {}
      } else {
        template = res
      }
    }

    // ロガーのvaluesをマージ
    _.defaults(values, this.values)
    values.level = level

    const record = new LogRecord(level, template, values)
    this.constructor.push(record)
    this.console(record)
  }

  // logメソッドへのエイリアス
  trace(...args) { return this.log('trace', ...args) }
  debug(...args) { return this.log('debug', ...args) }
  info(...args) { return this.log('info', ...args) }
  warn(...args) { return this.log('warn', ...args) }
  error(...args) { return this.log('error', ...args) }
  fatal(...args) { return this.log('fatal', ...args) }
}

/**
 * アプリケーションクラス
 * 特定のジョブを完遂するために主体となるクラス
 */
class App extends Actor {
  /**
   * パッケージ名
   * ジョブを完遂するためのソフトウェアパッケージ名を返す。
   * デフォルトではクラス名の小文字版を返すものとするが、
   * それ以外が望ましい場合にオーバーライドするプレースホルダとして用意しておく。
   */
  static get packageName() { return this.name.toLowerCase() }

  /**
   * ロガークラス
   * アプリケーションクラスとロガークラスはセットとし、アプリケーションには1種類のロガーが対応つけられることとする。
   */
  static get loggerClass() { return Logger }

  /**
   * ロガーオブジェクトを生成する
   * @param {Object} values 
   */
  newLogger(values = {}) {
    return new this.constructor.loggerClass(values)
  }

  /**
   * アプリケーションジョブのタスクの実行フレームワーク
   * タスクの実行前後に、タスク名を明記した新しいロガーの生成とロギング、エラーハンドリングを付加するため。
   * アプリケーションが実行する
   * @param {string} name 
   * @param {Function} logic 
   */
  async phase(name, logic) {
    const logger = this.newLogger({ phase: name })
    try {
      logger.trace(() => 'フェーズを開始')
      const result = await logic(logger)
      logger.trace(() => 'フェーズを完了')
      return result
    } catch (ex) {
      ex.phase = name
      if (ex instanceof Warning) {
        // Warningの場合はログに記録して処理を継続する
        logger.warn(ex.message)
      } else if (ex instanceof IgnoreException || ex instanceof SkipException) {
        // IgnoreとSkipの場合はここでは何もせず上流でキャッチする
        throw ex
      } else {
        // それ以外のIgnore、Skip、Errorは処理を中断して上流でキャッチする
        logger.trace(() => 'フェーズを中断')
        throw ex
      }
    }
  }


}

class SkipException extends Error {}

class IgnoreException extends Error {}

class Warning extends Error {}

module.exports = {
  Asset,
  Structure,
  Config,
  Context,
  Actor,
  LogRecord,
  Logger,
  App,
  SkipException,
  IgnoreException,
  Warning,
}