'use strict'

const md5hex = require('md5-hex')

const argumentsValue = require('./complexValues/arguments')
const arrayBufferValue = require('./complexValues/arrayBuffer')
const boxedValue = require('./complexValues/boxed')
const dataViewValue = require('./complexValues/dataView')
const dateValue = require('./complexValues/date')
const errorValue = require('./complexValues/error')
const functionValue = require('./complexValues/function')
const globalValue = require('./complexValues/global')
const mapValue = require('./complexValues/map')
const objectValue = require('./complexValues/object')
const promiseValue = require('./complexValues/promise')
const regexpValue = require('./complexValues/regexp')
const setValue = require('./complexValues/set')
const typedArrayValue = require('./complexValues/typedArray')

const encoder = require('./encoder')

const itemDescriptor = require('./metaDescriptors/item')
const mapEntryDescriptor = require('./metaDescriptors/mapEntry')
const pointerDescriptor = require('./metaDescriptors/pointer')
const propertyDescriptor = require('./metaDescriptors/property')
const statsDescriptors = require('./metaDescriptors/stats')

const pluginRegistry = require('./pluginRegistry')

const bigIntValue = require('./primitiveValues/bigInt')
const booleanValue = require('./primitiveValues/boolean')
const nullValue = require('./primitiveValues/null')
const numberValue = require('./primitiveValues/number')
const stringValue = require('./primitiveValues/string')
const symbolValue = require('./primitiveValues/symbol')
const undefinedValue = require('./primitiveValues/undefined')

const recursorUtils = require('./recursorUtils')

// Increment if encoding layout, descriptor IDs, or value types change. Previous
// Concordance versions will not be able to decode buffers generated by a newer
// version, so changing this value will require a major version bump of
// Concordance itself. The version is encoded as an unsigned 16 bit integer.
const VERSION = 3

// Adding or removing mappings or changing an index requires the version in
// encoder.js to be bumped, which necessitates a major version bump of
// Concordance itself. Indexes are hexadecimal to make reading the binary
// output easier.
const mappings = [
  [0x01, bigIntValue.tag, bigIntValue.deserialize],
  [0x02, booleanValue.tag, booleanValue.deserialize],
  [0x03, nullValue.tag, nullValue.deserialize],
  [0x04, numberValue.tag, numberValue.deserialize],
  [0x05, stringValue.tag, stringValue.deserialize],
  [0x06, symbolValue.tag, symbolValue.deserialize],
  [0x07, undefinedValue.tag, undefinedValue.deserialize],

  [0x08, objectValue.tag, objectValue.deserialize],
  [0x09, statsDescriptors.iterableTag, statsDescriptors.deserializeIterableStats],
  [0x0A, statsDescriptors.listTag, statsDescriptors.deserializeListStats],
  [0x0B, itemDescriptor.complexTag, itemDescriptor.deserializeComplex],
  [0x0C, itemDescriptor.primitiveTag, itemDescriptor.deserializePrimitive],
  [0x0D, statsDescriptors.propertyTag, statsDescriptors.deserializePropertyStats],
  [0x0E, propertyDescriptor.complexTag, propertyDescriptor.deserializeComplex],
  [0x0F, propertyDescriptor.primitiveTag, propertyDescriptor.deserializePrimitive],
  [0x10, pointerDescriptor.tag, pointerDescriptor.deserialize],

  [0x11, mapValue.tag, mapValue.deserialize],
  [0x12, mapEntryDescriptor.tag, mapEntryDescriptor.deserialize],

  [0x13, argumentsValue.tag, argumentsValue.deserialize],
  [0x14, arrayBufferValue.tag, arrayBufferValue.deserialize],
  [0x15, boxedValue.tag, boxedValue.deserialize],
  [0x16, dataViewValue.tag, dataViewValue.deserialize],
  [0x17, dateValue.tag, dateValue.deserialize],
  [0x18, errorValue.tag, errorValue.deserialize],
  [0x19, functionValue.tag, functionValue.deserialize],
  [0x1A, globalValue.tag, globalValue.deserialize],
  [0x1B, promiseValue.tag, promiseValue.deserialize],
  [0x1C, regexpValue.tag, regexpValue.deserialize],
  [0x1D, setValue.tag, setValue.deserialize],
  [0x1E, typedArrayValue.tag, typedArrayValue.deserialize],
  [0x1F, typedArrayValue.bytesTag, typedArrayValue.deserializeBytes],
]
const tag2id = new Map(mappings.map(mapping => [mapping[1], mapping[0]]))
const id2deserialize = new Map(mappings.map(mapping => [mapping[0], mapping[2]]))

