local _M = {}
local _MT = { __index = _M, }


local semaphore = require("ngx.semaphore")
local declarative = require("kong.db.declarative")
local clustering_utils = require("kong.clustering.utils")
local compat = require("kong.clustering.compat")
local constants = require("kong.constants")
local events = require("kong.clustering.events")
local calculate_config_hash = require("kong.clustering.config_helper").calculate_config_hash
local EMPTY = require("kong.tools.table").EMPTY


local string = string
local setmetatable = setmetatable
local type = type
local pcall = pcall
local pairs = pairs
local ngx = ngx
local ngx_log = ngx.log
local timer_at = ngx.timer.at
local json_decode = clustering_utils.json_decode
local json_encode = clustering_utils.json_encode
local kong = kong
local ngx_exit = ngx.exit
local exiting = ngx.worker.exiting
local worker_id = ngx.worker.id
local ngx_time = ngx.time
local ngx_now = ngx.now
local ngx_var = ngx.var
local table_insert = table.insert
local table_remove = table.remove
local sub = string.sub
local isempty = require("table.isempty")
local sleep = ngx.sleep
local now_updated = require("kong.tools.time").get_updated_now


local plugins_list_to_map = compat.plugins_list_to_map
local update_compatible_payload = compat.update_compatible_payload
local check_mixed_route_entities = compat.check_mixed_route_entities
local deflate_gzip = require("kong.tools.gzip").deflate_gzip
local yield = require("kong.tools.yield").yield
local connect_dp = clustering_utils.connect_dp


local kong_dict = ngx.shared.kong
local ngx_DEBUG = ngx.DEBUG
local ngx_NOTICE = ngx.NOTICE
local ngx_WARN = ngx.WARN
local ngx_ERR = ngx.ERR
local ngx_OK = ngx.OK
local ngx_ERROR = ngx.ERROR
local ngx_CLOSE = ngx.HTTP_CLOSE
local PING_WAIT = constants.CLUSTERING_PING_INTERVAL * 1.5
local CLUSTERING_SYNC_STATUS = constants.CLUSTERING_SYNC_STATUS
local DECLARATIVE_EMPTY_CONFIG_HASH = constants.DECLARATIVE_EMPTY_CONFIG_HASH
local PONG_TYPE = "PONG"
local RECONFIGURE_TYPE = "RECONFIGURE"
local _log_prefix = "[clustering] "


local no_connected_clients_logged


local function handle_export_deflated_reconfigure_payload(self)
  ngx_log(ngx_DEBUG, _log_prefix, "exporting config")

  local ok, p_err, err = pcall(self.export_deflated_reconfigure_payload, self)
  return ok, p_err or err
end


local function is_timeout(err)
  return err and sub(err, -7) == "timeout"
end


local function is_closed(err)
  return err and sub(err, -6) == "closed"
end


local function extract_dp_cert(cert)
  local expiry_timestamp = cert:get_not_after()
  -- values in cert_details must be strings
  local cert_details = {
    expiry_timestamp = expiry_timestamp,
  }

  return cert_details
end


function _M.new(clustering)
  assert(type(clustering) == "table",
         "kong.clustering is not instantiated")

  assert(type(clustering.conf) == "table",
         "kong.clustering did not provide configuration")

  local self = {
    clients = setmetatable({}, { __mode = "k", }),
    plugins_map = {},
    conf = clustering.conf,
  }

  return setmetatable(self, _MT)
end


function _M:export_deflated_reconfigure_payload()
  local config_table, err = declarative.export_config()
  if not config_table then
    return nil, err
  end

  -- update plugins map
  self.plugins_configured = {}
  if config_table.plugins then
    for _, plugin in pairs(config_table.plugins) do
      self.plugins_configured[plugin.name] = true
    end
  end

  -- store serialized plugins map for troubleshooting purposes
  local shm_key_name = "clustering:cp_plugins_configured:worker_" .. (worker_id() or -1)
  kong_dict:set(shm_key_name, json_encode(self.plugins_configured))
  kong.log.trace(_log_prefix, "plugin configuration map key: ", shm_key_name, " configuration: ", kong_dict:get(shm_key_name))

  local config_hash, hashes = calculate_config_hash(config_table)

  local payload = {
    type = "reconfigure",
    timestamp = ngx_now(),
    config_table = config_table,
    config_hash = config_hash,
    hashes = hashes,
  }

  self.reconfigure_payload = payload

  payload, err = json_encode(payload)
  if not payload then
    return nil, err
  end

  yield()

  payload, err = deflate_gzip(payload)
  if not payload then
    return nil, err
  end

  yield()

  self.current_hashes = hashes
  self.current_config_hash = config_hash
  self.deflated_reconfigure_payload = payload

  return payload, nil, config_hash
