const _ = require('lodash'),
  QueryString = require('querystring'),
  Events = require('events')

/**
 * S3オブジェクトの正確な読み込みと書き込みをサポートするクラス
 * S3オブジェクトは機能と属性が多いため、あるオブジェクトを読み込み、加工または未加工でそれを上書きまたは別名保存することが意外と難しい。
 * そのためこのクラスでその複雑な処理を隠蔽する。
 * 存在しない新規のオブジェクトを書き込む操作は想定せず、必ず既存のオブジェクトの読み込みから操作を開始する。
 */
class S3ObjectReadWrite extends Events.EventEmitter {
  /**
   * 
   * @param {Aws.S3} s3 aws-sdkモジュールのS3 APIオブジェクト
   * @param {string} bucket 読み込みするバケット名
   * @param {string} key 読み込みするキー 
   */
  constructor(s3, bucket, key) {
    super()

    this.s3 = s3
    this.bucket = bucket
    this.key = key

    this.attributes = null
    this.body = null
    this.metadata = null
    this.aclGrants = null
    this.tagSet = null
  }

  /**
   * オブジェクトをクローンする
   * 読み込んだオブジェクトを一方で内容を軽量化して上書きし、
   * 一方でバックアップする操作を並行で行うことに備えて分岐を可能にしておく。
   */
  clone() {
    const clone = _.clone(this)
      // metadataは変更する可能性が高いため別インスタンスとしておく
    clone.metadata = _.cloneDeep(clone.metadata)
    return clone
  }

  /**
   * バケットとキーを必ず持つAPI呼び出し用パラメータ
   * 多くの操作で共通で用いる組み合わせのためメソッド化する
   */
  requestParams(other = {}) {
    return _.merge({
      Bucket: this.bucket,
      Key: this.key,
    }, other)
  }

  /**
   * 新しいインスタンスを作成し、同時にheadする
   */
  static async head(...args) {
    const entry = new this(...args)
    await entry.head()
    return entry
  }

  /**
   * 新しいインスタンスを作成し、同時にgetする
   */
  static async get(...args) {
    const entry = new this(...args)
    await entry.get()
    return entry
  }

  /**
   * head(Bodyを伴わない属性の取得)を行う
   */
  async head() {
    const params = this.requestParams()
    this.emit('beforeHead', params)
    const data = await this.s3.headObject(params).promise()
    this.emit('afterHead', params, data)

    this.metadata = data.Metadata || {}
    delete data.Metadata

    this.attributes = _.merge(this.attributes, data)
  }

  /**
   * get(Bodyと属性の取得)を行う
   */
  async get() {
    const params = this.requestParams()
    this.emit('beforeGet', params)
    const data = await this.s3.getObject(params).promise()
    this.emit('afterHead', params, data)

    // BodyとMetadataを特別なプロパティとして分離
    // 変更する可能性が高いため変更を容易にしておく
    this.body = data.Body
    delete data.Body

    this.metadata = data.Metadata || {}
    delete data.Metadata

    this.attributes = _.merge(this.attributes, data)
  }

  /**
   * タグ情報を取得する
   */
  async getTagging() {
    if (this.tagSet) return
    const params = this.requestParams()
    this.emit('beforeGetTagging', params)
    const data = await this.s3.getObjectTagging(params).promise()
    this.emit('afterGetTagging', params, data)
    this.tagSet = data.TagSet
  }

  /**
   * ACLを取得する
   */
  async getAcl() {
    if (this.aclGrants) return
    const params = this.requestParams()
    this.emit('beforeGetAcl', params)
    const data = await this.s3.getObjectAcl(params).promise()
    this.emit('afterGetAcl', params, data)
    this.aclGrants = data.Grants
  }

