local type = type
local fmt = string.format
local find = string.find
local pcall = pcall
local remove = table.remove
local concat = table.concat
local assert = assert
local tonumber = tonumber
local encode_base64 = ngx.encode_base64
local decode_base64 = ngx.decode_base64
local strip = require("kong.tools.string").strip
local splitn = require("kong.tools.string").splitn
local get_rand_bytes = require("kong.tools.rand").get_rand_bytes


local ENABLED_ALGORITHMS = {
  ARGON2 = false,
  BCRYPT = false,
  PBKDF2 = true,
  SCRYPT = false,
}

--local CORES
--do
--  local infos = utils.get_system_infos()
--  if type(infos) == "table" then
--    CORES = infos.cores
--  end
--  if not CORES then
--    CORES = ngx.worker.count() or 1
--  end
--end


local function infer(value)
  value = strip(value)
  return tonumber(value, 10) or value
end


local function parse_phc(phc)
  local parts, count = splitn(phc, "$", 6)
  if count < 2 or count > 5 then
    return nil, "invalid phc string format"
  end

  local id = parts[2]
  local id_parts, id_count = splitn(id, "-")

  local prefix
  local digest
  if id_count == 1 then
    prefix = id_parts[1]
  else
    prefix = id_parts[1]
    remove(id_parts, 1)
    digest = concat(id_parts, "-")
  end

  local params = {}
  local prms = parts[3]
  if prms then
    local prm_parts, prm_parts_count = splitn(prms, ",")
    for i = 1, prm_parts_count do
      local param = prm_parts[i]
      local kv, kv_count = splitn(param, "=", 3)
      if kv_count == 1 then
        params[#params + 1] = infer(kv[1])
      elseif kv_count == 2 then
        local k = strip(kv[1])
        params[k] = infer(kv[2])
      else
        return nil, "invalid phc string format for parameter"
      end
    end
  end

  local salt = parts[4]
  if salt then
    local decoded_salt = decode_base64(salt)
    if decoded_salt then
      salt = decoded_salt
    end
  end

  local hash = parts[5]
  if hash then
    local decoded_hash = decode_base64(hash)
    if decoded_hash then
      hash = decoded_hash
    end
  end

  return {
    id     = strip(id),
    prefix = strip(prefix),
    digest = strip(digest),
    params = params,
    salt   = salt,
    hash   = hash,
  }
end


local PREFIX = nil -- currently chosen algorithm (nil means that we try to find one)


local ARGON2
local ARGON2_ID = "$argon2"
if ENABLED_ALGORITHMS.ARGON2 then
  local ARGON2_PREFIX
  local ok, crypt = pcall(function()
    local argon2 = require "argon2"

    -- argon2 settings
    local ARGON2_VARIANT     = argon2.variants.argon2_id
    local ARGON2_PARALLELISM = 1 --CORES
    local ARGON2_T_COST      = 1
    local ARGON2_M_COST      = 4096
    local ARGON2_HASH_LEN    = 32
    local ARGON2_SALT_LEN    = 16

    local ARGON2_OPTIONS = {
      variant     = ARGON2_VARIANT,
      parallelism = ARGON2_PARALLELISM,
      hash_len    = ARGON2_HASH_LEN,
      t_cost      = ARGON2_T_COST,
      m_cost      = ARGON2_M_COST,
    }
    do
      local hash = argon2.hash_encoded("", get_rand_bytes(ARGON2_SALT_LEN), ARGON2_OPTIONS)
      local parts = splitn(hash, "$")
      remove(parts)
      remove(parts)
      ARGON2_PREFIX = concat(parts, "$")
    end

    local crypt = {}

    function crypt.hash(secret)
      return argon2.hash_encoded(secret, get_rand_bytes(ARGON2_SALT_LEN), ARGON2_OPTIONS)
    end

    function crypt.verify(secret, hash)
      return argon2.verify(hash, secret)
    end

    return crypt
  end)

  if ok then
    ARGON2 = crypt
    PREFIX = PREFIX or ARGON2_PREFIX
  end
end


local BCRYPT
local BCRYPT_ID = "$2"
if ENABLED_ALGORITHMS.BCRYPT then
  local BCRYPT_PREFIX
  local ok, crypt = pcall(function()
    local bcrypt = require "bcrypt"

    -- bcrypt settings
    local BCRYPT_ROUNDS = 12

    do
      local hash = bcrypt.digest("", BCRYPT_ROUNDS)
      local parts = splitn(hash, "$")
      remove(parts)
      BCRYPT_PREFIX = concat(parts, "$")
    end

    local crypt = {}

    function crypt.hash(secret)
      return bcrypt.digest(secret, BCRYPT_ROUNDS)
    end

    function crypt.verify(secret, hash)
      return bcrypt.verify(secret, hash)
    end

    return crypt
  end)

  if ok then
    BCRYPT = crypt
    PREFIX = PREFIX or BCRYPT_PREFIX
  end
end


local PBKDF2
local PBKDF2_ID = "$pbkdf2"
if ENABLED_ALGORITHMS.PBKDF2 then
  local PBKDF2_PREFIX

  local ok, crypt = pcall(function()
    local openssl_kdf = require "resty.openssl.kdf"

    -- pbkdf2 default settings
    local PBKDF2_DIGEST     = "sha512"
    local PBKDF2_ITERATIONS = 10000
    local PBKDF2_HASH_LEN   = 32
    local PBKDF2_SALT_LEN   = 16

    local EMPTY  = {}

    local kdf

    local function derive(secret, opts)
      opts = opts or EMPTY
      local err
      if kdf then
        local _, err = kdf:reset()
        if err then
          kdf = nil
        end
      end

      if not kdf then
        kdf, err = openssl_kdf.new("PBKDF2")
        if err then
          return nil, err
        end
      end

      local salt = opts.salt or get_rand_bytes(PBKDF2_SALT_LEN)
      local hash, err = kdf:derive(opts.outlen or PBKDF2_HASH_LEN, {
        pass        = secret,
        salt        = salt,
        digest      = opts.digest or PBKDF2_DIGEST,
        iter        = opts.iter   or PBKDF2_ITERATIONS,
      }, 4)
      if not hash then
        return nil, err
      end

      local HASH = encode_base64(hash, true)
      local SALT = encode_base64(salt, true)

      return fmt("%s-%s$i=%u,l=%u$%s$%s",
                 PBKDF2_ID, PBKDF2_DIGEST,
                 PBKDF2_ITERATIONS, PBKDF2_HASH_LEN,
                 SALT, HASH)
    end

    do
      local hash = derive("")
      local parts = splitn(hash, "$")
      remove(parts)
      remove(parts)
      PBKDF2_PREFIX = concat(parts, "$")
    end

    local crypt = {}

    function crypt.hash(secret, options)
      return derive(secret, options)
    end

    function crypt.verify(secret, hash)
      local phc, err = parse_phc(hash)
      if not phc then
        return nil, err
      end

      local outlen = phc.params.l
      if not outlen and phc.hash then
        outlen = #phc.hash
      end

      local calculated_hash, err = derive(secret, {
        outlen      = outlen,
        salt        = phc.salt,
        digest      = phc.digest,
        iter        = phc.params.i
      })
      if not calculated_hash then
        return nil, err
      end

      return calculated_hash == hash
    end

    return crypt
  end)


  if ok then
    PBKDF2 = crypt
    PREFIX = PREFIX or PBKDF2_PREFIX
  end
end


local crypt = {}


function crypt.hash(secret, options)
  assert(type(secret) == "string", "secret needs to be a string")

  if ARGON2 then
    return ARGON2.hash(secret)
  end

  if BCRYPT then
    return BCRYPT.hash(secret)
  end

  if PBKDF2 then
    return PBKDF2.hash(secret, options)
  end

  return nil, "no suitable password hashing algorithm found"
end


function crypt.verify(secret, hash)
  if type(secret) ~= "string" then
    return false, "secret needs to be a string"
  end

  if type(hash) ~= "string" then
    return false, "hash needs to be a string"
  end

  if ARGON2 and find(hash, ARGON2_ID, 1, true) == 1 then
    return ARGON2.verify(secret, hash)
  end

  if BCRYPT and find(hash, BCRYPT_ID, 1, true) == 1 then
    return BCRYPT.verify(secret, hash)
  end

  if PBKDF2 and find(hash, PBKDF2_ID, 1, true) == 1 then
    return PBKDF2.verify(secret, hash)
  end

  return false, "no suitable password hashing algorithm found"
end


function crypt.needs_rehash(hash)
  if type(hash) ~= "string" then
    return true
  end

  if PREFIX then
    return find(hash, PREFIX, 1, true) ~= 1
  end

  return true
end


return crypt