end


function _M:push_config()
  local start = ngx_now()

  local payload, err = self:export_deflated_reconfigure_payload()
  if not payload then
    ngx_log(ngx_ERR, _log_prefix, "unable to export config from database: ", err)
    return
  end

  local n = 0
  for _, queue in pairs(self.clients) do
    table_insert(queue, RECONFIGURE_TYPE)
    queue.post()
    n = n + 1
  end

  local duration = now_updated() - start
  ngx_log(ngx_DEBUG, _log_prefix, "config pushed to ", n, " data-plane nodes in ", duration, " seconds")
end


_M.check_version_compatibility = compat.check_version_compatibility
_M.check_configuration_compatibility = compat.check_configuration_compatibility


function _M:handle_cp_websocket(cert)
  local dp_id = ngx_var.arg_node_id
  local dp_hostname = ngx_var.arg_node_hostname
  local dp_ip = ngx_var.remote_addr
  local dp_version = ngx_var.arg_node_version

  local wb, log_suffix, ec = connect_dp(dp_id, dp_hostname, dp_ip, dp_version)
  if not wb then
    return ngx_exit(ec)
  end

  -- connection established
  -- receive basic info
  local data, typ, err
  data, typ, err = wb:recv_frame()
  if err then
    err = "failed to receive websocket basic info frame: " .. err

  elseif typ == "binary" then
    if not data then
      err = "failed to receive websocket basic info data"

    else
      data, err = json_decode(data)
      if type(data) ~= "table" then
          err = "failed to decode websocket basic info data" ..
                (err and ": " .. err or "")

      else
        if data.type ~= "basic_info" then
          err = "invalid basic info data type: " .. (data.type or "unknown")

        else
          if type(data.plugins) ~= "table" then
            err = "missing plugins in basic info data"
          end
        end
      end
    end
  end

  if err then
    ngx_log(ngx_ERR, _log_prefix, err, log_suffix)
    wb:send_close()
    return ngx_exit(ngx_CLOSE)
  end

  local dp_cert_details = extract_dp_cert(cert)
  local dp_plugins_map = plugins_list_to_map(data.plugins)
  local config_hash = DECLARATIVE_EMPTY_CONFIG_HASH -- initial hash
  local last_seen = ngx_time()
  local sync_status = CLUSTERING_SYNC_STATUS.UNKNOWN
  local purge_delay = self.conf.cluster_data_plane_purge_delay
  local update_sync_status = function()
    local pk = { id = dp_id }
    local rpc_capabilities

    if self.conf.cluster_rpc then
      -- rpc framework should update rpc_capabilities when connecting
      local res, err = kong.db.clustering_data_planes:select(pk)
      if err then
        ngx_log(ngx_ERR, "unable to update clustering data plane status, select(",
                dp_id, ") failed: ", err, log_suffix)
        return
      end

      -- do not overwrite rpc_capabilities field
      rpc_capabilities = res and res.rpc_capabilities
    end

    local ok
    ok, err = kong.db.clustering_data_planes:upsert(pk, {
      last_seen = last_seen,
      config_hash = config_hash ~= ""
                and config_hash
                 or DECLARATIVE_EMPTY_CONFIG_HASH,
      hostname = dp_hostname,
      ip = dp_ip,
      version = dp_version,
      sync_status = sync_status, -- TODO: import may have been failed though
      labels = data.labels,
      cert_details = dp_cert_details,
      rpc_capabilities = rpc_capabilities or EMPTY,  -- should be a list or empty
    }, { ttl = purge_delay, no_broadcast_crud_event = true, })
    if not ok then
      ngx_log(ngx_ERR, _log_prefix, "unable to update clustering data plane status: ", err, log_suffix)
    end
  end

  local _
  _, err, sync_status = self:check_version_compatibility({
    dp_version = dp_version,
    dp_plugins_map = dp_plugins_map,
    log_suffix = log_suffix,
  })
  if err then
    ngx_log(ngx_ERR, _log_prefix, err, log_suffix)
    wb:send_close()
    update_sync_status()
    return ngx_exit(ngx_CLOSE)
  end

  ngx_log(ngx_DEBUG, _log_prefix, "data plane connected", log_suffix)

  local queue
  do
    local queue_semaphore = semaphore.new()
    queue = {
      wait = function(...)
        return queue_semaphore:wait(...)
      end,
      post = function(...)
        return queue_semaphore:post(...)
      end
    }
  end

  -- if clients table is empty, we might have skipped some config
  -- push event in `push_config_loop`, which means the cached config
  -- might be stale, so we always export the latest config again in this case
  if isempty(self.clients) or not self.deflated_reconfigure_payload then
    _, err = handle_export_deflated_reconfigure_payload(self)
  end

  self.clients[wb] = queue

  if self.deflated_reconfigure_payload then
    -- initial configuration compatibility for sync status variable
    _, _, sync_status = self:check_configuration_compatibility({
      dp_plugins_map = dp_plugins_map,
      filters = data.filters,
    })

    table_insert(queue, RECONFIGURE_TYPE)
    queue.post()

  else
    ngx_log(ngx_ERR, _log_prefix, "unable to send initial configuration to data plane: ", err, log_suffix)
  end

  -- how control plane connection management works:
  -- two threads are spawned, when any of these threads exits,
  -- it means a fatal error has occurred on the connection,
  -- and the other thread is also killed
  --
  -- * read_thread: it is the only thread that receives websocket frames from the
  --                data plane and records the current data plane status in the
  --                database, and is also responsible for handling timeout detection
  -- * write_thread: it is the only thread that sends websocket frames to the data plane
  --                 by grabbing any messages currently in the send queue and
  --                 send them to the data plane in a FIFO order. Notice that the
  --                 PONG frames are also sent by this thread after they are
  --                 queued by the read_thread

  local read_thread = ngx.thread.spawn(function()
    while not exiting() do
      local data, typ, err = wb:recv_frame()

      if exiting() then
        return
      end

      if err then
        if not is_timeout(err) then
          return nil, err
        end

        local waited = ngx_time() - last_seen
        if waited > PING_WAIT then
          return nil, "did not receive ping frame from data plane within " ..
                      PING_WAIT .. " seconds"
        end

        -- timeout
        goto continue
      end

      if typ == "close" then
        ngx_log(ngx_DEBUG, _log_prefix, "received close frame from data plane", log_suffix)
        return
      end

      if not data then
        return nil, "did not receive ping frame from data plane"

      elseif #data ~= 32 then
        return nil, "received a ping frame from the data plane with an invalid"
                 .. " hash: '" .. tostring(data) .. "'"
      end

      -- dps only send pings
      if typ ~= "ping" then
        return nil, "invalid websocket frame received from data plane: " .. typ
      end

      kong.log.trace(_log_prefix, "received ping frame from data plane", log_suffix)

      config_hash = data
      last_seen = ngx_time()
      update_sync_status()

      -- queue PONG to avoid races
      table_insert(queue, PONG_TYPE)
      queue.post()

      ::continue::
    end
  end)

  local write_thread = ngx.thread.spawn(function()
    while not exiting() do
      local ok, err = queue.wait(5)

      if exiting() then
        return
      end

      if not ok then
        if err ~= "timeout" then
          return nil, "semaphore wait error: " .. err
        end

        -- timeout
        goto continue
      end

      local payload = table_remove(queue, 1)
      if not payload then
        return nil, "config queue can not be empty after semaphore returns"
      end

      if payload == PONG_TYPE then
        local _, err = wb:send_pong()
        if err then
          if not is_timeout(err) then
            return nil, "failed to send pong frame to data plane: " .. err
          end

          ngx_log(ngx_NOTICE, _log_prefix, "failed to send pong frame to data plane: ", err, log_suffix)

        else
          kong.log.trace(_log_prefix, "sent pong frame to data plane", log_suffix)
        end

        -- pong ok
        goto continue
      end

      -- is reconfigure
      assert(payload == RECONFIGURE_TYPE)

      local previous_sync_status = sync_status
      ok, err, sync_status = self:check_configuration_compatibility({
        dp_plugins_map = dp_plugins_map,
        filters = data.filters,
      })

      if not ok then
        ngx_log(ngx_WARN, _log_prefix, "unable to send updated configuration to data plane: ", err, log_suffix)
        if sync_status ~= previous_sync_status then
          update_sync_status()
        end

        goto continue
      end

      ok, err = check_mixed_route_entities(self.reconfigure_payload, dp_version,
                                           kong and kong.configuration and
                                           kong.configuration.router_flavor)
      if not ok then
        ngx_log(ngx_WARN, _log_prefix, "unable to send updated configuration to data plane: ", err, log_suffix)

        goto continue
      end

      local _, deflated_payload, err = update_compatible_payload(self.reconfigure_payload, dp_version, log_suffix)

      if not deflated_payload then -- no modification or err, use the cached payload
        deflated_payload = self.deflated_reconfigure_payload
      end

      if err then
        ngx_log(ngx_WARN, "unable to update compatible payload: ", err, ", the unmodified config ",
                          "is returned", log_suffix)
      end

      -- config update
      local _, err = wb:send_binary(deflated_payload)
      if err then
        if not is_timeout(err) then
          return nil, "unable to send updated configuration to data plane: " .. err
        end

        ngx_log(ngx_NOTICE, _log_prefix, "unable to send updated configuration to data plane: ", err, log_suffix)

      else
        ngx_log(ngx_DEBUG, _log_prefix, "sent config update to data plane", log_suffix)
      end

      ::continue::
    end
  end)

  local ok, err, perr = ngx.thread.wait(write_thread, read_thread)

  self.clients[wb] = nil

  ngx.thread.kill(write_thread)
  ngx.thread.kill(read_thread)

  wb:send_close()

  --TODO: should we update disconnect data plane status?
  --sync_status = CLUSTERING_SYNC_STATUS.UNKNOWN
  --update_sync_status()

  if not ok then
    ngx_log(ngx_ERR, _log_prefix, err, log_suffix)
    return ngx_exit(ngx_ERROR)
  end

  if perr then
    if is_closed(perr) then
      ngx_log(ngx_DEBUG, _log_prefix, "data plane closed the connection", log_suffix)
    else
      ngx_log(ngx_ERR, _log_prefix, perr, log_suffix)
    end

    return ngx_exit(ngx_ERROR)
  end

  return ngx_exit(ngx_OK)
