import { decode as _decode, decodeFirst as _decodeFirst } from '../decode.js'
import { Token, Type } from '../token.js'
import { decodeCodePointsArray } from '../byte-utils.js'
import { decodeErrPrefix } from '../common.js'

/**
 * @typedef {import('../../interface.js').DecodeOptions} DecodeOptions
 * @typedef {import('../../interface.js').DecodeTokenizer} DecodeTokenizer
 */

/**
 * @implements {DecodeTokenizer}
 */
class Tokenizer {
  /**
   * @param {Uint8Array} data
   * @param {DecodeOptions} options
   */
  constructor (data, options = {}) {
    this._pos = 0
    this.data = data
    this.options = options
    /** @type {string[]} */
    this.modeStack = ['value']
    this.lastToken = ''
  }

  pos () {
    return this._pos
  }

  /**
   * @returns {boolean}
   */
  done () {
    return this._pos >= this.data.length
  }

  /**
   * @returns {number}
   */
  ch () {
    return this.data[this._pos]
  }

  /**
   * @returns {string}
   */
  currentMode () {
    return this.modeStack[this.modeStack.length - 1]
  }

  skipWhitespace () {
    let c = this.ch()
    // @ts-ignore
    while (c === 32 /* ' ' */ || c === 9 /* '\t' */ || c === 13 /* '\r' */ || c === 10 /* '\n' */) {
      c = this.data[++this._pos]
    }
  }

  /**
   * @param {number[]} str
   */
  expect (str) {
    if (this.data.length - this._pos < str.length) {
      throw new Error(`${decodeErrPrefix} unexpected end of input at position ${this._pos}`)
    }
    for (let i = 0; i < str.length; i++) {
      if (this.data[this._pos++] !== str[i]) {
        throw new Error(`${decodeErrPrefix} unexpected token at position ${this._pos}, expected to find '${String.fromCharCode(...str)}'`)
      }
    }
  }

  parseNumber () {
    const startPos = this._pos
    let negative = false
    let float = false

    /**
     * @param {number[]} chars
     */
    const swallow = (chars) => {
      while (!this.done()) {
        const ch = this.ch()
        if (chars.includes(ch)) {
          this._pos++
        } else {
          break
        }
      }
    }

    // lead
    if (this.ch() === 45) { // '-'
      negative = true
      this._pos++
    }
    if (this.ch() === 48) { // '0'
      this._pos++
      if (this.ch() === 46) { // '.'
        this._pos++
        float = true
      } else {
        return new Token(Type.uint, 0, this._pos - startPos)
      }
    }
    swallow([48, 49, 50, 51, 52, 53, 54, 55, 56, 57]) // DIGIT
    if (negative && this._pos === startPos + 1) {
      throw new Error(`${decodeErrPrefix} unexpected token at position ${this._pos}`)
    }
    if (!this.done() && this.ch() === 46) { // '.'
      if (float) {
        throw new Error(`${decodeErrPrefix} unexpected token at position ${this._pos}`)
      }
      float = true
      this._pos++
      swallow([48, 49, 50, 51, 52, 53, 54, 55, 56, 57]) // DIGIT
    }
    if (!this.done() && (this.ch() === 101 || this.ch() === 69)) { // '[eE]'
      float = true
      this._pos++
      if (!this.done() && (this.ch() === 43 || this.ch() === 45)) { // '+', '-'
        this._pos++
      }
      swallow([48, 49, 50, 51, 52, 53, 54, 55, 56, 57]) // DIGIT
    }
    // @ts-ignore
    const numStr = String.fromCharCode.apply(null, this.data.subarray(startPos, this._pos))
    const num = parseFloat(numStr)
    if (float) {
      return new Token(Type.float, num, this._pos - startPos)
    }
    if (this.options.allowBigInt !== true || Number.isSafeInteger(num)) {
      return new Token(num >= 0 ? Type.uint : Type.negint, num, this._pos - startPos)
    }
    return new Token(num >= 0 ? Type.uint : Type.negint, BigInt(numStr), this._pos - startPos)
  }

