const { strict: assert } = require('assert')

const { Reporter } = require('../base/reporter')
const { DecoderBuffer, EncoderBuffer } = require('../base/buffer')

// Supported tags
const tags = [
  'seq', 'seqof', 'set', 'setof', 'objid', 'bool',
  'gentime', 'utctime', 'null_', 'enum', 'int', 'objDesc',
  'bitstr', 'bmpstr', 'charstr', 'genstr', 'graphstr', 'ia5str', 'iso646str',
  'numstr', 'octstr', 'printstr', 't61str', 'unistr', 'utf8str', 'videostr'
]

// Public methods list
const methods = [
  'key', 'obj', 'use', 'optional', 'explicit', 'implicit', 'def', 'choice',
  'any', 'contains'
].concat(tags)

// Overrided methods list
const overrided = [
  '_peekTag', '_decodeTag', '_use',
  '_decodeStr', '_decodeObjid', '_decodeTime',
  '_decodeNull', '_decodeInt', '_decodeBool', '_decodeList',

  '_encodeComposite', '_encodeStr', '_encodeObjid', '_encodeTime',
  '_encodeNull', '_encodeInt', '_encodeBool'
]

function Node (enc, parent, name) {
  const state = {}
  this._baseState = state

  state.name = name
  state.enc = enc

  state.parent = parent || null
  state.children = null

  // State
  state.tag = null
  state.args = null
  state.reverseArgs = null
  state.choice = null
  state.optional = false
  state.any = false
  state.obj = false
  state.use = null
  state.useDecoder = null
  state.key = null
  state.default = null
  state.explicit = null
  state.implicit = null
  state.contains = null

  // Should create new instance on each method
  if (!state.parent) {
    state.children = []
    this._wrap()
  }
}

const stateProps = [
  'enc', 'parent', 'children', 'tag', 'args', 'reverseArgs', 'choice',
  'optional', 'any', 'obj', 'use', 'alteredUse', 'key', 'default', 'explicit',
  'implicit', 'contains'
]

Node.prototype.clone = function clone () {
  const state = this._baseState
  const cstate = {}
  stateProps.forEach(function (prop) {
    cstate[prop] = state[prop]
  })
  const res = new this.constructor(cstate.parent)
  res._baseState = cstate
  return res
}

Node.prototype._wrap = function wrap () {
  const state = this._baseState
  methods.forEach(function (method) {
    this[method] = function _wrappedMethod () {
      const clone = new this.constructor(this)
      state.children.push(clone)
      return clone[method].apply(clone, arguments)
    }
  }, this)
}

Node.prototype._init = function init (body) {
  const state = this._baseState

  assert(state.parent === null)
  body.call(this)

  // Filter children
  state.children = state.children.filter(function (child) {
    return child._baseState.parent === this
  }, this)
  assert.equal(state.children.length, 1, 'Root node can have only one child')
}

Node.prototype._useArgs = function useArgs (args) {
  const state = this._baseState

  // Filter children and args
  const children = args.filter(function (arg) {
    return arg instanceof this.constructor
  }, this)
  args = args.filter(function (arg) {
    return !(arg instanceof this.constructor)
  }, this)

  if (children.length !== 0) {
    assert(state.children === null)
    state.children = children

    // Replace parent to maintain backward link
    children.forEach(function (child) {
      child._baseState.parent = this
    }, this)
  }
  if (args.length !== 0) {
    assert(state.args === null)
    state.args = args
    state.reverseArgs = args.map(function (arg) {
      if (typeof arg !== 'object' || arg.constructor !== Object) { return arg }

      const res = {}
      Object.keys(arg).forEach(function (key) {
        if (key == (key | 0)) { key |= 0 } // eslint-disable-line eqeqeq
        const value = arg[key]
        res[value] = key
      })
      return res
    })
  }
}

//
// Overrided methods
//

