const Base = require('./base'),
  QueryString = require('querystring'),
  RequestPromise = require('request-promise-native'),
  LightFileScripts = require('lightfile-scripts'),
  Fs = require('fs'),
  FilesizeParser = require('filesize-parser'),
  Path = require('path'),
  _ = require('lodash'),
  Package = require('../package.json'),
  { FirehoseClient, PutRecordCommand } = require('@aws-sdk/client-firehose'),
  Moment = require('moment'),
  Sha1 = require('sha1'),
  Filesize = require('filesize'),
  Percentage = require('percentage')

const ACTIVATION_URL = 'https://op.lightfile.net/activate',
  ACTIVATION_TIMEOUT = 10 * 1000

const FIREHOSE_LOG_CONFIG = {
  region: 'ap-northeast-1',
  credentials: {
    accessKeyId: 'AKIAJ76V4ROQDTR3OMUQ',
    secretAccessKey: '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',
  },
}

/**
 * Faas版LightFileの設定ベースクラス
 * Google Cloud Functionなどに対応するときに備えて共通部分をFaaSクラスとして実装しておく。
 * 主にダウンロード・バックアップ・上書きアップロード・通知の部分がサービスにより異なるが、他の部分はほぼ共通となる。
 */
class FaasConfig extends Base.Config {
  defaultValues() {
    return this.constructor.defaultsDeep({
      appEnv: process.env.APP_ENV || 'prod',
      logging: 'normal',
      serial: '',
      labels: {
        location: '',
        charge: '',
      },
      activation: {
        urlBase: ACTIVATION_URL,
        timeout: ACTIVATION_TIMEOUT,
      },
      scripts: {
        jpeg: true,
        png: true,
      },
      file: {
        quality: 'medium',
        minSize: 5 * 1024,
        maxSize: 5 * 1024 * 1024,
        jpeg: {
          quality: null,
        },
        png: {
          quality: null,
        },
      }
    }, super.defaultValues())
  }

  /**
   * 開発モードか
   */
  get isDevMode() {
    return this.get('appEnv', 'prod') == 'dev'
  }

  /**
   * 高速実行モードか
   */
  get isFastMode() {
    return this.get('appEnv', 'prod') == 'fast'
  }

  /**
   * 品質値を正規化
   * 小文字にしてlow/medium/highのいずれかにする。該当しない場合はmediumにする。
   * @param {string} quality 
   */
  static normalizeQuality(quality) {
    quality = quality.toLowerCase()
    quality = ['low', 'medium', 'high'].includes(quality) ? quality : 'medium'
    return quality
  }

  /**
   * 環境変数から設定値の読み込みを行う
   * @param {Object} envs 
   */
  mappingEnvs(envs) {
    const mapping = {
      SERIAL: 'serial',
      APP_ENV: 'appEnv',
      LOGGING: 'logging',
      VERBOSE: (c, v) => c.set('logging', Base.Structure.truthy(v) ? 'debug' : 'normal'),
      NO_JPEG: (c, v) => c.set('scripts.jpeg', !Base.Structure.truthy(v)),
      NO_PNG: (c, v) => c.set('scripts.png', !Base.Structure.truthy(v)),
      QUALITY: (c, v) => c.set('file.quality', this.constructor.normalizeQuality(v)),
      JPEG_QUALITY: (c, v) => c.set('file.jpeg.quality', this.constructor.normalizeQuality(v)),
      PNG_QUALITY: (c, v) => c.set('file.png.quality', this.constructor.normalizeQuality(v)),
      MIN_SIZE: (c, v) => c.set('file.minSize', FilesizeParser(v)),
      MIN_SOURCE_SIZE: (c, v) => c.set('file.minSize', FilesizeParser(v)),
      MAX_SIZE: (c, v) => c.set('file.maxSize', FilesizeParser(v)),
      MAX_SOURCE_SIZE: (c, v) => c.set('file.maxSize', FilesizeParser(v)),
      LOCATION_LABEL: 'label.location',
      CHARGE_LABEL: 'label.charge',
    }

    this.mapping(mapping, envs)
  }
}

/**
 * Faas版LightFileのコンテキストベースクラス
 * Google Cloud Functionなどに対応するときに備えて共通部分をFaaSクラスとして実装しておく。
 * 主にダウンロード・バックアップ・上書きアップロード・通知の部分がサービスにより異なるが、他の部分はほぼ共通となる。
 */
class FaasContext extends Base.Context {
  constructor(values = {}) {
    super(values)
  }

