local workspaces = require "kong.workspaces"
local constants = require "kong.constants"
local tablepool = require "tablepool"
local req_dyn_hook = require "kong.dynamic_hook"
local wasm = require "kong.runloop.wasm"


local kong = kong
local error = error
local assert = assert
local var = ngx.var
local null = ngx.null
local pcall = pcall
local subsystem = ngx.config.subsystem
local pairs = pairs
local ipairs = ipairs
local format = string.format
local fetch_table = tablepool.fetch
local release_table = tablepool.release
local uuid = require("kong.tools.uuid").uuid
local get_updated_monotonic_ms = require("kong.tools.time").get_updated_monotonic_ms
local req_dyn_hook_disable_by_default = req_dyn_hook.disable_by_default


local TTL_ZERO = { ttl = 0 }
local GLOBAL_QUERY_OPTS = { workspace = null, show_ws_id = true }


local NON_COLLECTING_PHASES, DOWNSTREAM_PHASES, DOWNSTREAM_PHASES_COUNT, COLLECTING_PHASE, CONFIGURE_PHASE
do
  if subsystem == "stream" then
    NON_COLLECTING_PHASES = {
      "certificate",
      "log",
    }

    DOWNSTREAM_PHASES = {
      "log",
    }

    COLLECTING_PHASE = "preread"

  else
    NON_COLLECTING_PHASES = {
      "certificate",
      "rewrite",
      "response",
      "header_filter",
      "body_filter",
      "log",
    }

    DOWNSTREAM_PHASES = {
      "response",
      "header_filter",
      "body_filter",
      "log",
    }

    COLLECTING_PHASE = "access"
  end

  DOWNSTREAM_PHASES_COUNT = #DOWNSTREAM_PHASES
  CONFIGURE_PHASE = "configure"
end


local PLUGINS_NS = "plugins." .. subsystem
local ENABLED_PLUGINS
local LOADED_PLUGINS
local CONFIGURABLE_PLUGINS


local PluginsIterator = {}


---
-- Build a compound key by string formatting route_id, service_id, and consumer_id with colons as separators.
--
-- @function build_compound_key
-- @tparam string|nil route_id The route identifier. If `nil`, an empty string is used.
-- @tparam string|nil service_id The service identifier. If `nil`, an empty string is used.
-- @tparam string|nil consumer_id The consumer identifier. If `nil`, an empty string is used.
-- @treturn string The compound key, in the format `route_id:service_id:consumer_id`.
local function build_compound_key(route_id, service_id, consumer_id)
  return format("%s:%s:%s", route_id or "", service_id or "", consumer_id or "")
end


local PLUGIN_GLOBAL_KEY = build_compound_key() -- all nil


local function get_table_for_ctx(ws)
  local tbl = fetch_table(PLUGINS_NS, 0, DOWNSTREAM_PHASES_COUNT + 2)
  if not tbl.initialized then
    for i = 1, DOWNSTREAM_PHASES_COUNT do
      tbl[DOWNSTREAM_PHASES[i]] = kong.table.new(ws.plugins[0] * 2, 1)
    end
    tbl.initialized = true
  end

  for i = 1, DOWNSTREAM_PHASES_COUNT do
    tbl[DOWNSTREAM_PHASES[i]][0] = 0
  end

  tbl.ws = ws

  return tbl
end


local function release(ctx)
  local plugins = ctx.plugins
  if plugins then
    release_table(PLUGINS_NS, plugins, true)
    ctx.plugins = nil
  end
end


local function get_loaded_plugins()
  return assert(kong.db.plugins:get_handlers())
end


local function get_configurable_plugins()
  local i = 0
  local plugins_with_configure_phase = {}
  for _, plugin in ipairs(LOADED_PLUGINS) do
    if plugin.handler[CONFIGURE_PHASE] then
      i = i + 1
      local name = plugin.name
      plugins_with_configure_phase[name] = true
      plugins_with_configure_phase[i] = plugin
    end
  end
  return plugins_with_configure_phase
end


local function should_process_plugin(plugin)
  if wasm.filters_by_name[plugin.name] then
    return false
  end

  if plugin.enabled then
    local c = constants.PROTOCOLS_WITH_SUBSYSTEM
    for _, protocol in ipairs(plugin.protocols) do
      if c[protocol] == subsystem then
        return true
      end
    end
  end
end