overrided.forEach(function (method) {
  Node.prototype[method] = function _overrided () {
    const state = this._baseState
    throw new Error(`${method} not implemented for encoding: ${state.enc}`)
  }
})

//
// Public methods
//

tags.forEach(function (tag) {
  Node.prototype[tag] = function _tagMethod () {
    const state = this._baseState
    const args = Array.prototype.slice.call(arguments)

    assert(state.tag === null)
    state.tag = tag

    this._useArgs(args)

    return this
  }
})

Node.prototype.use = function use (item) {
  assert(item)
  const state = this._baseState

  assert(state.use === null)
  state.use = item

  return this
}

Node.prototype.optional = function optional () {
  const state = this._baseState

  state.optional = true

  return this
}

Node.prototype.def = function def (val) {
  const state = this._baseState

  assert(state.default === null)
  state.default = val
  state.optional = true

  return this
}

Node.prototype.explicit = function explicit (num) {
  const state = this._baseState

  assert(state.explicit === null && state.implicit === null)
  state.explicit = num

  return this
}

Node.prototype.implicit = function implicit (num) {
  const state = this._baseState

  assert(state.explicit === null && state.implicit === null)
  state.implicit = num

  return this
}

Node.prototype.obj = function obj () {
  const state = this._baseState
  const args = Array.prototype.slice.call(arguments)

  state.obj = true

  if (args.length !== 0) { this._useArgs(args) }

  return this
}

Node.prototype.key = function key (newKey) {
  const state = this._baseState

  assert(state.key === null)
  state.key = newKey

  return this
}

Node.prototype.any = function any () {
  const state = this._baseState

  state.any = true

  return this
}

Node.prototype.choice = function choice (obj) {
  const state = this._baseState

  assert(state.choice === null)
  state.choice = obj
  this._useArgs(Object.keys(obj).map(function (key) {
    return obj[key]
  }))

  return this
}

Node.prototype.contains = function contains (item) {
  const state = this._baseState

  assert(state.use === null)
  state.contains = item

  return this
}

//
// Decoding
//

Node.prototype._decode = function decode (input, options) {
  const state = this._baseState

  // Decode root node
  if (state.parent === null) { return input.wrapResult(state.children[0]._decode(input, options)) }

  let result = state.default
  let present = true

  let prevKey = null
  if (state.key !== null) { prevKey = input.enterKey(state.key) }

  // Check if tag is there
  if (state.optional) {
    let tag = null
    if (state.explicit !== null) { tag = state.explicit } else if (state.implicit !== null) { tag = state.implicit } else if (state.tag !== null) { tag = state.tag }

    if (tag === null && !state.any) {
      // Trial and Error
      const save = input.save()
      try {
        if (state.choice === null) { this._decodeGeneric(state.tag, input, options) } else { this._decodeChoice(input, options) }
        present = true
      } catch (e) {
        present = false
      }
      input.restore(save)
    } else {
      present = this._peekTag(input, tag, state.any)

      if (input.isError(present)) { return present }
    }
  }

  // Push object on stack
  let prevObj
  if (state.obj && present) { prevObj = input.enterObject() }

  if (present) {
    // Unwrap explicit values
    if (state.explicit !== null) {
      const explicit = this._decodeTag(input, state.explicit)
      if (input.isError(explicit)) { return explicit }
      input = explicit
    }

    const start = input.offset

    // Unwrap implicit and normal values
    if (state.use === null && state.choice === null) {
      let save
      if (state.any) { save = input.save() }
      const body = this._decodeTag(
        input,
        state.implicit !== null ? state.implicit : state.tag,
        state.any
      )
      if (input.isError(body)) { return body }

      if (state.any) { result = input.raw(save) } else { input = body }
    }

    if (options && options.track && state.tag !== null) { options.track(input.path(), start, input.length, 'tagged') }

    if (options && options.track && state.tag !== null) { options.track(input.path(), input.offset, input.length, 'content') }

    // Select proper method for tag
    if (state.any) {
      // no-op
    } else if (state.choice === null) {
      result = this._decodeGeneric(state.tag, input, options)
    } else {
      result = this._decodeChoice(input, options)
    }

    if (input.isError(result)) { return result }

    // Decode children
    if (!state.any && state.choice === null && state.children !== null) {
      state.children.forEach(function decodeChildren (child) {
        // NOTE: We are ignoring errors here, to let parser continue with other
        // parts of encoded data
        child._decode(input, options)
      })
    }

    // Decode contained/encoded by schema, only in bit or octet strings
    if (state.contains && (state.tag === 'octstr' || state.tag === 'bitstr')) {
      const data = new DecoderBuffer(result)
      result = this._getUse(state.contains, input._reporterState.obj)
        ._decode(data, options)
    }
  }

  // Pop object
  if (state.obj && present) { result = input.leaveObject(prevObj) }

  // Set key
  if (state.key !== null && (result !== null || present === true)) { input.leaveKey(prevKey, state.key, result) } else if (prevKey !== null) { input.exitKey(prevKey) }

  return result
}

