import { getCryptoLib } from './cryptoUtils'

export type Pbkdf2Digests = 'sha512' | 'sha256'

export interface Pbkdf2 {
  derive(
    password: string, 
    salt: Buffer,
    iterations: number, 
    keyLength: number, 
    digest: Pbkdf2Digests): Promise<Buffer>;
}

type NodePbkdf2Fn = typeof import('crypto').pbkdf2

export class NodeCryptoPbkdf2 implements Pbkdf2 {
  nodePbkdf2: NodePbkdf2Fn

  constructor(nodePbkdf2: NodePbkdf2Fn) {
    this.nodePbkdf2 = nodePbkdf2
  }

  async derive(
    password: string, 
    salt: Buffer,
    iterations: number, 
    keyLength: number, 
    digest: Pbkdf2Digests): 
    Promise<Buffer> {
    if (digest !== 'sha512' && digest !== 'sha256') {
      throw new Error(`Unsupported digest "${digest}" for Pbkdf2`)
    }
    return new Promise((resolve, reject) => {
      this.nodePbkdf2(password, salt, iterations, keyLength, digest, (error, result) => {
        if (error) {
          reject(error)
        }
        resolve(result)
      })
    })
  }
}

export class WebCryptoPbkdf2 implements Pbkdf2 {
  subtleCrypto: SubtleCrypto

  constructor(subtleCrypto: SubtleCrypto) {
    this.subtleCrypto = subtleCrypto
  }

  async derive(
    password: string, 
    salt: Buffer,
    iterations: number, 
    keyLength: number, 
    digest: Pbkdf2Digests): 
    Promise<Buffer> {
    let algo: string
    if (digest === 'sha256') {
      algo = 'SHA-256'
    } else if (digest === 'sha512') {
      algo = 'SHA-512'
    } else {
      throw new Error(`Unsupported Pbkdf2 digest algorithm "${digest}"`)
    }
    let result: ArrayBuffer
    const passwordBytes = Buffer.from(password, 'utf8')
    try {
      const key = await this.subtleCrypto.importKey(
        'raw', passwordBytes, 'PBKDF2', false, ['deriveBits']
      )
      result = await this.subtleCrypto.deriveBits({
        name: 'PBKDF2', salt, iterations, hash: { name: algo }
      }, key, keyLength * 8)
    } catch (error) {
      // Browser appears to support WebCrypto but missing pbkdf2 support.
      const partialWebCrypto = new WebCryptoPartialPbkdf2(this.subtleCrypto)
      return partialWebCrypto.derive(password, salt, iterations, keyLength, digest)
    }
    return Buffer.from(result)
  }
}

export class WebCryptoPartialPbkdf2 implements Pbkdf2 {
  // An async implementation for browsers that support WebCrypto hmac 
  // but not pbkdf2. Extracted from crypto-browserify/pbkdf2 and modified to 
  // use WebCrypto for hmac operations.
  // Original: https://github.com/crypto-browserify/pbkdf2/tree/v3.0.17/lib

  subtleCrypto: SubtleCrypto

  constructor(subtleCrypto: SubtleCrypto) {
    this.subtleCrypto = subtleCrypto
  }

  async derive(
    password: string, 
    salt: Buffer,
    iterations: number, 
    keyLength: number, 
    digest: Pbkdf2Digests): 
    Promise<Buffer> {
    if (digest !== 'sha512' && digest !== 'sha256') {
      throw new Error(`Unsupported digest "${digest}" for Pbkdf2`)
    }
    const key = Buffer.from(password, 'utf8')
    const algo = digest === 'sha512' ? 'SHA-512' : 'SHA-256'
    const algoOpts = { name: 'HMAC', hash: algo }
    const hmacDigest = (key: ArrayBuffer, data: ArrayBuffer) => this.subtleCrypto
      .importKey('raw', key, algoOpts, true, ['sign'])
      .then(cryptoKey => this.subtleCrypto.sign(algoOpts, cryptoKey, data))
      .then(result => new Uint8Array(result))
  
    const DK = new Uint8Array(keyLength)
    const saltLength = salt.length
    const block1 = new Uint8Array(saltLength + 4)
    block1.set(salt)
    let destPos = 0
    const hLen = digest === 'sha512' ? 64 : 32
    const l = Math.ceil(keyLength / hLen)
  
    function writeUInt32BE(data: Uint8Array, value: number, offset: number) {
      value = +value
      offset >>>= 0
      data[offset] = (value >>> 24)
      data[offset + 1] = (value >>> 16)
      data[offset + 2] = (value >>> 8)
      data[offset + 3] = (value & 0xff)
      return offset + 4
    }
  
    for (let i = 1; i <= l; i++) {
      writeUInt32BE(block1, i, saltLength)
      // eslint-disable-next-line no-await-in-loop
      const T = await hmacDigest(key, block1)
      let U = T
      for (let j = 1; j < iterations; j++) {
        // eslint-disable-next-line no-await-in-loop
        U = await hmacDigest(key, U)
        for (let k = 0; k < hLen; k++) {
          T[k] ^= U[k]
        }
      }
      DK.set(T.subarray(0, DK.byteLength - destPos), destPos)
      destPos += hLen
    }
    return Buffer.from(DK.buffer)
  }
}

export async function createPbkdf2(): Promise<Pbkdf2> {
  const cryptoLib = await getCryptoLib()
  if (cryptoLib.name === 'subtleCrypto') {
    return new WebCryptoPbkdf2(cryptoLib.lib)
  } else {
    return new NodeCryptoPbkdf2(cryptoLib.lib.pbkdf2)
  }
}