class DescriptorSerializationError extends Error {
  constructor (descriptor) {
    super('Could not serialize descriptor')
    this.name = 'DescriptorSerializationError'
    this.descriptor = descriptor
  }
}

class MissingPluginError extends Error {
  constructor (pluginName) {
    super(`Could not deserialize buffer: missing plugin ${JSON.stringify(pluginName)}`)
    this.name = 'MissingPluginError'
    this.pluginName = pluginName
  }
}

class PointerLookupError extends Error {
  constructor (index) {
    super(`Could not deserialize buffer: pointer ${index} could not be resolved`)
    this.name = 'PointerLookupError'
    this.index = index
  }
}

class UnsupportedPluginError extends Error {
  constructor (pluginName, serializerVersion) {
    super(`Could not deserialize buffer: plugin ${JSON.stringify(pluginName)} expects a different serialization`)
    this.name = 'UnsupportedPluginError'
    this.pluginName = pluginName
    this.serializerVersion = serializerVersion
  }
}

class UnsupportedVersion extends Error { // eslint-disable-line unicorn/custom-error-definition
  constructor (serializerVersion) {
    super('Could not deserialize buffer: a different serialization was expected')
    this.name = 'UnsupportedVersion'
    this.serializerVersion = serializerVersion
  }
}

function shallowSerializeDescriptor (descriptor, resolvePluginRef) {
  if (!descriptor.serialize) return undefined

  return serializeState(descriptor.serialize(), resolvePluginRef)
}

function serializeState (state, resolvePluginRef) {
  if (Array.isArray(state)) return state.map(x => serializeState(x))

  if (state && state.tag) {
    let id, pluginIndex
    if (tag2id.has(state.tag)) {
      id = tag2id.get(state.tag)
      pluginIndex = 0
    } else {
      const ref = resolvePluginRef(state.tag)
      if (ref) {
        id = ref.id
        pluginIndex = ref.pluginIndex
      }
    }

    if (id !== undefined) {
      const serialized = [pluginIndex, id, shallowSerializeDescriptor(state, resolvePluginRef)]
      serialized[encoder.descriptorSymbol] = true
      return serialized
    }
  }

  return state
}

function serialize (descriptor) {
  const usedPlugins = new Map()
  const resolvePluginRef = tag => {
    const ref = pluginRegistry.resolveDescriptorRef(tag)
    if (!ref) return null

    if (!usedPlugins.has(ref.name)) {
      // Start at 1, since 0 is reserved for Concordance's descriptors.
      const index = usedPlugins.size + 1
      usedPlugins.set(ref.name, Object.assign({ index }, ref.serialization))
    }

    return {
      id: ref.id,
      pluginIndex: usedPlugins.get(ref.name).index,
    }
  }

  const seen = new Set()

  const stack = []
  let topIndex = -1

  let rootRecord
  do {
    if (descriptor.isComplex === true) {
      if (seen.has(descriptor.pointer)) {
        descriptor = pointerDescriptor.describe(descriptor.pointer)
      } else {
        seen.add(descriptor.pointer)
      }
    }

    let id
    let pluginIndex = 0
    if (tag2id.has(descriptor.tag)) {
      id = tag2id.get(descriptor.tag)
    } else {
      const ref = resolvePluginRef(descriptor.tag)
      if (!ref) throw new DescriptorSerializationError(descriptor)

      id = ref.id
      pluginIndex = ref.pluginIndex
    }

    const record = {
      id,
      pluginIndex,
      children: [],
      state: shallowSerializeDescriptor(descriptor, resolvePluginRef),
    }
    if (!rootRecord) {
      rootRecord = record
    } else {
      stack[topIndex].children.push(record)
    }

    if (descriptor.createRecursor) {
      stack.push({ recursor: descriptor.createRecursor(), children: record.children })
      topIndex++
    }

    while (topIndex >= 0) {
      descriptor = stack[topIndex].recursor()
      if (descriptor === null) {
        stack.pop()
        topIndex--
      } else {
        break
      }
    }
  } while (topIndex >= 0)

  return encoder.encode(VERSION, rootRecord, usedPlugins)
}
exports.serialize = serialize