  defaultValues() {
    return this.constructor.defaultsDeep({
      record: {
        location: '',
        relPath: '',
        beforeSize: 0,
        afterSize: 0,
        scriptClass: '',
        startedAt: Moment().format(),
        charge: 'charge',
        chargeLabel: null,
        locationLabel: null,
        myhost: 'myhost',
        myhostLabel: null,
        session: '',
        memorySize: null,
        duration: null,
        quality: null,
      },
      startedMoment: Moment(),
      error: null,
      lastMessage: null,
      convert: {
        srcTmp: null,
        destTmp: null,
        metrics: null,
      },
      done: false,
    }, super.defaultValues())
  }

  /**
   * URIの導出
   */
  get uri() {
    return [
      this.get('record.location', '').replace(/\/+$/, ''),
      this.get('record.relPath', '').replace(/^\/+/, ''),
    ].join('/')
  }

  static sessionize(phrase, len = 8) {
    const hash = Sha1(phrase).slice(0, len)
    return hash
  }
}

/**
 * アクティベーションを実行するアクタークラス
 * 入力はシリアルコード、出力はなしかエラーというシンプルさだが、ネットワーク通信を伴うロジックのためアクターとして実装した。
 */
class FaasActivator extends Base.Actor {
  static get configClass() { return FaasConfig }
  static get contextClass() { return FaasContext }

  updateContextStatus(status, error) {
    if (status) this.context.set('activation.status', status)
    if (error) this.context.set('activation.error', error)
  }

  async verifySerial(serial) {
    const qs = QueryString.stringify({ code: serial })
    const url = [this.config.get('activation.urlBase'), qs].join('?')

    try {
      const res = await RequestPromise({
        url,
        timeout: this.config.getAsInt('activation.timeout'),
      })
      return JSON.parse(res)
    } catch (ex) {
      if (ex.statusCode) {
        if (ex.statusCode < 400) {
          // 400未満のエラーは意図しないサーバー側の問題のため合格とみなす
          return { code: serial, warning: ex.error }
        } else if (ex.statusCode < 500) {
          // 400台はクライアントエラーのため失敗とみなす
          throw new FaasActivatorError(`${serial}は正しいシリアルコードではありません`)
        } else {
          // 500以上はサーバーエラーのため失敗とみなす
          return { code: serial, warning: `アクティベーションサーバーのエラー: ${ex.error}` }
        }
      } else {
        if (ex.message.match(/TIMEDOUT/)) {
          // タイムアウトはサーバー側の問題とみなし成功扱いとする
          return { code: serial, warning: 'アクティベーションがタイムアウト' }
        } else if (ex.message.match(/not be established|ENOTFOUND/)) {
          // 接続エラーはクライアント側がオフラインのため失敗扱いとする
          throw new FaasActivatorError('オフラインのためアクティベーション不可')
        } else {
          // その他のエラーもクライアント起因とみなし失敗扱いとする
          throw ex
        }
      }

    }
  }
}

class FaasActivatorError extends Error {}

/**
 * Faas版LightFileのアプリケーションベースクラス
 * Google Cloud Functionなどに対応するときに備えて共通部分をFaaSクラスとして実装しておく。
 * 主にダウンロード・バックアップ・上書きアップロード・通知の部分がサービスにより異なるが、他の部分はほぼ共通となる。
 */
class FaasApp extends Base.App {
  static get configClass() { return FaasConfig }
  static get contextClass() { return FaasContext }

  /**
   * ロガーオブジェクトの作成
   * プレフィックスやログの記録に対象となるファイルのURI情報を付加する。
   * Lambdaの起動が輻輳するとCloudWatchログに複数のパスに関する処理が混在するため、ログのプレフィックスにパスをつけておくと見やすい。
   * @param {Object} values 
   */
  newLogger(values = {}) {
    // URI関連をマージ
    _.merge(values, {
      location: this.context.get('record.location', ''),
      relPath: this.context.get('record.relPath', ''),
      uri: this.context.uri,
    })

    // phaseプレフィックスにURIを追加
    if (values.phase) values.phase = `${values.phase}; ${this.context.uri}`

    const logger = super.newLogger(values)
    logger.logLevel = this.config.get('logging') == 'debug' ? 'TRACE' : 'INFO'
    return logger
  }

  /**
   * 対応する全スクリプトクラスを返す
   * 主に対象のパスの拡張子を処理の対象とするべきかを判定するために全クラスリストを対象にrunnaleForテストをする。
   * NO_JPEGやNO_PNGの指定に応じて絞り込むために外部化する
   */
  scripts() {
    if (this._scripts) return this._scripts

    this._scripts = []
    if (this.config.getAsBool('scripts.jpeg')) this._scripts.push(LightFileScripts.JpegScript)
    if (this.config.getAsBool('scripts.png')) this._scripts.push(LightFileScripts.PngScript)

    return this._scripts
  }

