// Const
const TRANSFORMED_TYPE_KEY = '@t'
const CIRCULAR_REF_KEY = '@r'
const KEY_REQUIRE_ESCAPING_RE = /^#*@(t|r)$/

const GLOBAL = (function getGlobal() {
  // NOTE: see http://www.ecma-international.org/ecma-262/6.0/index.html#sec-performeval step 10
  const savedEval = eval

  return savedEval('this')
})()

const ARRAY_BUFFER_SUPPORTED = typeof ArrayBuffer === 'function'
const MAP_SUPPORTED = typeof Map === 'function'
const SET_SUPPORTED = typeof Set === 'function'

const TYPED_ARRAY_CTORS = [
  'Int8Array',
  'Uint8Array',
  'Uint8ClampedArray',
  'Int16Array',
  'Uint16Array',
  'Int32Array',
  'Uint32Array',
  'Float32Array',
  'Float64Array',
]

// Saved proto functions
const arrSlice = Array.prototype.slice

// Default serializer
const JSONSerializer = {
  serialize(val: any) {
    return JSON.stringify(val)
  },

  deserialize(val: any) {
    return JSON.parse(val)
  },
}

// EncodingTransformer
class EncodingTransformer {
  references: any
  transforms: any
  circularCandidates: any
  circularCandidatesDescrs: any
  circularRefCount: any

  constructor(val: any, transforms: any) {
    this.references = val
    this.transforms = transforms
    this.circularCandidates = []
    this.circularCandidatesDescrs = []
    this.circularRefCount = 0
  }

  static _createRefMark(idx: any) {
    const obj = Object.create(null)

    obj[CIRCULAR_REF_KEY] = idx

    return obj
  }

  _createCircularCandidate(val: any, parent: any, key: any) {
    this.circularCandidates.push(val)
    this.circularCandidatesDescrs.push({ parent, key, refIdx: -1 })
  }

  _applyTransform(val: any, parent: any, key: any, transform: any) {
    const result = Object.create(null)
    const serializableVal = transform.toSerializable(val)

    if (typeof serializableVal === 'object')
      this._createCircularCandidate(val, parent, key)

    result[TRANSFORMED_TYPE_KEY] = transform.type
    result.data = this._handleValue(() => serializableVal, parent, key)

    return result
  }

  _handleArray(arr: any): any {
    const result = [] as any

    for (let i = 0; i < arr.length; i++)
      result[i] = this._handleValue(() => arr[i], result, i)

    return result
  }

  _handlePlainObject(obj: any) {
    const result = Object.create(null)

    for (const key in obj) {
      if (Reflect.has(obj, key)) {
        const resultKey = KEY_REQUIRE_ESCAPING_RE.test(key) ? `#${key}` : key

        result[resultKey] = this._handleValue(() => obj[key], result, resultKey)
      }
    }

    const name = obj?.__proto__?.constructor?.name
    if (name && name !== 'Object') {
      result.constructor = { name }
    }

    return result
  }

  _handleObject(obj: any, parent: any, key: any) {
    this._createCircularCandidate(obj, parent, key)

    return Array.isArray(obj)
      ? this._handleArray(obj)
      : this._handlePlainObject(obj)
  }

  _ensureCircularReference(obj: any) {
    const circularCandidateIdx = this.circularCandidates.indexOf(obj)

    if (circularCandidateIdx > -1) {
      const descr = this.circularCandidatesDescrs[circularCandidateIdx]

      if (descr.refIdx === -1)
        descr.refIdx = descr.parent ? ++this.circularRefCount : 0

      return EncodingTransformer._createRefMark(descr.refIdx)
    }

    return null
  }

  _handleValue(getVal: () => any, parent: any, key: any) {
    try {
      const val = getVal()
      const type = typeof val
      const isObject = type === 'object' && val !== null

      if (isObject) {
        const refMark = this._ensureCircularReference(val)

        if (refMark) return refMark
      }

      for (const transform of this.transforms) {
        if (transform.shouldTransform(type, val))
          return this._applyTransform(val, parent, key, transform)
      }

      if (isObject) return this._handleObject(val, parent, key)

      return val
    } catch (e) {
      try {
        return this._handleValue(
          () => (e instanceof Error ? e : new Error(e)),
          parent,
          key
        )
      } catch {
        return null
      }
    }
  }

  transform() {
    const references = [this._handleValue(() => this.references, null, null)]

    for (const descr of this.circularCandidatesDescrs) {
      if (descr.refIdx > 0) {
        references[descr.refIdx] = descr.parent[descr.key]
        descr.parent[descr.key] = EncodingTransformer._createRefMark(
          descr.refIdx
        )
      }
    }

    return references
  }
}