local function get_plugin_config(plugin, name, ws_id)
  if not plugin or not plugin.enabled then
    return
  end

  local cfg = plugin.config or {}

  cfg.route_id = plugin.route and plugin.route.id
  cfg.service_id = plugin.service and plugin.service.id
  cfg.consumer_id = plugin.consumer and plugin.consumer.id
  cfg.plugin_instance_name = plugin.instance_name
  cfg.__plugin_id = plugin.id
  cfg.__ws_id = ws_id

  local key = kong.db.plugins:cache_key(name,
                                        cfg.route_id,
                                        cfg.service_id,
                                        cfg.consumer_id,
                                        nil,
                                        ws_id)

  -- TODO: deprecate usage of __key__ as id of plugin
  if not cfg.__key__ then
    cfg.__key__ = key
    -- generate a unique sequence across workers
    -- with a seq 0, plugin server generates an unused random instance id
    local next_seq, err = ngx.shared.kong:incr("plugins_iterator:__seq__", 1, 0, 0)
    if err then
      next_seq = 0
    end
    cfg.__seq__ = next_seq
  end

  return cfg
end


---
-- Lookup a configuration for a given combination of route_id, service_id, consumer_id
--
-- The function checks various combinations of route_id, service_id and consumer_id to find
-- the best matching configuration in the given 'combos' table. The priority order is as follows:
--
-- 1. Route, Service, Consumer
-- 2. Route, Consumer
-- 3. Service, Consumer
-- 4. Route, Service
-- 5. Consumer
-- 6. Route
-- 7. Service
-- 8. Global
--
-- @function lookup_cfg
-- @tparam table combos A table containing configuration data indexed by compound keys.
-- @tparam string|nil route_id The route identifier.
-- @tparam string|nil service_id The service identifier.
-- @tparam string|nil consumer_id The consumer identifier.
-- @return any|nil The configuration corresponding to the best matching combination, or 'nil' if no configuration is found.
local function lookup_cfg(combos, route_id, service_id, consumer_id)
  -- Use the build_compound_key function to create an index for the 'combos' table
  if route_id and service_id and consumer_id then
    local key = build_compound_key(route_id, service_id, consumer_id)
    if combos[key] then
      return combos[key]
    end
  end

  if route_id and consumer_id then
    local key = build_compound_key(route_id, nil, consumer_id)
    if combos[key] then
      return combos[key]
    end
  end

  if service_id and consumer_id then
    local key = build_compound_key(nil, service_id, consumer_id)
    if combos[key] then
      return combos[key]
    end
  end

  if route_id and service_id then
    local key = build_compound_key(route_id, service_id, nil)
    if combos[key] then
      return combos[key]
    end
  end

  if consumer_id then
    local key = build_compound_key(nil, nil, consumer_id)
    if combos[key] then
      return combos[key]
    end
  end

  if route_id then
    local key = build_compound_key(route_id, nil, nil)
    if combos[key] then
      return combos[key]
    end
  end

  if service_id then
    local key = build_compound_key(nil, service_id, nil)
    if combos[key] then
      return combos[key]
    end
  end

  return combos[PLUGIN_GLOBAL_KEY]
end


---
-- Load the plugin configuration based on the context (route, service, and consumer) and plugin handler rules.
--
-- This function filters out route, service, and consumer information from the context based on the plugin handler rules,
-- and then calls the 'lookup_cfg' function to get the best matching plugin configuration for the given combination of
-- route_id, service_id, and consumer_id.
--
-- @function load_configuration_through_combos
-- @tparam table ctx A table containing the context information, including route, service, and authenticated_consumer.
-- @tparam table combos A table containing configuration data indexed by compound keys.
-- @tparam table plugin A table containing plugin information, including the handler with no_route, no_service, and no_consumer rules.
-- @treturn any|nil The configuration corresponding to the best matching combination, or 'nil' if no configuration is found.
local function load_configuration_through_combos(ctx, combos, plugin)
  -- Filter out route, service, and consumer based on the plugin handler rules and get their ids
  local handler = plugin.handler
  local route_id = (not handler.no_route and ctx.route) and ctx.route.id or nil
  local service_id = (not handler.no_service and ctx.service) and ctx.service.id or nil
  local consumer_id = (not handler.no_consumer and ctx.authenticated_consumer) and ctx.authenticated_consumer.id or nil

  -- Call the lookup_cfg function to get the best matching plugin configuration
  return lookup_cfg(combos, route_id, service_id, consumer_id)
end


local function get_workspace(self, ctx)
  if not ctx then
    return self.ws[kong.default_workspace]
  end

  return self.ws[workspaces.get_workspace_id(ctx) or kong.default_workspace]
end


local function get_next_init_worker(plugins, i)
  local i = i + 1
  local plugin = plugins[i]
  if not plugin then
    return nil
  end

  if plugin.handler.init_worker then
    return i, plugin
  end

  return get_next_init_worker(plugins, i)
end