  /**
   * @returns {Token}
   */
  parseString () {
    /* c8 ignore next 4 */
    if (this.ch() !== 34) { // '"'
      // this would be a programming error
      throw new Error(`${decodeErrPrefix} unexpected character at position ${this._pos}; this shouldn't happen`)
    }
    this._pos++

    // check for simple fast-path, all printable ascii, no escapes
    // >0x10000 elements may fail fn.apply() (http://stackoverflow.com/a/22747272/680742)
    for (let i = this._pos, l = 0; i < this.data.length && l < 0x10000; i++, l++) {
      const ch = this.data[i]
      if (ch === 92 || ch < 32 || ch >= 128) { // '\', ' ', control-chars or non-trivial
        break
      }
      if (ch === 34) { // '"'
        // @ts-ignore
        const str = String.fromCharCode.apply(null, this.data.subarray(this._pos, i))
        this._pos = i + 1
        return new Token(Type.string, str, l)
      }
    }

    const startPos = this._pos
    const chars = []

    const readu4 = () => {
      if (this._pos + 4 >= this.data.length) {
        throw new Error(`${decodeErrPrefix} unexpected end of unicode escape sequence at position ${this._pos}`)
      }
      let u4 = 0
      for (let i = 0; i < 4; i++) {
        let ch = this.ch()
        if (ch >= 48 && ch <= 57) { // '0' && '9'
          ch -= 48
        } else if (ch >= 97 && ch <= 102) { // 'a' && 'f'
          ch = ch - 97 + 10
        } else if (ch >= 65 && ch <= 70) { // 'A' && 'F'
          ch = ch - 65 + 10
        } else {
          throw new Error(`${decodeErrPrefix} unexpected unicode escape character at position ${this._pos}`)
        }
        u4 = u4 * 16 + ch
        this._pos++
      }
      return u4
    }

    // mostly taken from feross/buffer and adjusted to fit
    const readUtf8Char = () => {
      const firstByte = this.ch()
      let codePoint = null
      /* c8 ignore next 1 */
      let bytesPerSequence = (firstByte > 0xef) ? 4 : (firstByte > 0xdf) ? 3 : (firstByte > 0xbf) ? 2 : 1

      if (this._pos + bytesPerSequence > this.data.length) {
        throw new Error(`${decodeErrPrefix} unexpected unicode sequence at position ${this._pos}`)
      }

      let secondByte, thirdByte, fourthByte, tempCodePoint

      switch (bytesPerSequence) {
        /* c8 ignore next 6 */
        // this case is dealt with by the caller function
        case 1:
          if (firstByte < 0x80) {
            codePoint = firstByte
          }
          break
        case 2:
          secondByte = this.data[this._pos + 1]
          if ((secondByte & 0xc0) === 0x80) {
            tempCodePoint = (firstByte & 0x1f) << 0x6 | (secondByte & 0x3f)
            if (tempCodePoint > 0x7f) {
              codePoint = tempCodePoint
            }
          }
          break
        case 3:
          secondByte = this.data[this._pos + 1]
          thirdByte = this.data[this._pos + 2]
          if ((secondByte & 0xc0) === 0x80 && (thirdByte & 0xc0) === 0x80) {
            tempCodePoint = (firstByte & 0xf) << 0xc | (secondByte & 0x3f) << 0x6 | (thirdByte & 0x3f)
            /* c8 ignore next 3 */
            if (tempCodePoint > 0x7ff && (tempCodePoint < 0xd800 || tempCodePoint > 0xdfff)) {
              codePoint = tempCodePoint
            }
          }
          break
        case 4:
          secondByte = this.data[this._pos + 1]
          thirdByte = this.data[this._pos + 2]
          fourthByte = this.data[this._pos + 3]
          if ((secondByte & 0xc0) === 0x80 && (thirdByte & 0xc0) === 0x80 && (fourthByte & 0xc0) === 0x80) {
            tempCodePoint = (firstByte & 0xf) << 0x12 | (secondByte & 0x3f) << 0xc | (thirdByte & 0x3f) << 0x6 | (fourthByte & 0x3f)
            if (tempCodePoint > 0xffff && tempCodePoint < 0x110000) {
              codePoint = tempCodePoint
            }
          }
      }

      /* c8 ignore next 5 */
      if (codePoint === null) {
        // we did not generate a valid codePoint so insert a
        // replacement char (U+FFFD) and advance only 1 byte
        codePoint = 0xfffd
        bytesPerSequence = 1
      } else if (codePoint > 0xffff) {
        // encode to utf16 (surrogate pair dance)
        codePoint -= 0x10000
        chars.push(codePoint >>> 10 & 0x3ff | 0xd800)
        codePoint = 0xdc00 | codePoint & 0x3ff
      }

      chars.push(codePoint)
      this._pos += bytesPerSequence
    }

    // TODO: could take the approach of a quick first scan for special chars like encoding/json/decode.go#unquoteBytes
    // and converting all of the ascii chars from the base array in bulk
    while (!this.done()) {
      const ch = this.ch()
      let ch1
      switch (ch) {
        case 92: // '\'
          this._pos++
          if (this.done()) {
            throw new Error(`${decodeErrPrefix} unexpected string termination at position ${this._pos}`)
          }
          ch1 = this.ch()
          this._pos++
          switch (ch1) {
            case 34: // '"'
            case 39: // '\''
            case 92: // '\'
            case 47: // '/'
              chars.push(ch1)
              break
            case 98: // 'b'
              chars.push(8)
              break
            case 116: // 't'
              chars.push(9)
              break
            case 110: // 'n'
              chars.push(10)
              break
            case 102: // 'f'
              chars.push(12)
              break
            case 114: // 'r'
              chars.push(13)
              break
            case 117: // 'u'
              chars.push(readu4())
              break
            default:
              throw new Error(`${decodeErrPrefix} unexpected string escape character at position ${this._pos}`)
          }
          break
        case 34: // '"'
          this._pos++
          return new Token(Type.string, decodeCodePointsArray(chars), this._pos - startPos)
        default:
          if (ch < 32) { // ' '
            throw new Error(`${decodeErrPrefix} invalid control character at position ${this._pos}`)
          } else if (ch < 0x80) {
            chars.push(ch)
            this._pos++
          } else {
            readUtf8Char()
          }
      }
    }

    throw new Error(`${decodeErrPrefix} unexpected end of string at position ${this._pos}`)
  }