// DecodingTransform
class DecodingTransformer {
  references: any
  transformMap: any
  activeTransformsStack = [] as any
  visitedRefs = Object.create(null)

  constructor(references: any, transformsMap: any) {
    this.references = references
    this.transformMap = transformsMap
  }

  _handlePlainObject(obj: any) {
    const unescaped = Object.create(null)

    if ('constructor' in obj) {
      if (!obj.constructor || typeof obj.constructor.name !== 'string') {
        obj.constructor = {
          name: 'Object',
        }
      }
    }

    for (const key in obj) {
      if (obj.hasOwnProperty(key)) {
        this._handleValue(obj[key], obj, key)

        if (KEY_REQUIRE_ESCAPING_RE.test(key)) {
          // NOTE: use intermediate object to avoid unescaped and escaped keys interference
          // E.g. unescaped "##@t" will be "#@t" which can overwrite escaped "#@t".
          unescaped[key.substring(1)] = obj[key]
          delete obj[key]
        }
      }
    }

    for (const unsecapedKey in unescaped)
      obj[unsecapedKey] = unescaped[unsecapedKey]
  }

  _handleTransformedObject(obj: any, parent: any, key: any) {
    const transformType = obj[TRANSFORMED_TYPE_KEY]
    const transform = this.transformMap[transformType]

    if (!transform)
      throw new Error(`Can't find transform for "${transformType}" type.`)

    this.activeTransformsStack.push(obj)
    this._handleValue(obj.data, obj, 'data')
    this.activeTransformsStack.pop()

    parent[key] = transform.fromSerializable(obj.data)
  }

  _handleCircularSelfRefDuringTransform(refIdx: any, parent: any, key: any) {
    // NOTE: we've hit a hard case: object reference itself during transformation.
    // We can't dereference it since we don't have resulting object yet. And we'll
    // not be able to restore reference lately because we will need to traverse
    // transformed object again and reference might be unreachable or new object contain
    // new circular references. As a workaround we create getter, so once transformation
    // complete, dereferenced property will point to correct transformed object.
    const references = this.references

    Object.defineProperty(parent, key, {
      // @ts-ignore
      val: void 0,
      configurable: true,
      enumerable: true,

      get() {
        if (this.val === void 0) this.val = references[refIdx]

        return (<any>this).val
      },

      set(value) {
        this.val = value
      },
    })
  }

  _handleCircularRef(refIdx: any, parent: any, key: any) {
    if (this.activeTransformsStack.includes(this.references[refIdx]))
      this._handleCircularSelfRefDuringTransform(refIdx, parent, key)
    else {
      if (!this.visitedRefs[refIdx]) {
        this.visitedRefs[refIdx] = true
        this._handleValue(this.references[refIdx], this.references, refIdx)
      }

      parent[key] = this.references[refIdx]
    }
  }

  _handleValue(val: any, parent: any, key: any) {
    if (typeof val !== 'object' || val === null) return

    const refIdx = val[CIRCULAR_REF_KEY]

    if (refIdx !== void 0) this._handleCircularRef(refIdx, parent, key)
    else if (val[TRANSFORMED_TYPE_KEY])
      this._handleTransformedObject(val, parent, key)
    else if (Array.isArray(val)) {
      for (let i = 0; i < val.length; i++) this._handleValue(val[i], val, i)
    } else this._handlePlainObject(val)
  }

  transform() {
    this.visitedRefs[0] = true
    this._handleValue(this.references[0], this.references, 0)

    return this.references[0]
  }
}