  /**
   * aclGrantsに格納された認可配列からputObject用のACL関連パラメータを合成する
   */
  putObjectParamsFromAclGrants() {
    const permissions = {
      READ: 'GrantRead',
      READ_ACP: 'GrantReadACP',
      WRITE: 'GrantWrite',
      WRITE_ACP: 'GrantWriteACP',
      FULL_CONTROL: 'GrantFullControl',
    }

    const params = {}
    this.aclGrants.forEach(grant => {
      const perm = grant.Permission
      if (!perm) throw new Error(`No Permission in Grant '${JSON.stringify(grant)}'`)
      const name = permissions[perm]
      if (!name) throw new Error(`Unknown Permission in Grant '${JSON.stringify(grant)}'`)

      const grantee = grant.Grantee
      if (!grantee) throw new Error(`No Grantee in Grant '${JSON.stringify(grant)}'`)

      const pair = {}
      if (grantee.ID) {
        pair.name = 'id'
        pair.value = grantee.ID
      } else if (grantee.URI) {
        pair.name = 'uri'
        pair.value = grantee.URI
      } else if (grantee.EmailAddress) {
        pair.name = 'emailaddress'
        pair.value = grantee.EmailAddress
      } else {
        throw new Error(`No ID, URI or EmailAddress in Grantee '${JSON.stringify(grant)}'`)
      }

      if (!pair.value) throw new Error(`No value in Grantee '${JSON.stringify(grant)}'`)

      const jsoned = JSON.stringify(pair.value)
      params[name] = params[name] || []
      params[name].push(`${pair.name}=${jsoned}`)
    })

    Object.keys(params).forEach(name => {
      params[name] = params[name].join(',')
    })

    return params
  }

  /**
   * 属性を維持してオブジェクトをput
   * aclに_keepを指定すると、元のオブジェクトのACLを完全に移行する
   * @param {string} acl 
   */
  async put(override = {}) {
    _.defaults(override, {
      bucket: this.bucket,
      key: this.key,
      body: this.body,
      metadata: this.metadata,
      acl: this.constructor.defaultAcl || 'public-read',
    })

    if (!override.body) throw new Error('body is required')

    // 属性のタグ件数>0ならタグセットを取得しておく
    if (this.attributes.TagCount) await this.getTagging()

    // ACLが_keepであれば取得しておく
    if (override.acl == '_keep') await this.getAcl()

    // パラメータのベース
    const params = this.requestParams()

    // 属性からputObjectに指定できるパラメータをピックアップする
    // aws-sdkは受け付けないパラメータを渡すとエラーを発生させるため
    _.merge(params, _.pick(this.attributes, [
      'CacheControl',
      'ContentDisposition',
      'ContentEncoding',
      'ContentLanguage',
      'ContentType',
      'Expires',
      'ServerSideEncryption',
      'StorageClass',
      'WebsiteRedirectLocation',
      'SSECustomerAlgorithm',
      'SSECustomerKeyMD5',
      'SSEKMSKeyId',
      'RequestPayer',
    ]))

    // 変更の可能性がある特別なアトリビュート(Bucket, Key, Body、Metadata、ACL)をマージする
    params.Bucket = override.bucket
    params.Key = override.key
    params.Body = override.body
    params.ContentLength = override.body.length
    if (override.acl != '_keep') params.ACL = override.acl
    params.Metadata = override.metadata

    // タグがある場合
    if (this.tagSet) {
      // QueryString形式に変換してputObjectのパラメータに渡す
      const hash = this.tagSet.reduce((hash, tag) => {
        hash[tag.Key] = tag.Value
        return hash
      }, {})
      params.Tagging = QueryString.stringify(hash)
    }

    // ACLの_keepの場合、復元のためのparamsを追加する
    if (override.acl == '_keep') {
      const grantParams = this.putObjectParamsFromAclGrants()
        // GrantWriteがサポートされていないので削除
      delete grantParams.GrantWrite

      _.merge(params, grantParams)
    }

    // putObjectを実行
    this.emit('beforePut', params)
    const data = await this.s3.putObject(params).promise()
    this.emit('afterPut', params, data)
  }
}

S3ObjectReadWrite.defaultAcl = 'public-read'


module.exports = S3ObjectReadWrite