lib/redis_sessions/client.ex

defmodule RedisSessions.Client do
  @moduledoc ~S"""
  The Client module contains functions to interact with the sessions.
  """
  @type app :: String.t()
  @type id :: String.t()
  @type token :: String.t()
  @type session :: %{
          id: String.t(),
          r: integer,
          w: integer,
          idle: integer,
          ttl: integer,
          d: Map.t()
        }
  @type ip :: {integer, integer, integer, integer}

  @tokenchars "ABCDEFGHIJKLMNOPQRSTUVWabcdefghijklmnopqrstuvw0123456789"
              |> String.split("", trim: true)

  use GenServer
  import Logger

  alias RedisSessions.RedixPool, as: Redis

  @doc """
  Create a session

  ## Parameters

  * `app` (Binary) The app id (namespace) for this session. Must be [a-zA-Z0-9_-] and 3-20 chars long.
  * `id` (Binary) The user id of this user. Note: There can be multiple sessions for the same user id. If the user uses multiple client devices.
  * `ip` (Binary) IP address of the user. This is used to show all ips from which the user is logged in.
  * `ttl` (Integer) *optional* The "Time-To-Live" for the session in seconds. Default: 7200.
  * `d` (Map) *optional* Additional data to set for this sessions. (see the "set" method)

  ## Examples
  	{:ok, %{token: token}} = RedisSessions.Client.create( "exrs-test", "foo", "127.0.0.1", 3600, %{ "foo" => "bar", "ping" => "pong"} )
  """
  @spec create(app, id, ip, integer, Map.t()) :: boolean
  def create(app, id, ip, ttl \\ 3600, data \\ nil) do
    GenServer.call(__MODULE__, {:create, {app, id, ip, ttl, data}})
  end

  @doc """
  Set/Update/Delete custom data for a single session.
  All custom data is stored in the `d` object which is a simple hash object structure.

  `d` might contain a map with **one or more** keys with the following types: `binary`, `number`, `boolean`, `nil`.
  Keys with all values except `nil` will be stored. If a key containts `nil` the key will be removed.

  Note: If `d` already contains keys that are not supplied in the set request then these keys will be untouched.

  ## Parameters

  * `app` (Binary) The app id (namespace) for this session. Must be [a-zA-Z0-9_-] and 3-20 chars long.
  * `token` (Binary) The generated session token. Must be [a-zA-Z0-9] and 64 chars long
  * `d` (Map) *optional* Data to set. Must be a map with keys whose values only consist of binaries, numbers, boolean and nil.

  ## Returns

  `{:ok, session }` the session data after change

  ## Examples

  	{:ok, token} = RedisSessions.Client.create( "exrs-test", "foo", "127.0.0.1", 3600, %{ "foo" => "bar"} )
  	RedisSessions.Client.set( "exrs-test", token, %{ "foo" => "buzz"} )
  	# {:ok, %{ id: "foo", r:1, w:2, idle: 3, ttl: 3600, d: %{"foo" => "buzz"} }}

  """
  @spec set(app, token, Map.t()) :: {:ok, session} | {:error, String.t()}
  def set(app, token, data \\ nil) do
    GenServer.call(__MODULE__, {:set, {app, token, data}})
  end

  @doc """

  Get a session for an app and token

  ## Parameters

  * `app` (Binary) The app id (namespace) for this session. Must be [a-zA-Z0-9_-] and 3-20 chars long.
  * `token` (Binary) The generated session token. Must be [a-zA-Z0-9] and 64 chars long

  ## Examples

  	{:ok, %{ token: token }} = RedisSessions.Client.create( "exrs-test", "foo", "127.0.0.1", 3600, %{ "foo" => "bar"} )
  	RedisSessions.Client.get( "exrs-test", token )
  	# {:ok, %{ id: "foo", r: 2, w: 1, idle: 1, ttl: 3600, ip: "127.0.0.1", d: %{"foo" => "bar"} }}
  """
  @spec get(app, token) :: {:ok, session} | {:error, String.t()}
  def get(app, token) do
    GenServer.call(__MODULE__, {:get, {app, token, false}})
  end

  @doc """
  Kill a session for an app and token.

  ## Parameters

  * `app` (Binary) The app id (namespace) for this session. Must be [a-zA-Z0-9_-] and 3-20 chars long.
  * `token` (Binary) The generated session token. Must be [a-zA-Z0-9] and 64 chars long

  ## Examples

  	iex>{:ok, %{token: token}} = RedisSessions.Client.create( "exrs-test", "foo", "127.0.0.1", 3600, %{ "foo" => "bar"} )
  	...>RedisSessions.Client.kill( "exrs-test", token )
  	{:ok, %{kill: 1} }
  """
  @spec kill(app, token) :: {:kill, integer} | {:error, String.t()}
  def kill(app, token) do
    GenServer.call(__MODULE__, {:kill, {app, token}})
  end

  @doc """
  Query the amount of active session within the last 10 minutes (600 seconds). Note: Multiple sessions from the same user id will be counted as one.

  ## Parameters

  * `app` (Binary) The app id (namespace) for this session. Must be [a-zA-Z0-9_-] and 3-20 chars long.
  * `dt` (Integer) Delta time. Amount of seconds to check (e.g. 600 for the last 10 min.)

  ## Examples

  	iex>{:ok, _} = RedisSessions.Client.create( "exrs-test", "foo", "127.0.0.1", 3600, %{ "foo" => "bar"} )
  	...>RedisSessions.Client.activity( "exrs-test" )
  	{:ok, %{activity: 1}}
  """
  @spec activity(app, integer) :: {:ok, integer} | {:error, String.t()}
  def activity(app, dt \\ 600) do
    GenServer.call(__MODULE__, {:activity, {app, dt}})
  end

  @doc """
  Get all sessions of an app there were active within the last 10 minutes (600 seconds).

  ## Parameters

  * `app` (Binary) The app id (namespace) for this session. Must be [a-zA-Z0-9_-] and 3-20 chars long.
  * `dt` (Integer) Delta time. Amount of seconds to check (e.g. 600 for the last 10 min.)

  ## Examples

  	iex>{:ok, tokenA} = RedisSessions.Client.create( "exrs-test", "foo", "127.0.0.1", 3600 )
  	...>{:ok, tokenB} = RedisSessions.Client.create( "exrs-test", "bar", "127.0.0.1", 3600 )
  	...>RedisSessions.Client.soapp( "exrs-test" )
  	{:ok, %{ sessions: [ %{ id: "foo", r: 1, w: 1, idle: 1, ttl: 3600, d: nil }, %{ id: "bar", r: 1, w: 1, idle: 1, ttl: 3600, d: nil } ] } }
  """
  @spec soapp(app, integer) :: {:ok, [session]} | {:error, String.t()}
  def soapp(app, dt \\ 600) do
    GenServer.call(__MODULE__, {:soapp, {app, dt}})
  end

  @doc """
  Get all sessions within an app that belong to a single id. This would be all sessions of a single user in case he is logged in on different browsers / devices.

  ## Parameters

  * `app` (Binary) The app id (namespace) for this session. Must be [a-zA-Z0-9_-] and 3-20 chars long.
  * `id` (Binary) The user id of this user.

  ## Examples

  	iex>{:ok, tokenA1} = RedisSessions.Client.create( "exrs-test", "foo", "127.0.0.1", 3600 )
  	...>{:ok, tokenB1} = RedisSessions.Client.create( "exrs-test", "bar", "127.0.0.1", 3600 )
  	...>{:ok, tokenA2} = RedisSessions.Client.create( "exrs-test", "foo", "192.168.0.42", 3600 )
  	...>RedisSessions.Client.soid( "exrs-test", "foo" )
  	{:ok, [ %{ id: "foo", r:1, w:1, idle: 1, ttl: 3600 }, %{ id: "bar", r:1, w:1, idle: 1, ttl: 3600 } ] }
  """
  @spec soid(app, id) :: {:ok, [session]} | {:error, String.t()}
  def soid(app, id) do
    GenServer.call(__MODULE__, {:soid, {app, id}})
  end

  @doc """
  Kill all sessions of an id within an app

  ## Parameters

  * `app` (Binary) The app id (namespace) for this session. Must be [a-zA-Z0-9_-] and 3-20 chars long.
  * `id` (Binary) The user id of this user.

  ## Examples

  	iex>{:ok, tokenA1} = RedisSessions.Client.create( "exrs-test", "foo", "127.0.0.1", 3600 )
  	...>{:ok, tokenB1} = RedisSessions.Client.create( "exrs-test", "bar", "127.0.0.1", 3600 )
  	...>{:ok, tokenA2} = RedisSessions.Client.create( "exrs-test", "foo", "192.168.0.42", 3600 )
  	...>{:ok, tokenA2} = RedisSessions.Client.create( "exrs-test2", "foo", "192.168.0.42", 3600 )
  	...>RedisSessions.Client.killsoid( "exrs-test", "foo" )
  	{:ok, %{ kill: 2 }}

  """
  @spec killsoid(app, id) :: {:kill, integer} | {:error, String.t()}
  def killsoid(app, id) do
    GenServer.call(__MODULE__, {:killsoid, {app, id}})
  end

  @doc """
  Kill all sessions of an app

  ## Parameters

  * `app` (Binary) The app id (namespace) for this session. Must be [a-zA-Z0-9_-] and 3-20 chars long.

  ## Examples

  	iex>{:ok, tokenA1} = RedisSessions.Client.create( "exrs-test", "foo", "127.0.0.1", 3600 )
  	...>{:ok, tokenB1} = RedisSessions.Client.create( "exrs-test", "bar", "127.0.0.1", 3600 )
  	...>{:ok, tokenA2} = RedisSessions.Client.create( "exrs-test", "foo", "192.168.0.42", 3600 )
  	...>{:ok, tokenA2} = RedisSessions.Client.create( "exrs-test2", "foo", "192.168.0.42", 3600 )
  	...>RedisSessions.Client.killall( "exrs-test" )
  	{:ok, %{ kill: 3 }}
  """
  @spec killall(app) :: {:kill, integer} | {:error, String.t()}
  def killall(app) do
    GenServer.call(__MODULE__, {:killall, {app}})
  end

  @doc """
  Wipe all deprecated sessions

  ## Parameters
  """
  @spec wipe :: :ok
  def wipe() do
    GenServer.cast(__MODULE__, {:wipe})
    :ok
  end

  ####
  # GENSERVER API
  ####

  def init(init_arg) do
    {:ok, init_arg}
  end

  @doc """
  start genserver
  """
  @spec start_link(any) :: true
  def start_link(_args) do
    ret = GenServer.start_link(__MODULE__, [], name: __MODULE__)

    interval = get_interval() * 1000

    if interval >= 10 do
      :timer.apply_interval(interval, __MODULE__, :wipe, [])
    end

    ret
  end

  def handle_call({:create, {app, id, ip, ttl, data}}, _from, opts) do
    case Vex.errors([app: app, id: id, ip: ip, ttl: ttl, d: data],
           app: validate(:app),
           id: validate(:id),
           ip: validate(:ip),
           ttl: validate(:ttl),
           d: validate(:d)
         ) do
      [] ->
        debug("create session of app `#{app}` with id: `#{id}` from ip: `#{ip}`")
        now = DateTime.utc_now()

        token = create_token(now)

        thesession =
          case data do
            nil ->
              []

            data ->
              # filter nil values from data
              data = d_filter_nil(data)

              if data !== %{} do
                ["d", Poison.encode!(data)]
              else
                []
              end
          end

        thesession = [
          "HMSET",
          "#{get_ns()}:#{app}:#{token}",
          "id",
          id,
          "r",
          1,
          "w",
          1,
          "ip",
          ip,
          "la",
          ts(now, :second),
          "ttl",
          ttl | thesession
        ]

        mc =
          create_multi_statement(
            [["SADD", "#{get_ns()}:#{app}:us:#{id}", token], thesession],
            {app, token, id, ttl},
            now
          )

        case Redis.pipeline(mc) do
          {:ok, [_, _, _, _, "OK"]} ->
            {:reply, {:ok, %{token: token}}, opts}

          {:ok, [_, _, _, _, error]} ->
            {:reply, {:error, error}, opts}

          {:error, error} ->
            {:reply, {:error, error}, opts}
        end

      errors ->
        handle_validation_errors(errors, opts)
    end
  end

  def handle_call({:set, {app, token, data}}, _from, opts) do
    case Vex.errors([app: app, token: token, d: data],
           app: validate(:app),
           token: validate(:token),
           d: validate(:d)
         ) do
      [] ->
        debug("update session of app `#{app}` with token: `#{token}`")

        case session_get(app, token, true) do
          {:ok, %{} = session} ->
            now = DateTime.utc_now()
            thekey = "#{get_ns()}:#{app}:#{token}"

            {session, cmds} = update_d_key(thekey, session, data, [])

            cmds = update_la_key(thekey, session, data, now, cmds)

            cmds = [["HINCRBY", thekey, "w", 1] | cmds]
            session = Map.update!(session, :w, &(&1 + 1))

            mc = create_multi_statement(cmds, {app, token, session.id, session.ttl}, now)

            debug("update session of app `#{app}` write id `#{session.id}`")

            case Redis.pipeline(mc) do
              {:ok, _} ->
                {:reply, {:ok, session}, opts}

              {:error, error} ->
                {:reply, {:error, error}, opts}
            end

          reply ->
            {:reply, reply, opts}
        end

      errors ->
        handle_validation_errors(errors, opts)
    end
  end

  def handle_call({:get, {app, token, noupdate}}, _from, opts) do
    case Vex.errors([app: app, token: token],
           app: validate(:app),
           token: validate(:token)
         ) do
      [] ->
        debug("get session of app `#{app}` with token: `#{token}`")
        {:reply, session_get(app, token, noupdate), opts}

      errors ->
        handle_validation_errors(errors, opts)
    end
  end

  def handle_call({:kill, {app, token}}, _from, opts) do
    case Vex.errors([app: app, token: token],
           app: validate(:app),
           token: validate(:token)
         ) do
      [] ->
        debug("kill session of app `#{app}` with token: `#{token}`")

        case session_get(app, token, true) do
          {:ok, %{} = session} ->
            case kill_sessions(app, token, session.id) do
              {:ok, result} ->
                {:reply, {:ok, result}, opts}

              {:error, error} ->
                {:reply, {:error, error}, opts}
            end

          reply ->
            {:reply, reply, opts}
        end

      errors ->
        handle_validation_errors(errors, opts)
    end
  end

  def handle_call({:activity, {app, dt}}, _from, opts) do
    case Vex.errors([app: app, dt: dt],
           app: validate(:app),
           dt: validate(:dt)
         ) do
      [] ->
        debug("get app `#{app}` activity")

        case Redis.command([
               "ZCOUNT",
               "#{get_ns()}:#{app}:_users",
               (DateTime.utc_now() |> ts) - dt,
               "+inf"
             ]) do
          {:ok, result} ->
            {:reply, {:ok, %{activity: result}}, opts}

          {:error, error} ->
            {:reply, {:error, error}, opts}
        end

      errors ->
        handle_validation_errors(errors, opts)
    end
  end

  def handle_call({:soapp, {app, dt}}, _from, opts) do
    case Vex.errors([app: app, dt: dt],
           app: validate(:app),
           dt: validate(:dt)
         ) do
      [] ->
        debug("kill all sessions of app `#{app}`")

        case Redis.command([
               "ZREVRANGEBYSCORE",
               "#{get_ns()}:#{app}:_sessions",
               "+inf",
               (DateTime.utc_now() |> ts) - dt
             ]) do
          {:ok, result} ->
            sessions =
              result
              |> Enum.map(&Enum.at(String.split(&1, ":"), 0))

            {:reply, grep_sessions(app, sessions), opts}

          {:error, error} ->
            {:reply, {:error, error, opts}}
        end

      errors ->
        handle_validation_errors(errors, opts)
    end
  end

  def handle_call({:soid, {app, id}}, _from, opts) do
    case Vex.errors([app: app, id: id],
           app: validate(:app),
           id: validate(:id)
         ) do
      [] ->
        debug("get all session of app `#{app}` with id: `#{id}`")

        case sessions_of_id(app, id) do
          {:ok, result} ->
            {:reply, result, opts}

          {:error, error} ->
            {:reply, error, opts}
        end

      errors ->
        handle_validation_errors(errors, opts)
    end
  end

  def handle_call({:killsoid, {app, id}}, _from, opts) do
    case Vex.errors([app: app, id: id],
           app: validate(:app),
           id: validate(:id)
         ) do
      [] ->
        debug("kill all session of app `#{app}` with id: `#{id}`")

        case session_tokens(app, id) do
          {:ok, []} ->
            {:reply, {:ok, %{kill: 0}}, opts}

          {:ok, tokens} ->
            {:reply, kill_sessions(app, tokens, id), opts}

          {:error, error} ->
            {:reply, error, opts}
        end

      errors ->
        handle_validation_errors(errors, opts)
    end
  end

  def handle_call({:killall, {app}}, _from, opts) do
    case Vex.errors([app: app],
           app: validate(:app)
         ) do
      [] ->
        debug("kill all session of app `#{app}`")

        case kill_all(app) do
          {:ok, []} ->
            {:reply, {:ok, %{kill: 0}}, opts}

          {:ok, resp} ->
            {:reply, {:ok, resp}, opts}

          {:error, error} ->
            {:reply, error, opts}
        end

      errors ->
        handle_validation_errors(errors, opts)
    end
  end

  def handle_cast({:wipe}, opts) do
    debug("wipe session call")

    case Redis.command(["ZRANGEBYSCORE", "#{get_ns()}:SESSIONS", "-inf", DateTime.utc_now() |> ts]) do
      {:ok, []} ->
        debug("wipe sessions empty")

      {:ok, sessions} ->
        info("wipe #{Enum.count(sessions)} sessions")

        for session <- sessions do
          [app, token, id] = String.split(session, ":")
          kill_sessions(app, token, id)
        end

      {:error, err} ->
        error(err)
    end

    {:noreply, opts}
  end

  ####
  # PRIVATE METHODS
  ####

  defp session_get(app, token, noupdate) do
    cmd = ["HMGET", "#{get_ns()}:#{app}:#{token}", "id", "r", "w", "ttl", "d", "la", "ip"]

    case Redis.command(cmd) do
      {:ok, result} ->
        session = prepare_session(result)

        cond do
          nil === session ->
            {:ok, nil}

          noupdate ->
            {:ok, session}

          %{} = session ->
            case update_counter(app, token, session, :r) do
              {:error, error} ->
                {:error, error}

              session ->
                {:ok, session}
            end
        end

      {:error, error} ->
        {:error, error}
    end
  end

  defp create_multi_statement(mc, {app, token, id, ttl}, date) do
    now = ts(date)

    mc = [["ZADD", "#{get_ns()}:SESSIONS", "#{now}#{ttl}", "#{app}:#{token}:#{id}"] | mc]
    mc = [["ZADD", "#{get_ns()}:#{app}:_users", now, id] | mc]
    mc = [["ZADD", "#{get_ns()}:#{app}:_sessions", now, "#{token}:#{id}"] | mc]
    mc
  end

  defp create_token(date) do
    random_string() <> "Z" <> str36_datetime(date)
  end

  defp prepare_session([id, r, w, ttl, d, la, ip]) do
    now = DateTime.utc_now()

    case id do
      nil ->
        nil

      _ ->
        session = %{
          id: id,
          r: String.to_integer(r),
          w: String.to_integer(w),
          ttl: String.to_integer(ttl),
          idle: ts(now, :second) - String.to_integer(la),
          ip: ip,
          d: nil
        }

        if session.ttl < session.idle do
          nil
        else
          if d do
            %{session | d: Poison.decode!(d)}
          else
            %{session | d: nil}
          end
        end
    end
  end

  defp kill_sessions(app, token, id) do
    mc =
      case token do
        tkn when is_binary(token) ->
          create_kill_statement(app, tkn, id)

        tokens when is_list(token) ->
          tokens
          |> Enum.reduce([], &(&2 ++ create_kill_statement(app, &1, id)))

        _ ->
          []
      end

    mc = mc ++ [["EXISTS", "#{get_ns()}:#{app}:us:#{id}"]]

    case Redis.pipeline(mc) do
      {:ok, results} ->
        deleted =
          results
          |> Enum.chunk_every(4)
          |> Enum.filter(&(Enum.count(&1) == 4))
          |> Enum.reduce(0, fn [_, _, _, deleted], acc -> acc + deleted end)

        case Redis.command(["ZREM", "#{get_ns()}:#{app}:_users", id]) do
          {:ok, _} ->
            {:ok, %{kill: deleted}}

          {:error, error} ->
            {:error, error}
        end

      {:error, error} ->
        {:error, error}
    end
  end

  defp create_kill_statement(app, token, id) do
    [
      ["ZREM", "#{get_ns()}:#{app}:_sessions", "#{token}:#{id}"],
      ["SREM", "#{get_ns()}:#{app}:us:#{id}", token],
      ["ZREM", "#{get_ns()}:SESSIONS", "#{app}:#{token}:#{id}"],
      ["DEL", "#{get_ns()}:#{app}:#{token}"]
    ]
  end

  defp kill_all(app) do
    ask = "#{get_ns()}:#{app}:_sessions"

    case Redis.command(["ZRANGE", ask, 0, -1]) do
      {:ok, []} ->
        {:ok, %{kill: 0}}

      {:ok, sessions} ->
        {gk, tk, uk, ids} =
          sessions
          |> Enum.reduce({[], [], %MapSet{}, %MapSet{}}, fn session, {gk, tk, uk, ids} ->
            [token, id] = String.split(session, ":")

            gk = ["#{app}:#{session}" | gk]
            tk = ["#{get_ns()}:#{app}:#{token}" | tk]

            uk = MapSet.put(uk, "#{get_ns()}:#{app}:us:#{id}")
            ids = MapSet.put(ids, id)

            {gk, tk, uk, ids}
          end)

        mc = [
          ["ZREM", ask | sessions],
          ["ZREM", "#{get_ns()}:#{app}:_users" | MapSet.to_list(ids)],
          ["ZREM", "#{get_ns()}:SESSIONS" | gk],
          ["DEL" | MapSet.to_list(uk)],
          ["DEL" | tk]
        ]

        case Redis.pipeline(mc) do
          {:ok, [count | _t]} ->
            {:ok, %{kill: count}}

          {:error, error} ->
            {:error, error}
        end

      {:error, error} ->
        {:error, error}
    end
  end

  defp session_tokens(app, id) do
    case Redis.command(["SMEMBERS", "#{get_ns()}:#{app}:us:#{id}"]) do
      {:ok, tokens} ->
        {:ok, tokens}

      {:error, error} ->
        {:error, error}
    end
  end

  defp sessions_of_id(app, id) do
    case session_tokens(app, id) do
      {:ok, tokens} ->
        {:ok, grep_sessions(app, tokens)}

      {:error, error} ->
        {:error, error}
    end
  end

  defp update_d_key(thekey, session, nil, cmds) do
    cmds = [["HDEL", thekey, "d"] | cmds]
    {%{session | d: nil}, cmds}
  end

  defp update_d_key(thekey, session, data, cmds) do
    nil_keys = map_get_nil_keys(data)
    data = Map.merge(Map.drop(session.d, nil_keys), Map.drop(data, nil_keys))

    if data !== %{} do
      cmds = [["HSET", thekey, "d", Poison.encode!(data)] | cmds]
      {%{session | d: data}, cmds}
    else
      update_d_key(thekey, session, nil, cmds)
    end
  end

  defp update_la_key(_thekey, _session, idle, _now, cmds) when idle <= 0 do
    cmds
  end

  defp update_la_key(thekey, _session, _idle, now, cmds) do
    [["HSET", thekey, "la", ts(now, :second)] | cmds]
  end

  defp update_counter(app, token, session, key, inc \\ 1) do
    session = Map.update!(session, key, &(&1 + inc))

    cmd = ["hincrby", "#{get_ns()}:#{app}:#{token}", Atom.to_string(key), inc]

    case Redis.command(cmd) do
      {:ok, _} ->
        session

      {:error, error} ->
        {:error, error}
    end
  end

  defp grep_sessions(_app, []) do
    {:ok, %{sessions: []}}
  end

  defp grep_sessions(app, sessions) do
    mc =
      sessions
      |> Enum.map(&["HMGET", "#{get_ns()}:#{app}:#{&1}", "id", "r", "w", "ttl", "d", "la", "ip"])

    case Redis.pipeline(mc) do
      {:ok, sessiondatas} ->
        ret =
          sessiondatas
          |> Enum.filter(&(&1 != nil))
          |> Enum.map(&prepare_session(&1))

        {:ok, %{sessions: ret}}

      {:error, error} ->
        {:error, error}
    end
  end

  defp d_filter_nil(data) do
    data
    |> Enum.filter(fn {_, val} ->
      not is_nil(val)
    end)
    |> Enum.reduce(%{}, fn {key, val}, acc ->
      Map.put(acc, key, val)
    end)
  end

  defp map_get_nil_keys(data) do
    data
    |> Enum.filter(fn {_, val} -> is_nil(val) end)
    |> Enum.reduce([], fn {key, _}, acc -> [key | acc] end)
  end

  defp handle_validation_errors(errors, opts) do
    case errormsg(errors) do
      errors when is_list(errors) ->
        {:reply, {:error, errors}, opts}

      errors ->
        {:reply, {:error, [errors]}, opts}
    end
  end

  defp validate(:app) do
    [
      presence: true,
      format: [with: ~r/^([a-zA-Z0-9_-]){3,20}$/]
    ]
  end

  defp validate(:id) do
    [
      presence: true,
      format: [with: ~r/^([a-zA-Z0-9_-]){1,64}$/]
    ]
  end

  defp validate(:ip) do
    [
      presence: true,
      format: [with: ~r/^.{1,39}$/]
    ]
  end

  defp validate(:ttl) do
    [
      presence: true,
      by: [function: &(is_integer(&1) and &1 > 10)]
    ]
  end

  defp validate(:token) do
    [
      presence: true,
      format: [with: ~r/^([a-zA-Z0-9]){64}$/i]
    ]
  end

  defp validate(:dt) do
    [
      presence: true,
      by: [function: &(is_integer(&1) and &1 > 10)]
    ]
  end

  defp validate(:d) do
    [
      by: [function: &is_map/1, allow_nil: true]
    ]
  end

  defp validate(:d_req) do
    [
      by: [function: &is_map/1]
      # IDEA add a more detailed validation. see https://github.com/smrchy/redis-sessions/blob/master/index.coffee#L613
    ]
  end

  defp errormsg(errors) when is_list(errors) do
    errors
    |> Enum.map(fn error ->
      errormsg(error)
    end)
  end

  defp errormsg({_err, key, type, msg}) do
    case {key, type} do
      {:app, :format} ->
        {key, :invalidFormat, "Invalid app format"}

      {:app, :presence} ->
        {key, :missingParameter, "no app supplied"}

      {:id, :format} ->
        {key, :invalidFormat, "Invalid id format"}

      {:id, :presence} ->
        {key, :missingParameter, "no id supplied"}

      {:ip, :format} ->
        {key, :invalidFormat, "Invalid ip format"}

      {:ip, :presence} ->
        {key, :missingParameter, "no ip supplied"}

      {:token, :format} ->
        {key, :invalidFormat, "Invalid token format"}

      {:token, :presence} ->
        {key, :missingParameter, "no token supplied"}

      {:ttl, :by} ->
        {key, :invalidValue, "ttl must be a positive integer >= 10"}

      {:dt, :by} ->
        {key, :invalidValue, "ttl must be a positive integer >= 10"}

      {:d, :by} ->
        {key, :invalidValue, "d must be an object"}

      _ ->
        {key, :invalid, msg}
    end
  end

  defp random_string(length \\ 55, chars \\ @tokenchars) do
    1..length |> Enum.map_join(fn _ -> Enum.random(chars) end)
  end

  defp ts(date, resolution \\ :millisecond) do
    date |> DateTime.to_unix(resolution)
  end

  defp str36_datetime(date) do
    date
    |> ts
    |> Integer.to_string(36)
    |> String.downcase()
  end

  defp get_ns do
    get_ns(Application.get_env(:redis_sessions, :ns, "rs"))
  end

  defp get_ns(ns) when is_binary(ns) do
    ns
  end

  defp get_ns({:system, envvar}) do
    get_ns({:system, envvar, "rs"})
  end

  defp get_ns({:system, envvar, default}) do
    sysvar = System.get_env(envvar)

    if sysvar == nil do
      default
    else
      sysvar
    end
  end

  defp get_interval do
    get_interval(Application.get_env(:redis_sessions, :wipe, 600))
  end

  defp get_interval(interval) when is_binary(interval) do
    String.to_integer(interval)
  end

  defp get_interval(interval) when is_number(interval) do
    interval
  end

  defp get_interval({:system, envvar}) do
    get_interval({:system, envvar, 600})
  end

  defp get_interval({:system, envvar, default}) do
    sysvar = System.get_env(envvar)

    if sysvar == nil do
      default
    else
      sysvar
    end
  end
end