// Transforms
const builtInTransforms = [
  {
    type: '[[NaN]]',

    shouldTransform(type: any, val: any) {
      return type === 'number' && isNaN(val)
    },

    toSerializable() {
      return ''
    },

    fromSerializable() {
      return NaN
    },
  },

  {
    type: '[[undefined]]',

    shouldTransform(type: any) {
      return type === 'undefined'
    },

    toSerializable() {
      return ''
    },

    fromSerializable() {
      return void 0
    },
  },
  {
    type: '[[Date]]',

    shouldTransform(type: any, val: any) {
      return val instanceof Date
    },

    toSerializable(date: any) {
      return date.getTime()
    },

    fromSerializable(val: any) {
      const date = new Date()

      date.setTime(val)
      return date
    },
  },
  {
    type: '[[RegExp]]',

    shouldTransform(type: any, val: any) {
      return val instanceof RegExp
    },

    toSerializable(re: any) {
      const result = {
        src: re.source,
        flags: '',
      }

      if (re.global) result.flags += 'g'

      if (re.ignoreCase) result.flags += 'i'

      if (re.multiline) result.flags += 'm'

      return result
    },

    fromSerializable(val: any) {
      return new RegExp(val.src, val.flags)
    },
  },

  {
    type: '[[Error]]',

    shouldTransform(type: any, val: any) {
      return val instanceof Error
    },

    toSerializable(err: any) {
      if (!err.stack) {
        ;(Error as any).captureStackTrace?.(err)
      }

      return {
        name: err.name,
        message: err.message,
        stack: err.stack,
      }
    },

    fromSerializable(val: any) {
      const Ctor = GLOBAL[val.name] || Error
      const err = new Ctor(val.message)

      err.stack = val.stack
      return err
    },
  },

  {
    type: '[[ArrayBuffer]]',

    shouldTransform(type: any, val: any) {
      return ARRAY_BUFFER_SUPPORTED && val instanceof ArrayBuffer
    },

    toSerializable(buffer: any) {
      const view = new Int8Array(buffer)

      return arrSlice.call(view)
    },

    fromSerializable(val: any) {
      if (ARRAY_BUFFER_SUPPORTED) {
        const buffer = new ArrayBuffer(val.length)
        const view = new Int8Array(buffer)

        view.set(val)

        return buffer
      }

      return val
    },
  },

  {
    type: '[[TypedArray]]',

    shouldTransform(type: any, val: any) {
      for (const ctorName of TYPED_ARRAY_CTORS) {
        if (
          typeof GLOBAL[ctorName] === 'function' &&
          val instanceof GLOBAL[ctorName]
        )
          return true
      }

      return false
    },

    toSerializable(arr: any) {
      return {
        ctorName: arr.constructor.name,
        arr: arrSlice.call(arr),
      }
    },

    fromSerializable(val: any) {
      return typeof GLOBAL[val.ctorName] === 'function'
        ? new GLOBAL[val.ctorName](val.arr)
        : val.arr
    },
  },

  {
    type: '[[Map]]',

    shouldTransform(type: any, val: any) {
      return MAP_SUPPORTED && val instanceof Map
    },

    toSerializable(map: any) {
      const flattenedKVArr: any = []

      map.forEach((val: any, key: any) => {
        flattenedKVArr.push(key)
        flattenedKVArr.push(val)
      })

      return flattenedKVArr
    },

    fromSerializable(val: any) {
      if (MAP_SUPPORTED) {
        // NOTE: new Map(iterable) is not supported by all browsers
        const map = new Map()

        for (var i = 0; i < val.length; i += 2) map.set(val[i], val[i + 1])

        return map
      }

      const kvArr = []

      // @ts-ignore
      for (let j = 0; j < val.length; j += 2) kvArr.push([val[i], val[i + 1]])

      return kvArr
    },
  },

  {
    type: '[[Set]]',

    shouldTransform(type: any, val: any) {
      return SET_SUPPORTED && val instanceof Set
    },

    toSerializable(set: any) {
      const arr: any = []

      set.forEach((val: any) => {
        arr.push(val)
      })

      return arr
    },

    fromSerializable(val: any) {
      if (SET_SUPPORTED) {
        // NOTE: new Set(iterable) is not supported by all browsers
        const set = new Set()

        for (let i = 0; i < val.length; i++) set.add(val[i])

        return set
      }

      return val
    },
  },
]

// Replicator
class Replicator {
  transforms = [] as any
  transformsMap = Object.create(null)
  serializer: any

  constructor(serializer?: any) {
    this.serializer = serializer || JSONSerializer

    this.addTransforms(builtInTransforms)
  }

  addTransforms(transforms: any) {
    transforms = Array.isArray(transforms) ? transforms : [transforms]

    for (const transform of transforms) {
      if (this.transformsMap[transform.type])
        throw new Error(
          `Transform with type "${transform.type}" was already added.`
        )

      this.transforms.push(transform)
      this.transformsMap[transform.type] = transform
    }

    return this
  }

  removeTransforms(transforms: any) {
    transforms = Array.isArray(transforms) ? transforms : [transforms]

    for (const transform of transforms) {
      const idx = this.transforms.indexOf(transform)

      if (idx > -1) this.transforms.splice(idx, 1)

      delete this.transformsMap[transform.type]
    }

    return this
  }

  encode(val: any) {
    const transformer = new EncodingTransformer(val, this.transforms)
    const references = transformer.transform()

    return this.serializer.serialize(references)
  }

  decode(val: any) {
    const references = this.serializer.deserialize(val)
    const transformer = new DecodingTransformer(references, this.transformsMap)

    return transformer.transform()
  }
}

export default Replicator