Node.prototype._decodeGeneric = function decodeGeneric (tag, input, options) {
  const state = this._baseState

  if (tag === 'seq' || tag === 'set') { return null }
  if (tag === 'seqof' || tag === 'setof') { return this._decodeList(input, tag, state.args[0], options) } else if (/str$/.test(tag)) { return this._decodeStr(input, tag, options) } else if (tag === 'objid' && state.args) { return this._decodeObjid(input, state.args[0], state.args[1], options) } else if (tag === 'objid') { return this._decodeObjid(input, null, null, options) } else if (tag === 'gentime' || tag === 'utctime') { return this._decodeTime(input, tag, options) } else if (tag === 'null_') { return this._decodeNull(input, options) } else if (tag === 'bool') { return this._decodeBool(input, options) } else if (tag === 'objDesc') { return this._decodeStr(input, tag, options) } else if (tag === 'int' || tag === 'enum') { return this._decodeInt(input, state.args && state.args[0], options) }

  if (state.use !== null) {
    return this._getUse(state.use, input._reporterState.obj)
      ._decode(input, options)
  } else {
    return input.error(`unknown tag: ${tag}`)
  }
}

Node.prototype._getUse = function _getUse (entity, obj) {
  const state = this._baseState
  // Create altered use decoder if implicit is set
  state.useDecoder = this._use(entity, obj)
  assert(state.useDecoder._baseState.parent === null)
  state.useDecoder = state.useDecoder._baseState.children[0]
  if (state.implicit !== state.useDecoder._baseState.implicit) {
    state.useDecoder = state.useDecoder.clone()
    state.useDecoder._baseState.implicit = state.implicit
  }
  return state.useDecoder
}

Node.prototype._decodeChoice = function decodeChoice (input, options) {
  const state = this._baseState
  let result = null
  let match = false

  Object.keys(state.choice).some(function (key) {
    const save = input.save()
    const node = state.choice[key]
    try {
      const value = node._decode(input, options)
      if (input.isError(value)) { return false }

      result = { type: key, value: value }
      match = true
    } catch (e) {
      input.restore(save)
      return false
    }
    return true
  }, this)

  if (!match) { return input.error('Choice not matched') }

  return result
}

//
// Encoding
//

Node.prototype._createEncoderBuffer = function createEncoderBuffer (data) {
  return new EncoderBuffer(data, this.reporter)
}

Node.prototype._encode = function encode (data, reporter, parent) {
  const state = this._baseState
  if (state.default !== null && state.default === data) { return }

  const result = this._encodeValue(data, reporter, parent)
  if (result === undefined) { return }

  if (this._skipDefault(result, reporter, parent)) { return }

  return result
}