end


local function push_config_loop(premature, self, push_config_semaphore, delay)
  if premature then
    return
  end

  local ok, err = handle_export_deflated_reconfigure_payload(self)
  if not ok then
    ngx_log(ngx_ERR, _log_prefix, "unable to export initial config from database: ", err)
  end

  while not exiting() do
    local ok, err = push_config_semaphore:wait(1)
    if exiting() then
      return
    end

    if not ok then
      if err ~= "timeout" then
        ngx_log(ngx_ERR, _log_prefix, "semaphore wait error: ", err)
      end

      goto continue
    end

    if isempty(self.clients) then
      if not no_connected_clients_logged then
        ngx_log(ngx_DEBUG, _log_prefix, "skipping config push (no connected clients)")
        no_connected_clients_logged = true
      end
      sleep(1)
      -- re-queue the task. wait until we have clients connected
      if push_config_semaphore:count() <= 0 then
        push_config_semaphore:post()
      end

      goto continue
    end

    no_connected_clients_logged = nil

    ok, err = pcall(self.push_config, self)
    if not ok then
      ngx_log(ngx_ERR, _log_prefix, "export and pushing config failed: ", err)
      goto continue
    end

    -- push_config ok, waiting for a while

    local sleep_left = delay
    while sleep_left > 0 do
      if sleep_left <= 1 then
        sleep(sleep_left)
        break
      end

      sleep(1)

      if exiting() then
        return
      end

      sleep_left = sleep_left - 1
    end

    ::continue::
  end
end


function _M:init_worker(basic_info)
  -- ROLE = "control_plane"
  local plugins_list = basic_info.plugins
  self.plugins_list = plugins_list
  self.plugins_map = plugins_list_to_map(plugins_list)

  self.deflated_reconfigure_payload = nil
  self.reconfigure_payload = nil
  self.plugins_configured = {}
  self.plugin_versions = {}

  for i = 1, #plugins_list do
    local plugin = plugins_list[i]
    self.plugin_versions[plugin.name] = plugin.version
  end

  self.filters = basic_info.filters

  local push_config_semaphore = semaphore.new()

  -- When "clustering", "push_config" worker event is received by a worker,
  -- it loads and pushes the config to its the connected data planes
  events.clustering_push_config(function(_)
    if push_config_semaphore:count() <= 0 then
      -- the following line always executes immediately after the `if` check
      -- because `:count` will never yield, end result is that the semaphore
      -- count is guaranteed to not exceed 1
      push_config_semaphore:post()
    end
  end)

  timer_at(0, push_config_loop, self, push_config_semaphore,
               self.conf.db_update_frequency)
end


return _M
