---
-- Shared Memory (SHM) backend for session library
--
-- @module resty.session.shm


local utils  = require "resty.session.utils"


local meta_get_value = utils.meta_get_value
local meta_get_next = utils.meta_get_next
local table_new = utils.table_new
local get_name = utils.get_name
local errmsg = utils.errmsg


local setmetatable = setmetatable
local shared = ngx.shared
local random = math.random
local assert = assert
local error = error
local pairs = pairs
local max = math.max
local log = ngx.log


local WARN = ngx.WARN


local DEFAULT_ZONE = "sessions"
local CLEANUP_PROBABILITY = 0.1 -- 1 / 10


local function get_and_clean_metadata(dict, meta_key, current_time)
  local size = dict:llen(meta_key)
  if not size or size == 0 then
    return
  end

  local max_expiry = current_time
  local sessions = table_new(0, size)

  for _ = 1, size do
    local meta_value, err = dict:lpop(meta_key)
    if not meta_value then
      log(WARN, "[session] ", errmsg(err, "failed read meta value"))
      break
    end

    local key, err, exp = meta_get_next(meta_value, 1)
    if err then
      return nil, err
    end

    if exp and exp > current_time then
      sessions[key] = exp
      max_expiry = max(max_expiry, exp)

    else
      sessions[key] = nil
    end
  end

  for key, exp in pairs(sessions) do
    local meta_value = meta_get_value(key, exp)
    local ok, err = dict:rpush(meta_key, meta_value)
    if not ok then
      log(WARN, "[session] ", errmsg(err, "failed to update metadata"))
    end
  end

  local exp = max_expiry - current_time
  if exp > 0 then
    local ok, err = dict:expire(meta_key, max_expiry - current_time)
    if not ok and err ~= "not found" then
      log(WARN, "[session] ", errmsg(err, "failed to touch metadata"))
    end

  else
    dict:delete(meta_key)
  end

  return sessions
end


local function cleanup(dict, meta_key, current_time)
  get_and_clean_metadata(dict, meta_key, current_time)
end


local function read_metadata(self, meta_key, current_time)
  return get_and_clean_metadata(self.dict, meta_key, current_time)
end

---
-- Storage
-- @section instance


local metatable = {}


metatable.__index = metatable


function metatable.__newindex()
  error("attempt to update a read-only table", 2)
end


---
-- Store session data.
--
-- @function instance:set
-- @tparam string name cookie name
-- @tparam string key session key
-- @tparam string value session value
-- @tparam number ttl session ttl
-- @tparam number current_time current time
-- @tparam[opt] string old_key old session id
-- @tparam string stale_ttl stale ttl
-- @tparam[opt] table metadata table of metadata
-- @treturn true|nil ok
-- @treturn string error message
function metatable:set(name, key, value, ttl, current_time, old_key, stale_ttl, metadata)
  local dict = self.dict
  if not metadata and not old_key then
    local ok, err = dict:set(get_name(self, name, key), value, ttl)
    if not ok then
      return nil, err
    end

    return true
  end

  local old_name, old_ttl
  if old_key then
    old_name = get_name(self, name, old_key)
    old_ttl = dict:ttl(old_name)
  end

  local ok, err = dict:set(get_name(self, name, key), value, ttl)
  if not ok then
    return nil, err
  end

  if old_name and (not old_ttl or old_ttl > stale_ttl) then
    local ok, err = dict:expire(old_name, stale_ttl)
    if not ok then
      log(WARN, "[session] ", errmsg(err, "failed to touch old session"))
    end
  end

  if not metadata then
    return true
  end

  local audiences = metadata.audiences
  local subjects = metadata.subjects
  local count = #audiences
  for i = 1, count do
    local meta_key = get_name(self, name, audiences[i], subjects[i])
    local meta_value = meta_get_value(key, current_time + ttl)

    local ok, err = dict:rpush(meta_key, meta_value)
    if not ok then
      log(WARN, "[session] ", errmsg(err, "failed to update metadata"))
    end

    if old_key then
      meta_value = meta_get_value(old_key, 0)
      local ok, err = dict:rpush(meta_key, meta_value)
      if not ok then
        log(WARN, "[session] ", errmsg(err, "failed to update old metadata"))
      end
    end

    -- no need to clean up every time we write
    -- it is just beneficial when a key is used a lot
    if random() < CLEANUP_PROBABILITY then
      cleanup(dict, meta_key, current_time)
    end
  end

  return true
end


---
-- Retrieve session data.
--
-- @function instance:get
-- @tparam string name cookie name
-- @tparam string key session key
-- @treturn string|nil session data
-- @treturn string error message
function metatable:get(name, key)
  local value, err = self.dict:get(get_name(self, name, key))
  if not value then
    return nil, err
  end

  return value
end


---
-- Delete session data.
--
-- @function instance:delete
-- @tparam string name cookie name
-- @tparam string key session key
-- @tparam[opt] table metadata session meta data
-- @treturn boolean|nil session data
-- @treturn string error message
function metatable:delete(name, key, current_time, metadata)
  local dict = self.dict

  dict:delete(get_name(self, name, key))

  if not metadata then
    return true
  end

  local audiences = metadata.audiences
  local subjects = metadata.subjects
  local count = #audiences
  for i = 1, count do
    local meta_key = get_name(self, name, audiences[i], subjects[i])
    local meta_value = meta_get_value(key, 0)

    local ok, err = dict:rpush(meta_key, meta_value)
    if not ok then
      if not ok then
        log(WARN, "[session] ", errmsg(err, "failed to update metadata"))
      end
    end

    cleanup(dict, meta_key, current_time)
  end

  return true
end


---
-- Read session metadata.
--
-- @function instance:read_metadata
-- @tparam string name cookie name
-- @tparam string audience session key
-- @tparam string subject session key
-- @tparam number current_time current time
-- @treturn table|nil session metadata
-- @treturn string error message
function metatable:read_metadata(name, audience, subject, current_time)
  local meta_key = get_name(self, name, audience, subject)
  return read_metadata(self, meta_key, current_time)
end


local storage = {}


---
-- Configuration
-- @section configuration


---
-- Shared memory storage backend configuration
-- @field prefix Prefix for the keys stored in SHM.
-- @field suffix Suffix for the keys stored in SHM.
-- @field zone A name of shared memory zone (defaults to `sessions`).
-- @table configuration


---
-- Constructors
-- @section constructors


---
-- Create a SHM storage.
--
-- This creates a new shared memory storage instance.
--
-- @function module.new
-- @tparam[opt] table configuration shm storage @{configuration}
-- @treturn table shm storage instance
function storage.new(configuration)
  local prefix = configuration and configuration.prefix
  local suffix = configuration and configuration.suffix
  local zone   = configuration and configuration.zone or DEFAULT_ZONE

  local dict = assert(shared[zone], "lua_shared_dict " .. zone .. " is missing")

  return setmetatable({
    prefix = prefix,
    suffix = suffix,
    dict = dict,
  }, metatable)
end


return storage