  /**
   * 設定を読み込む
   * 基本的には環境変数からマッピングする。
   * そうでないFaas実装の場合はオーバーライドする。
   */
  async loadConfig() {
    this.config.mappingEnvs(process.env)
  }

  /**
   * コンテキストを初期化する
   * あまり実行することはないと思われるが、特別な値の初期化や、処理の中断があればここで実装する。
   */
  async initContext() {}

  /**
   * アクティベーション
   * アクティベーションを実行し、エラーが発生したら処理を中断させる。
   */
  async activate(logger) {
    if (this.config.isFastMode) {
      logger.debug(`高速実行モードのためアクティベーションを省略します`)
      return
    }

    if (this.config.isDevMode) {
      logger.debug(`開発モードのためアクティベーションを省略します`)
      return
    }

    const serial = this.config.get('serial')
    const activator = await FaasActivator.create()
    const res = await activator.verifySerial(serial)
    this.context.set('activation', res)
  }

  /**
   * ファイルのダウンロード
   * プラットフォームやインターフェースによって異なるため抽象化する。
   * context.convert.srcTmp に変換を行う一時ファイルを作成する。
   */
  async downloadFile() {}

  /**
   * 処理を続行するかを監査する
   * { size: Integer, status: String }
   * @param {Object} values 
   */
  async auditToProcess(values) {
    // ファイルパス
    if (values.pathname) {
      const runnable = this.scripts().some(s => s.runnableFor(values.pathname))
      if (!runnable)
        throw new FaasSkipByUnsupported(Path.extname(values.pathname))
    }

    // メタデータのステータス(処理済みやバックアップファイルは除外)
    if (values.status)
      throw new FaasIgnoreByMeta(values.status)

    if (values.size) {
      const minSize = this.config.getAsInt('file.minSize'),
        maxSize = this.config.getAsInt('file.maxSize')
      if (values.size < minSize)
        throw new FaasSkipByMinSize(values.size, minSize)
      if (values.size > maxSize)
        throw new FaasSkipByMaxSize(values.size, maxSize)
    }
  }

  /**
   * スクリプトオブジェクトを作成する
   * @param {string} path 
   */
  scriptForPath() {
    let script

    const srcPath = this.context.get('convert.srcPath'),
      opts = { quality: this.config.get('file.quality') }

    if (LightFileScripts.JpegScript.runnableFor(srcPath)) {
      opts.loops = 8
      const q = this.config.get('file.jpeg.quality')
      if (q) opts.quality = q
      script = new LightFileScripts.JpegScript(opts)
    } else if (LightFileScripts.PngScript.runnableFor(srcPath)) {
      const q = this.config.get('file.png.quality')
      if (q) opts.quality = q
      script = new LightFileScripts.PngScript(opts)
    } else {
      throw new FaasSkipByUnsupported(Path.extname(srcPath))
    }

    // metricsにscriptClassを設定
    this.context.set('record.scriptClass', script.constructor.name)
    this.context.set('record.quality', opts.quality)

    return script
  }


  /**
   * ファイルの変換
   * 一時ファイルを変換する。context.convert.destTmp に変換語の一時ファイルを設定する。
   * これは変換前のファイルと同一にしてはいけない。並行してバックアップを行うため、変換中のファイルがバックアップされる恐れがあるため。
   */
  async convertFile(logger) {
    const srcPath = this.context.get('convert.srcPath'),
      script = this.scriptForPath()

    // バッファかtmpファイルから軽量化する一時ファイルを作成
    const destTmp = await this.context.newTmpFile({ for: srcPath }),
      srcTmp = this.context.get('convert.srcTmp'),
      srcBuffer = this.context.get('convert.srcBuffer')

    if (srcBuffer) {
      await new Promise((ok, ng) => Fs.writeFile(destTmp.name, srcBuffer, (err, data) => err ? ng(err) : ok(data)))
    } else if (srcTmp) {
      await new Promise((ok, ng) => Fs.copyFile(srcTmp.name, destTmp.name, (err, data) => err ? ng(err) : ok(data)))
    } else {
      throw new Error(`処理の対象となるバッファか一時ファイルが前のフェーズから渡させれていません。`)
    }

    // 軽量化を実行
    logger.trace(() => `スクリプトクラス${script.constructor.name}で変換処理を開始`)
    const metrics = await script.run(destTmp.name)
    logger.debug(() => `スクリプトの戻り値: ${JSON.stringify(metrics)}`)
    this.context.set('convert.metrics', metrics)
    this.context.set('record.beforeSize', metrics.before.size)
    this.context.set('record.afterSize', metrics.after.size)

    // すでに削減済みの場合はここで処理をスキップ
    if (metrics.alreadyDone) {
      throw new FaasSkipByAlrady(metrics.tool || 'LightFile')
    }

    // 軽量化の効果がない場合は記録を残してスキップ
    if (metrics.unavailable) {
      throw new FaasSkipByUnavailable()
    }

    // 軽量化されたファイルを次の工程に渡す
    this.context.set('convert.destTmp', destTmp)
  }