local function get_init_worker_iterator(self)
  if #self.loaded == 0 then
    return nil
  end

  return get_next_init_worker, self.loaded
end


local function get_next_global_or_collected_plugin(plugins, i)
  i = i + 2
  if i > plugins[0] then
    return nil
  end

  return i, plugins[i - 1], plugins[i]
end


local function get_global_iterator(self, phase)
  local plugins = self.globals[phase]
  local count = plugins and plugins[0] or 0
  if count == 0 then
    return nil
  end

  -- only execute this once per request
  if phase == "certificate" or (phase == "rewrite" and var.https ~= "on") then
    local i = 2
    while i <= count do
      kong.vault.update(plugins[i])
      i = i + 2
    end
  end

  return get_next_global_or_collected_plugin, plugins
end


local function get_collected_iterator(self, phase, ctx)
  local plugins = ctx.plugins
  if plugins then
    plugins = plugins[phase]
    if not plugins or plugins[0] == 0 then
      return nil
    end

    return get_next_global_or_collected_plugin, plugins
  end

  return get_global_iterator(self, phase)
end


local function get_next_and_collect(ctx, i)
  i = i + 1
  local ws = ctx.plugins.ws
  local plugins = ws.plugins
  if i > plugins[0] then
    return nil
  end

  local plugin = plugins[i]
  local name = plugin.name
  local cfg
  -- Only pass combos for the plugin we're operating on
  local combos = ws.combos[name]
  if combos then
    cfg = load_configuration_through_combos(ctx, combos, plugin)
    if cfg then
      kong.vault.update(cfg)
      local handler = plugin.handler
      local collected = ctx.plugins
      for j = 1, DOWNSTREAM_PHASES_COUNT do
        local phase = DOWNSTREAM_PHASES[j]
        if handler[phase] then
          local n = collected[phase][0] + 2
          collected[phase][0] = n
          collected[phase][n] = cfg
          collected[phase][n - 1] = plugin
          if phase == "response" and not ctx.buffered_proxying then
            ctx.buffered_proxying = true
          end
        end
      end

      if handler[COLLECTING_PHASE] then
        return i, plugin, cfg
      end
    end
  end

  return get_next_and_collect(ctx, i)
end


local function get_collecting_iterator(self, ctx)
  local ws = get_workspace(self, ctx)
  ctx.plugins = get_table_for_ctx(ws)
  if not ws then
    return nil
  end

  local plugins = ws.plugins
  if plugins[0] == 0 then
    return nil
  end

  return get_next_and_collect, ctx
end


local function new_ws_data()
  return {
    plugins = { [0] = 0 },
    combos = {},
  }
end


local function configure(configurable, ctx)
  -- Disable hooks that are selectively enabled by plugins
  -- in their :configure handler
  req_dyn_hook_disable_by_default("observability_logs")

  ctx = ctx or ngx.ctx
  local kong_global = require "kong.global"
  for _, plugin in ipairs(CONFIGURABLE_PLUGINS) do
    local name = plugin.name

    kong_global.set_namespaced_log(kong, plugin.name, ctx)
    local start = get_updated_monotonic_ms()
    local ok, err = pcall(plugin.handler[CONFIGURE_PHASE], plugin.handler, configurable[name])
    local elapsed = get_updated_monotonic_ms() - start
    kong_global.reset_log(kong, ctx)

    if not ok then
      kong.log.err("failed to execute plugin '", name, ":", CONFIGURE_PHASE, " (", err, ")")
    else
      if elapsed > 50 then
        kong.log.notice("executing plugin '", name, ":", CONFIGURE_PHASE, " took excessively long: ", elapsed, " ms")
      end
    end
  end
end