  /**
   * @returns {Token}
   */
  parseValue () {
    switch (this.ch()) {
      case 123: // '{'
        this.modeStack.push('obj-start')
        this._pos++
        return new Token(Type.map, Infinity, 1)
      case 91: // '['
        this.modeStack.push('array-start')
        this._pos++
        return new Token(Type.array, Infinity, 1)
      case 34: { // '"'
        return this.parseString()
      }
      case 110: // 'n' / null
        this.expect([110, 117, 108, 108]) // 'null'
        return new Token(Type.null, null, 4)
      case 102: // 'f' / // false
        this.expect([102, 97, 108, 115, 101]) // 'false'
        return new Token(Type.false, false, 5)
      case 116: // 't' / // true
        this.expect([116, 114, 117, 101]) // 'true'
        return new Token(Type.true, true, 4)
      case 45: // '-'
      case 48: // '0'
      case 49: // '1'
      case 50: // '2'
      case 51: // '3'
      case 52: // '4'
      case 53: // '5'
      case 54: // '6'
      case 55: // '7'
      case 56: // '8'
      case 57: // '9'
        return this.parseNumber()
      default:
        throw new Error(`${decodeErrPrefix} unexpected character at position ${this._pos}`)
    }
  }

  /**
   * @returns {Token}
   */
  next () {
    this.skipWhitespace()
    switch (this.currentMode()) {
      case 'value':
        this.modeStack.pop()
        return this.parseValue()
      case 'array-value': {
        this.modeStack.pop()
        if (this.ch() === 93) { // ']'
          this._pos++
          this.skipWhitespace()
          return new Token(Type.break, undefined, 1)
        }
        if (this.ch() !== 44) { // ','
          throw new Error(`${decodeErrPrefix} unexpected character at position ${this._pos}, was expecting array delimiter but found '${String.fromCharCode(this.ch())}'`)
        }
        this._pos++
        this.modeStack.push('array-value')
        this.skipWhitespace()
        return this.parseValue()
      }
      case 'array-start': {
        this.modeStack.pop()
        if (this.ch() === 93) { // ']'
          this._pos++
          this.skipWhitespace()
          return new Token(Type.break, undefined, 1)
        }
        this.modeStack.push('array-value')
        this.skipWhitespace()
        return this.parseValue()
      }
      // @ts-ignore
      case 'obj-key':
        if (this.ch() === 125) { // '}'
          this.modeStack.pop()
          this._pos++
          this.skipWhitespace()
          return new Token(Type.break, undefined, 1)
        }
        if (this.ch() !== 44) { // ','
          throw new Error(`${decodeErrPrefix} unexpected character at position ${this._pos}, was expecting object delimiter but found '${String.fromCharCode(this.ch())}'`)
        }
        this._pos++
        this.skipWhitespace()
      case 'obj-start': { // eslint-disable-line no-fallthrough
        this.modeStack.pop()
        if (this.ch() === 125) { // '}'
          this._pos++
          this.skipWhitespace()
          return new Token(Type.break, undefined, 1)
        }
        const token = this.parseString()
        this.skipWhitespace()
        if (this.ch() !== 58) { // ':'
          throw new Error(`${decodeErrPrefix} unexpected character at position ${this._pos}, was expecting key/value delimiter ':' but found '${String.fromCharCode(this.ch())}'`)
        }
        this._pos++
        this.modeStack.push('obj-value')
        return token
      }
      case 'obj-value': {
        this.modeStack.pop()
        this.modeStack.push('obj-key')
        this.skipWhitespace()
        return this.parseValue()
      }
      /* c8 ignore next 2 */
      default:
        throw new Error(`${decodeErrPrefix} unexpected parse state at position ${this._pos}; this shouldn't happen`)
    }
  }
}

/**
 * @param {Uint8Array} data
 * @param {DecodeOptions} [options]
 * @returns {any}
 */
function decode (data, options) {
  options = Object.assign({ tokenizer: new Tokenizer(data, options) }, options)
  return _decode(data, options)
}

/**
 * @param {Uint8Array} data
 * @param {DecodeOptions} [options]
 * @returns {[any, Uint8Array]}
 */
function decodeFirst (data, options) {
  options = Object.assign({ tokenizer: new Tokenizer(data, options) }, options)
  return _decodeFirst(data, options)
}

export { decode, decodeFirst, Tokenizer }