  /**
   * ファイルのバックアップ
   * context.convert.srcTmp の一時ファイルを対象にバックアップする
   */
  async backupFile() {}

  /**
   * アクティベーションとファイルの処理を並列に実行
   */
  async activateAndRun() {
    // 開発モードの場合はactivationをPromise.resolve()で省略する
    // downloadAndRunは複合メソッドなのでphaseで括らない
    await Promise.all([
      this.phase('activate', logger => this.activate(logger)),
      this.downloadAndRun(),
    ])
  }

  /**
   * ダウンロードと次の処理を直列に実行
   */
  async downloadAndRun() {
    await this.phase('downloadFile', logger => this.downloadFile(logger))
      // convertAndBackupは複合メソッドなのでphaseで括らない
    await this.convertAndBackup()
  }

  /**
   * 軽量化とバックアップを配列に実行
   */
  async convertAndBackup() {
    await Promise.all([
      this.phase('convertFile', logger => this.convertFile(logger)),
      this.phase('backupFile', logger => this.backupFile(logger)),
    ])
  }

  /**
   * ファイルのアップロード
   * context.convert.destTmp の一時ファイルを対象にアップロードする
   */
  async uploadFile() {}

  /**
   * 
   */
  async notify() {}

  async complete(logger) {
    const packageName = this.constructor.packageName
    const record = this.context.get('record')

    const beforeSize = this.context.get('record.beforeSize', null),
      afterSize = this.context.get('record.afterSize', null)

    const values = _.defaultsDeep({
      '@schema': `${packageName}.complete`,
      '@category': 'metrics',
      beforeSize,
      afterSize,
      // serialはserial
    }, record)

    // fileSystemUriはlocationのエイリアス
    values.fileSystemUri = values.location

    // ~Labelは指定がない場合は元の値で補う
    values.chargeLabel = values.chargeLabel || values.charge
    values.locationLabel = values.locationLabel || values.location
    values.myhostLabel = values.myhostLabel || values.myhost

    const now = Moment(),
      started = this.context.get('startedMoment')
    if (started) {
      values.duration = +now - +started
    }

    // サマリー
    let summary = ': ファイルサイズ不明'
    if (beforeSize > 0 && afterSize > 0) {
      const percentage = (afterSize - beforeSize) / beforeSize
      summary = `${Filesize(beforeSize)} → ${Filesize(afterSize)} (${Percentage(percentage)})`
    }

    logger.info(`軽量化が完了: ${summary}`, values)
  }

  async teardown() {
    await this.context.teardown()
  }

  firehoseLogDefaults() {
    return {}
  }

  async sendLogs(logger) {
    if (this.config.isFastMode) {
      logger.debug('高速実行モードのため統計ログの送信を省略します')
      return
    }

    const packageName = this.constructor.packageName
    const loggerClass = this.constructor.loggerClass

    const logDefaults = this.firehoseLogDefaults()
    logDefaults.serial = this.config.get('serial')

    const records = loggerClass.queryItems('INFO')
    this.logsToSend = records.map(r => {
      try {
        const value = _.merge({
          '@schema': `${packageName}.log`,
          '@version': Package.version,
          '@timestamp': r.moment.format(),
          '@category': 'default',
          '@product': 'lightfile',
          'product.package': packageName,
          'product.version': Package.version,
          message: r.format(),
        }, logDefaults, r.values)

        value['@level'] = value.level || 'INFO'
        delete value.level

        return value
      } catch (ex) {
        logger.error(() => `ログの整形でエラーが発生: ${ex.message}`)
        return
      }
    }).filter(r => !!r)

    if (!this.config.isDevMode) {
      await this.putObjectArrayToFirehose(this.logsToSend)
    }
  }