local function create_configure(configurable)
  -- we only want the plugin_iterator:configure to be only available on proxying
  -- nodes (or data planes), thus we disable it if this code gets executed on control
  -- plane or on a node that does not listen any proxy ports.
  --
  -- TODO: move to PDK, e.g. kong.node.is_proxying()
  if kong.configuration.role == "control_plane"
  or ((subsystem == "http"   and #kong.configuration.proxy_listeners == 0) or
      (subsystem == "stream" and #kong.configuration.stream_listeners == 0))
  then
    return function() end
  end

  return function(self, ctx)
    configure(configurable, ctx)
    -- self destruct the function so that it cannot be called twice
    -- if it ever happens to be called twice, it should be very visible
    -- because of this.
    self.configure = nil
    configurable = nil
  end
end


function PluginsIterator.new(version)
  local is_not_dbless = kong.db.strategy ~= "off"
  if is_not_dbless then
    if not version then
      error("version must be given", 2)
    end
  end

  LOADED_PLUGINS = LOADED_PLUGINS or get_loaded_plugins()
  CONFIGURABLE_PLUGINS = CONFIGURABLE_PLUGINS or get_configurable_plugins()
  ENABLED_PLUGINS = ENABLED_PLUGINS or kong.configuration.loaded_plugins

  local ws_id = workspaces.get_workspace_id() or kong.default_workspace
  local ws = {
    [ws_id] = new_ws_data()
  }

  local counter = 0
  local globals
  do
    globals = {}
    for _, phase in ipairs(NON_COLLECTING_PHASES) do
      globals[phase] = { [0] = 0 }
    end
  end

  local configurable = {}
  local has_plugins = false
  local page_size = kong.db.plugins.pagination.max_page_size

  if kong.configuration.role == "control_plane" then
    goto done
  end

  for plugin, err in kong.db.plugins:each(page_size, GLOBAL_QUERY_OPTS) do
    if err then
      return nil, err
    end

    local name = plugin.name
    if not ENABLED_PLUGINS[name] then
      return nil, name .. " plugin is in use but not enabled"
    end

    if is_not_dbless and counter > 0 and counter % page_size == 0 and kong.core_cache then
      local new_version, err = kong.core_cache:get("plugins_iterator:version", TTL_ZERO, uuid)
      if err then
        return nil, "failed to retrieve plugins iterator version: " .. err
      end

      if new_version ~= version then
        -- the plugins iterator rebuild is being done by a different process at
        -- the same time, stop here and let the other one go for it
        kong.log.info("plugins iterator was changed while rebuilding it")
        return
      end
    end

    if should_process_plugin(plugin) then
      -- Get the plugin configuration for the specified workspace (ws_id)
      local cfg = get_plugin_config(plugin, name, plugin.ws_id)
      if cfg then
        has_plugins = true

        if CONFIGURABLE_PLUGINS[name] then
          configurable[name] = configurable[name] or {}
          configurable[name][#configurable[name] + 1] = cfg
        end

        local data = ws[plugin.ws_id]
        if not data then
          data = new_ws_data()
          ws[plugin.ws_id] = data
        end
        local plugins = data.plugins
        local combos = data.combos

        plugins[name] = true

        -- Retrieve route_id, service_id, and consumer_id from the plugin object, if they exist
        local route_id = plugin.route and plugin.route.id
        local service_id = plugin.service and plugin.service.id
        local consumer_id = plugin.consumer and plugin.consumer.id

        -- Determine if the plugin configuration is global (i.e., not tied to any route, service, consumer or group)
        if not (route_id or service_id or consumer_id) and plugin.ws_id == kong.default_workspace then
          -- Store the global configuration for the plugin in the 'globals' table
          globals[name] = cfg
        end

        -- Initialize an empty table for the plugin in the 'combos' table if it doesn't already exist
        combos[name] = combos[name] or {}

        -- Build a compound key using the route_id, service_id, and consumer_id
        local compound_key = build_compound_key(route_id, service_id, consumer_id)

        -- Store the plugin configuration in the 'combos' table using the compound key
        combos[name][compound_key] = cfg
      end
    end

    counter = counter + 1
  end

  if has_plugins then
    -- loaded_plugins contains all the plugins that we _may_ execute
    for _, plugin in ipairs(LOADED_PLUGINS) do
      local name = plugin.name
      -- ws contains all the plugins that are associated to the request via route/service/global mappings
      for _, data in pairs(ws) do
        local plugins = data.plugins
        if plugins[name] then -- is the plugin associated to the request(workspace/route/service)?
          local n = plugins[0] + 1
          plugins[n] = plugin -- next item goes into next slot
          plugins[0] = n      -- index 0 holds table size
          plugins[name] = nil -- remove the placeholder value
        end
      end

      local cfg = globals[name]
      if cfg then
        for _, phase in ipairs(NON_COLLECTING_PHASES) do
          if plugin.handler[phase] then
            local plugins = globals[phase]
            local n = plugins[0] + 2
            plugins[0] = n
            plugins[n] = cfg
            plugins[n - 1] = plugin
          end
        end
      end
    end
  end

::done::

  return {
    version = version,
    ws = ws,
    loaded = LOADED_PLUGINS,
    configure = create_configure(configurable),
    globals = globals,
    get_init_worker_iterator = get_init_worker_iterator,
    get_global_iterator = get_global_iterator,
    get_collecting_iterator = get_collecting_iterator,
    get_collected_iterator = get_collected_iterator,
    has_plugins = has_plugins,
    release = release,
  }
end


-- for testing
PluginsIterator.lookup_cfg = lookup_cfg
PluginsIterator.build_compound_key = build_compound_key


return PluginsIterator