function deserializeState (state, getDescriptorDeserializer) {
  if (state && state[encoder.descriptorSymbol] === true) {
    return shallowDeserializeDescriptor(state, getDescriptorDeserializer)
  }

  return Array.isArray(state)
    ? state.map(item => deserializeState(item, getDescriptorDeserializer))
    : state
}

function shallowDeserializeDescriptor (entry, getDescriptorDeserializer) {
  const deserializeDescriptor = getDescriptorDeserializer(entry[0], entry[1])
  return deserializeDescriptor(entry[2])
}

function deserializeRecord (record, getDescriptorDeserializer, buffer) {
  const deserializeDescriptor = getDescriptorDeserializer(record.pluginIndex, record.id)
  const state = deserializeState(record.state, getDescriptorDeserializer)

  if (record.pointerAddresses.length === 0) {
    return deserializeDescriptor(state)
  }

  const endIndex = record.pointerAddresses.length
  let index = 0
  const recursor = () => {
    if (index === endIndex) return null

    const recursorRecord = encoder.decodeRecord(buffer, record.pointerAddresses[index++])
    return deserializeRecord(recursorRecord, getDescriptorDeserializer, buffer)
  }

  return deserializeDescriptor(state, recursor)
}

function buildPluginMap (buffer, options) {
  const cache = options && options.deserializedPluginsCache
  const cacheKey = md5hex(buffer)
  if (cache && cache.has(cacheKey)) return cache.get(cacheKey)

  const decodedPlugins = encoder.decodePlugins(buffer)
  if (decodedPlugins.size === 0) {
    const pluginMap = new Map()
    if (cache) cache.set(cacheKey, pluginMap)
    return pluginMap
  }

  const deserializerLookup = new Map()
  if (Array.isArray(options && options.plugins)) {
    for (const deserializer of pluginRegistry.getDeserializers(options.plugins)) {
      deserializerLookup.set(deserializer.name, deserializer)
    }
  }

  const pluginMap = new Map()
  for (const index of decodedPlugins.keys()) {
    const used = decodedPlugins.get(index)
    const pluginName = used.name
    const serializerVersion = used.serializerVersion

    // TODO: Allow plugin author to encode a helpful message in its serialization
    if (!deserializerLookup.has(pluginName)) {
      throw new MissingPluginError(pluginName)
    }
    if (serializerVersion !== deserializerLookup.get(pluginName).serializerVersion) {
      throw new UnsupportedPluginError(pluginName, serializerVersion)
    }

    pluginMap.set(index, deserializerLookup.get(pluginName).id2deserialize)
  }

  if (cache) cache.set(cacheKey, pluginMap)
  return pluginMap
}

function deserialize (buffer, options) {
  const version = encoder.extractVersion(buffer)
  if (version !== VERSION) throw new UnsupportedVersion(version)

  const decoded = encoder.decode(buffer)
  const pluginMap = buildPluginMap(decoded.pluginBuffer, options)

  const descriptorsByPointerIndex = new Map()
  const mapPointerDescriptor = descriptor => {
    if (descriptor.isPointer === true) {
      if (descriptorsByPointerIndex.has(descriptor.index)) {
        return descriptorsByPointerIndex.get(descriptor.index)
      }

      if (typeof rootDescriptor.createRecursor === 'function') {
        // The descriptor we're pointing to may be elsewhere in the serialized
        // structure. Consume the entire structure and check again.
        recursorUtils.consumeDeep(rootDescriptor.createRecursor())

        if (descriptorsByPointerIndex.has(descriptor.index)) {
          return descriptorsByPointerIndex.get(descriptor.index)
        }
      }

      throw new PointerLookupError(descriptor.index)
    }

    if (descriptor.isComplex === true) {
      descriptorsByPointerIndex.set(descriptor.pointer, descriptor)
    }

    return descriptor
  }

  const getDescriptorDeserializer = (pluginIndex, id) => {
    return (state, recursor) => {
      const deserializeDescriptor = pluginIndex === 0
        ? id2deserialize.get(id)
        : pluginMap.get(pluginIndex).get(id)

      return mapPointerDescriptor(deserializeDescriptor(state, recursor))
    }
  }

  const rootDescriptor = deserializeRecord(decoded.rootRecord, getDescriptorDeserializer, buffer)
  return rootDescriptor
}
exports.deserialize = deserialize