  async runAll() {
    const logger = this.newLogger({ phase: 'runAll' })
    try {
      // 設定の読み込み
      await this.phase('loadConfig', logger => this.loadConfig(logger))

      // コンテキストの初期化
      await this.phase('initContext', logger => this.initContext(logger))

      // アクティベーションとメインの処理を並行で実行
      // activateAndRunは複合メソッドなのでphaseで括らない
      await this.activateAndRun()

      // ファイルのアップロード
      await this.phase('uploadFile', logger => this.uploadFile(logger))

      // 通知
      await this.phase('notify', logger => this.notify(logger))

      // 完了ログ
      await this.phase('complete', logger => this.complete(logger))
    } catch (ex) {
      // 最終エラーを記録
      this.context.set('error', ex)

      if (ex instanceof Base.IgnoreException) {
        logger.info(() => `ファイルを無視: ${ex.message}`)
      } else if (ex instanceof Base.SkipException) {
        logger.info(() => `ファイルをスキップ: ${ex.message}`)
      } else {
        logger.error(() => `エラーの発生により中断: ${ex.message}`)
        throw ex
      }
    } finally {
      // 一時ファイルなどの削除
      try {
        logger.trace(() => `終了処理を開始`)
        await this.teardown()
        logger.trace(() => `終了処理を完了`)
      } catch (ex) {
        logger.warn(() => `終了処理でエラーが発生: ${ex.message}`)
      }

      // ログをFirehoseに送信
      try {
        logger.trace(() => `ログを集約システムに送信開始`)
        await this.sendLogs(logger)
        logger.trace(() => `ログを集約システムに送信完了`)
      } catch (ex) {
        logger.warn(() => `ログの集約システムへの送信でエラーが発生: ${ex.message}`)
      }
    }
    return logger
  }

  buildResponse() {
    return {}
  }

  /**
   * ログ集約用のFirehose APIオブジェクト
   * Lambda以外のFaasでどのストリーミングサービスを使うか不明だが、ひとまずFirehoseが有力候補なので
   * Base(フレームワークレイヤー)で実装する。
   * node20対応時にaws-sdk-v3に移行。そのためFirehoseClientを返す。
   */
  firehose() {
    return new FirehoseClient(_.defaultsDeep({
      apiVersion: '2015-08-04',
    }, FIREHOSE_LOG_CONFIG))
  }

  /**
   * オブジェクト配列をNDJSONにシリアライズ
   * pubObjectArrayToFirehoseのためのメソッドだがテストに備えて分離
   * @param {Arary} objectArray 
   */
  objectArrayToNDJSON(objectArray) {
    let ndjson = objectArray
      .filter(o => _.isObject(o))
      .map(o => JSON.stringify(o))
      .join("\n")
    if (ndjson !== '') ndjson += "\n"
    return ndjson
  }

  /**
   * NDJSONデータをLightFile Firehoseに送信
   * @param {string} ndjson 
   */
  async putNDJSONRecordToFirehose(ndjson) {
    const firehose = this.firehose()
    const command = new PutRecordCommand({
      DeliveryStreamName: 'LightFile',
      Record: {
        Data: Buffer.from(ndjson),
      }
    })
    const result = await firehose.send(command)
    return result
  }

  /**
   * FirehoseにNDJSONを送信
   * @param {Array} objectArray 
   */
  async putObjectArrayToFirehose(objectArray) {
    const ndjson = this.objectArrayToNDJSON(objectArray)
    return await this.putNDJSONRecordToFirehose(ndjson)
  }
}

class FaasSkipByUnsupported extends Base.SkipException {
  constructor(ext) {
    super(`拡張子${ext}はサポート対象外`)
    this.ext = ext
  }
}

class FaasIgnoreByMeta extends Base.IgnoreException {
  constructor(reason) {
    super(`LightFileで生成されたファイル(${reason})`)
    this.reason = reason
  }
}

class FaasSkipByAlrady extends Base.SkipException {
  constructor(tool) {
    super(`${tool}で軽量化済み`)
    this.tool = tool
  }
}

class FaasSkipByMinSize extends Base.SkipException {
  constructor(size, minSize) {
    super(`ファイルサイズが${size}(最小${minSize})`)
    this.size = size
    this.minSize = minSize
  }
}

class FaasSkipByMaxSize extends Base.SkipException {
  constructor(size, maxSize) {
    super(`ファイルサイズが${size}(最大${maxSize})`)
    this.size = size
    this.maxSize = maxSize
  }
}

class FaasSkipByUnavailable extends Base.SkipException {
  constructor() {
    super(`軽量化の効果なし`)
  }
}

module.exports = {
  FaasConfig,
  FaasContext,
  FaasApp,
  FaasActivator,
  FaasActivatorError,
  FaasSkipByUnsupported,
  FaasIgnoreByMeta,
  FaasSkipByAlrady,
  FaasSkipByMinSize,
  FaasSkipByMaxSize,
  FaasSkipByUnavailable,
}