Node.prototype._encodeValue = function encode (data, reporter, parent) {
  const state = this._baseState

  // Decode root node
  if (state.parent === null) { return state.children[0]._encode(data, reporter || new Reporter()) }

  let result = null

  // Set reporter to share it with a child class
  this.reporter = reporter

  // Check if data is there
  if (state.optional && data === undefined) {
    if (state.default !== null) { data = state.default } else { return }
  }

  // Encode children first
  let content = null
  let primitive = false
  if (state.any) {
    // Anything that was given is translated to buffer
    result = this._createEncoderBuffer(data)
  } else if (state.choice) {
    result = this._encodeChoice(data, reporter)
  } else if (state.contains) {
    content = this._getUse(state.contains, parent)._encode(data, reporter)
    primitive = true
  } else if (state.children) {
    content = state.children.map(function (child) {
      if (child._baseState.tag === 'null_') { return child._encode(null, reporter, data) }

      if (child._baseState.key === null) { return reporter.error('Child should have a key') }
      const prevKey = reporter.enterKey(child._baseState.key)

      if (typeof data !== 'object') { return reporter.error('Child expected, but input is not object') }

      const res = child._encode(data[child._baseState.key], reporter, data)
      reporter.leaveKey(prevKey)

      return res
    }, this).filter(function (child) {
      return child
    })
    content = this._createEncoderBuffer(content)
  } else {
    if (state.tag === 'seqof' || state.tag === 'setof') {
      if (!(state.args && state.args.length === 1)) { return reporter.error(`Too many args for: ${state.tag}`) }

      if (!Array.isArray(data)) { return reporter.error('seqof/setof, but data is not Array') }

      const child = this.clone()
      child._baseState.implicit = null
      content = this._createEncoderBuffer(data.map(function (item) {
        const state = this._baseState

        return this._getUse(state.args[0], data)._encode(item, reporter)
      }, child))
    } else if (state.use !== null) {
      result = this._getUse(state.use, parent)._encode(data, reporter)
    } else {
      content = this._encodePrimitive(state.tag, data)
      primitive = true
    }
  }

  // Encode data itself
  if (!state.any && state.choice === null) {
    const tag = state.implicit !== null ? state.implicit : state.tag
    const cls = state.implicit === null ? 'universal' : 'context'

    if (tag === null) {
      if (state.use === null) { reporter.error('Tag could be omitted only for .use()') }
    } else {
      if (state.use === null) { result = this._encodeComposite(tag, primitive, cls, content) }
    }
  }

  // Wrap in explicit
  if (state.explicit !== null) { result = this._encodeComposite(state.explicit, false, 'context', result) }

  return result
}

Node.prototype._encodeChoice = function encodeChoice (data, reporter) {
  const state = this._baseState

  const node = state.choice[data.type]
  if (!node) {
    assert(
      false,
      `${data.type} not found in ${JSON.stringify(Object.keys(state.choice))}`
    )
  }
  return node._encode(data.value, reporter)
}

Node.prototype._encodePrimitive = function encodePrimitive (tag, data) {
  const state = this._baseState

  if (/str$/.test(tag)) { return this._encodeStr(data, tag) } else if (tag === 'objid' && state.args) { return this._encodeObjid(data, state.reverseArgs[0], state.args[1]) } else if (tag === 'objid') { return this._encodeObjid(data, null, null) } else if (tag === 'gentime' || tag === 'utctime') { return this._encodeTime(data, tag) } else if (tag === 'null_') { return this._encodeNull() } else if (tag === 'int' || tag === 'enum') { return this._encodeInt(data, state.args && state.reverseArgs[0]) } else if (tag === 'bool') { return this._encodeBool(data) } else if (tag === 'objDesc') { return this._encodeStr(data, tag) } else { throw new Error(`Unsupported tag: ${tag}`) }
}

Node.prototype._isNumstr = function isNumstr (str) {
  return /^[0-9 ]*$/.test(str)
}

Node.prototype._isPrintstr = function isPrintstr (str) {
  return /^[A-Za-z0-9 '()+,-./:=?]*$/.test(str)
}

module.exports = Node
