-- This software is copyright Kong Inc. and its licensors.
-- Use of the software is subject to the agreement between your organization
-- and Kong Inc. If there is no such agreement, use is governed by and
-- subject to the terms of the Kong Master Software License Agreement found
-- at https://konghq.com/enterprisesoftwarelicense/.
-- [ END OF LICENSE 0867164ffc95e54f04670b5169c09574bdbd9bba ]

local ngx_pipe = require("ngx.pipe")
local ffi = require("ffi")
local cjson_safe = require("cjson.safe")
local logger = require("spec.helpers.perf.logger")

local log = logger.new_logger("[controller]")
local DISABLE_EXEC_OUTPUT = os.getenv("PERF_TEST_DISABLE_EXEC_OUTPUT") or false

string.startswith = function(s, start) -- luacheck: ignore
  return s and start and start ~= "" and s:sub(1, #start) == start
end

string.endswith = function(s, e) -- luacheck: ignore
  return s and e and e ~= "" and s:sub(#s-#e+1, #s) == e
end

--- Spawns a child process and get its exit code and outputs
-- @param opts.stdin string the stdin buffer
-- @param opts.logger function(lvl, _, line) stdout+stderr writer; if not defined, whole
-- stdout and stderr is returned
-- @param opts.stop_signal function return true to abort execution
-- @return stdout+stderr string, err|nil
local function execute(cmd, opts)
  local log_output = opts and opts.logger

  -- skip if PERF_TEST_DISABLE_EXEC_OUTPUT is set
  if not DISABLE_EXEC_OUTPUT then
    -- fallback to default logger if not defined
    log_output = log_output or log.debug
    log_output("[exec]: ", cmd)
  end

  local proc, err = ngx_pipe.spawn(cmd, {
    merge_stderr = true,
  })
  if not proc then
    return "", "failed to start process: " .. err
  end

  -- set stdout/stderr read timeout to 1s for faster noticing process exit
  -- proc:set_timeouts(write_timeout?, stdout_read_timeout?, stderr_read_timeout?, wait_timeout?)
  proc:set_timeouts(nil, 1000, 1000, nil)
  if opts and opts.stdin then
    proc:write(opts.stdin)
  end
  proc:shutdown("stdin")

  local ret = {}

  while true do
    -- is it alive?
    local ok = proc:kill(0)
    if not ok then
      break
    end

    local l, err = proc:stdout_read_line()
    if l then
      if log_output then
        log_output(l)
      end

      -- always store output
      table.insert(ret, l)
    end
    if err == "closed" then
      break
    end
    local sig = opts and opts.stop_signal and opts.stop_signal()
    if sig then
      proc:kill(sig)
      break
    end
  end
  local ok, msg, code = proc:wait()
  ok = ok and code == 0
  ret = table.concat(ret, "\n")
  if ok then
    return ret
  end

  return ret, ("process exited with code %s: %s"):format(code, msg)
end

--- Execute a command and return until pattern is found in its output
-- @function wait_output
-- @param cmd string the command the execute
-- @param pattern string the pattern to find in stdout and stderr
-- @param timeout number time in seconds to wait for the pattern
-- @return bool whether the pattern is found
local function wait_output(cmd, pattern, timeout)
  timeout = timeout or 5
  local found
  local co = coroutine.create(function()
    while not found do
      local line = coroutine.yield("yield")
      if line:match(pattern) then
        found = true
      end
    end
  end)

  -- start
  coroutine.resume(co)

  -- don't kill it, it me finish by itself
  ngx.thread.spawn(function()
    execute(cmd, {
      logger = function(line)
        return coroutine.running(co) and coroutine.resume(co, line)
      end,
      stop_signal = function() if found then return 9 end end,
    })
  end)

  ngx.update_time()
  local s = ngx.now()
  while not found and ngx.now() - s <= timeout do
    ngx.update_time()
    ngx.sleep(0.1)
  end

  return found
end

ffi.cdef [[
  int setenv(const char *name, const char *value, int overwrite);
  int unsetenv(const char *name);
]]

--- Set an environment variable
-- @function setenv
-- @param env (string) name of the environment variable
-- @param value the value to set
-- @return true on success, false otherwise
local function setenv(env, value)
  return ffi.C.setenv(env, value, 1) == 0
end


--- Unset an environment variable
-- @function setenv
-- @param env (string) name of the environment variable
-- @return true on success, false otherwise
local function unsetenv(env)
  return ffi.C.unsetenv(env) == 0
end

local handler = require("busted.outputHandlers.base")()
local current_test_element

local function register_busted_hook()
  local busted = require("busted")

  handler.testStart = function(element, parent)
    current_test_element = element
  end

  busted.subscribe({'test', 'start'}, handler.testStart)
end

local function get_test_descriptor(sanitized, element_override)
  local elem = current_test_element or element_override
  if elem then
    local msg = handler.getFullName(elem)
    local common_prefix = "perf test for Kong "
    if msg:startswith(common_prefix) then
      msg = msg:sub(#common_prefix+1)
    end
    if sanitized then
      msg = msg:gsub("[:/]", "#"):gsub("[ ,]", "_"):gsub("__", "_")
    end
    return msg
  end
end

local function get_test_output_filename()
  return get_test_descriptor(true)
end

local function parse_docker_image_labels(docker_inspect_output)
  local m, err = cjson_safe.decode(docker_inspect_output)
  if err then
    return nil, err
  end

  local labels = m[1].Config.Labels or {}
  labels.version = labels["org.opencontainers.image.version"] or "unknown_version"
  labels.revision = labels["org.opencontainers.image.revision"] or "unknown_revision"
  labels.created = labels["org.opencontainers.image.created"] or "unknown_created"
  return labels
end

local original_lua_package_paths = package.path
local function add_lua_package_paths(d)
  d = d or "."
  local pp = d .. "/?.lua;" ..
       d .. "/?/init.lua;"
  local pl_dir = require("pl.dir")
  local pl_path = require("pl.path")
  if pl_path.isdir(d .. "/plugins-ee") then
    for _, p in ipairs(pl_dir.getdirectories(d .. "/plugins-ee")) do
      pp = pp.. p .. "/?.lua;"..
                p .. "/?/init.lua;"
    end
  end
  package.path = pp .. ";" .. original_lua_package_paths
end

local function restore_lua_package_paths()
  package.path = original_lua_package_paths
end

-- clear certain packages to allow spec.helpers to be re-imported
-- those modules are only needed to run migrations in the "controller"
-- and won't affect kong instances performing tests
local function clear_loaded_package()
  for _, p in ipairs({
    "spec.helpers", "kong.cluster_events",
    "kong.global", "kong.constants",
    "kong.meta", "kong.enterprise_edition.meta",
    "kong.cache", "kong.db", "kong.plugins", "kong.pdk", "kong.enterprise_edition.pdk",
  }) do
    package.loaded[p] = nil
  end
end

local function print_and_save(s, path)
  local shell = require "resty.shell"
  shell.run("mkdir -p output", nil, 0)
  print(s)
  local f = io.open(path or "output/result.txt", "a")
  f:write(s)
  f:write("\n")
  f:close()
end

return {
  execute = execute,
  wait_output = wait_output,
  setenv = setenv,
  unsetenv = unsetenv,
  register_busted_hook = register_busted_hook,
  get_test_descriptor = get_test_descriptor,
  get_test_output_filename = get_test_output_filename,
  parse_docker_image_labels = parse_docker_image_labels,
  add_lua_package_paths = add_lua_package_paths,
  restore_lua_package_paths = restore_lua_package_paths,
  clear_loaded_package = clear_loaded_package,
  print_and_save = print_and_save,
}
