--
-- Licensed to the Apache Software Foundation (ASF) under one or more
-- contributor license agreements.  See the NOTICE file distributed with
-- this work for additional information regarding copyright ownership.
-- The ASF licenses this file to You under the Apache License, Version 2.0
-- (the "License"); you may not use this file except in compliance with
-- the License.  You may obtain a copy of the License at
--
--     http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
local require = require
local core = require("apisix.core")
local get_uri_args = ngx.req.get_uri_args
local route = require("apisix.utils.router")
local plugin = require("apisix.plugin")
local v3_adapter = require("apisix.admin.v3_adapter")
local utils = require("apisix.admin.utils")
local ngx = ngx
local get_method = ngx.req.get_method
local ngx_time = ngx.time
local ngx_timer_at = ngx.timer.at
local ngx_worker_id = ngx.worker.id
local tonumber = tonumber
local tostring = tostring
local str_lower = string.lower
local reload_event = "/apisix/admin/plugins/reload"
local ipairs = ipairs
local error = error
local type = type


local events
local MAX_REQ_BODY = 1024 * 1024 * 1.5      -- 1.5 MiB


local viewer_methods = {
    get = true,
}


local resources = {
    routes          = require("apisix.admin.routes"),
    services        = require("apisix.admin.services"),
    upstreams       = require("apisix.admin.upstreams"),
    consumers       = require("apisix.admin.consumers"),
    credentials     = require("apisix.admin.credentials"),
    schema          = require("apisix.admin.schema"),
    ssls            = require("apisix.admin.ssl"),
    plugins         = require("apisix.admin.plugins"),
    protos          = require("apisix.admin.proto"),
    global_rules    = require("apisix.admin.global_rules"),
    stream_routes   = require("apisix.admin.stream_routes"),
    plugin_metadata = require("apisix.admin.plugin_metadata"),
    plugin_configs  = require("apisix.admin.plugin_config"),
    consumer_groups = require("apisix.admin.consumer_group"),
    secrets         = require("apisix.admin.secrets"),
}


local _M = {version = 0.4}
local router


local function check_token(ctx)
    local local_conf = core.config.local_conf()

    -- check if admin_key is required
    if local_conf.deployment.admin.admin_key_required == false then
        return true
    end

    local admin_key = core.table.try_read_attr(local_conf, "deployment", "admin", "admin_key")
    if not admin_key then
        return true
    end

    local req_token = ctx.var.arg_api_key or ctx.var.http_x_api_key
                      or ctx.var.cookie_x_api_key
    if not req_token then
        return false, "missing apikey"
    end

    local admin
    for i, row in ipairs(admin_key) do
        if req_token == row.key then
            admin = row
            break
        end
    end

    if not admin then
        return false, "wrong apikey"
    end

    if admin.role == "viewer" and
       not viewer_methods[str_lower(get_method())] then
        return false, "invalid method for role viewer"
    end

    return true
end

-- Set the `apictx` variable and check admin api token, if the check fails, the current
-- request will be interrupted and an error response will be returned.
--
-- NOTE: This is a higher wrapper for `check_token` function.
local function set_ctx_and_check_token()
    local api_ctx = {}
    core.ctx.set_vars_meta(api_ctx)
    ngx.ctx.api_ctx = api_ctx

    local ok, err = check_token(api_ctx)
    if not ok then
        core.log.warn("failed to check token: ", err)
        core.response.exit(401, { error_msg = "failed to check token", description = err })
    end
end


local function strip_etcd_resp(data)
    if type(data) == "table"
        and data.header ~= nil
        and data.header.revision ~= nil
        and data.header.raft_term ~= nil
    then
        -- strip etcd data
        data.header = nil
        data.responses = nil
        data.succeeded = nil

        if data.node then
            data.node.createdIndex = nil
            data.node.modifiedIndex = nil
        end

        data.count = nil
        data.more = nil
        data.prev_kvs = nil

        if data.deleted then
            -- We used to treat the type incorrectly. But for compatibility we follow
            -- the existing type.
            data.deleted = tostring(data.deleted)
        end
    end

    return data
end


local function head()
    core.response.exit(200)
end


local function run()
    set_ctx_and_check_token()

    local uri_segs = core.utils.split_uri(ngx.var.uri)
    core.log.info("uri: ", core.json.delay_encode(uri_segs))

    -- /apisix/admin/schema/route
    local seg_res, seg_id = uri_segs[4], uri_segs[5]
    local seg_sub_path = core.table.concat(uri_segs, "/", 6)
    if seg_res == "schema" and seg_id == "plugins" then
        -- /apisix/admin/schema/plugins/limit-count
        seg_res, seg_id = uri_segs[5], uri_segs[6]
        seg_sub_path = core.table.concat(uri_segs, "/", 7)
    end

    if seg_res == "stream_routes" then
        local local_conf = core.config.local_conf()
        if local_conf.apisix.proxy_mode ~= "stream" and
           local_conf.apisix.proxy_mode ~= "http&stream" then
            core.log.warn("stream mode is disabled, can not add any stream ",
                          "routes")
            core.response.exit(400, {error_msg = "stream mode is disabled, " ..
                               "can not add stream routes"})
        end
    end

    if seg_res == "consumers" and #uri_segs >= 6 and uri_segs[6] == "credentials" then
        seg_sub_path = seg_id .. "/" .. seg_sub_path
        seg_res = uri_segs[6]
        seg_id = uri_segs[7]
    end

    local resource = resources[seg_res]
    if not resource then
        core.response.exit(404, {error_msg = "Unsupported resource type: ".. seg_res})
    end

    local method = str_lower(get_method())
    if not resource[method] then
        core.response.exit(404, {error_msg = "not found"})
    end

    local req_body, err = core.request.get_body(MAX_REQ_BODY)
    if err then
        core.log.error("failed to read request body: ", err)
        core.response.exit(400, {error_msg = "invalid request body: " .. err})
    end

    if req_body then
        local data, err = core.json.decode(req_body)
        if err then
            core.log.error("invalid request body: ", req_body, " err: ", err)
            core.response.exit(400, {error_msg = "invalid request body: " .. err,
                                     req_body = req_body})
        end

        req_body = data
    end

    local uri_args = ngx.req.get_uri_args() or {}
    if uri_args.ttl then
        if not tonumber(uri_args.ttl) then
            core.response.exit(400, {error_msg = "invalid argument ttl: "
                                                 .. "should be a number"})
        end
    end

    local code, data
    if seg_res == "schema" or seg_res == "plugins" then
        code, data = resource[method](seg_id, req_body, seg_sub_path, uri_args)
    else
        code, data = resource[method](resource, seg_id, req_body, seg_sub_path, uri_args)
    end

    if code then
        if method == "get" and plugin.enable_data_encryption then
            if seg_res == "consumers" or seg_res == "credentials" then
                utils.decrypt_params(plugin.decrypt_conf, data, core.schema.TYPE_CONSUMER)
            elseif seg_res == "plugin_metadata" then
                utils.decrypt_params(plugin.decrypt_conf, data, core.schema.TYPE_METADATA)
            else
                utils.decrypt_params(plugin.decrypt_conf, data)
            end
        end

        if v3_adapter.enable_v3() then
            core.response.set_header("X-API-VERSION", "v3")
        else
            core.response.set_header("X-API-VERSION", "v2")
        end
        if resource.need_v3_filter then
            data = v3_adapter.filter(data)
        end

        data = strip_etcd_resp(data)

        core.response.exit(code, data)
    end
end


local function get_plugins_list()
    set_ctx_and_check_token()
    local args = get_uri_args()
    local subsystem = args["subsystem"]
    -- If subsystem is passed then it should be either http or stream.
    -- If it is not passed/nil then http will be default.
    subsystem = subsystem or "http"
    if subsystem == "http" or subsystem == "stream" then
        local plugins = resources.plugins.get_plugins_list(subsystem)
        core.response.exit(200, plugins)
    end
    core.response.exit(400,"invalid subsystem passed")
end

-- Handle unsupported request methods for the virtual "reload" plugin
local function unsupported_methods_reload_plugin()
    set_ctx_and_check_token()

    core.response.exit(405, {
        error_msg = "please use PUT method to reload the plugins, "
                    .. get_method() .. " method is not allowed."
    })
end


local function post_reload_plugins()
    set_ctx_and_check_token()

    local success, err = events:post(reload_event, get_method(), ngx_time())
    if not success then
        core.response.exit(503, err)
    end

    core.response.exit(200, "done")
end


local function plugins_eq(old, new)
    local old_set = {}
    for _, p in ipairs(old) do
        old_set[p.name] = p
    end

    local new_set = {}
    for _, p in ipairs(new) do
        new_set[p.name] = p
    end

    return core.table.set_eq(old_set, new_set)
end


local function sync_local_conf_to_etcd(reset)
    local local_conf = core.config.local_conf()

    local plugins = {}
    for _, name in ipairs(local_conf.plugins) do
        core.table.insert(plugins, {
            name = name,
        })
    end

    for _, name in ipairs(local_conf.stream_plugins) do
        core.table.insert(plugins, {
            name = name,
            stream = true,
        })
    end

    if reset then
        local res, err = core.etcd.get("/plugins")
        if not res then
            core.log.error("failed to get current plugins: ", err)
            return
        end

        if res.status == 404 then
            -- nothing need to be reset
            return
        end

        if res.status ~= 200 then
            core.log.error("failed to get current plugins, status: ", res.status)
            return
        end

        local stored_plugins = res.body.node.value
        local revision = res.body.node.modifiedIndex
        if plugins_eq(stored_plugins, plugins) then
            core.log.info("plugins not changed, don't need to reset")
            return
        end

        core.log.warn("sync local conf to etcd")

        local res, err = core.etcd.atomic_set("/plugins", plugins, nil, revision)
        if not res then
            core.log.error("failed to set plugins: ", err)
        end

        return
    end

    core.log.warn("sync local conf to etcd")

    -- need to store all plugins name into one key so that it can be updated atomically
    local res, err = core.etcd.set("/plugins", plugins)
    if not res then
        core.log.error("failed to set plugins: ", err)
    end
end


local function reload_plugins(data, event, source, pid)
    core.log.info("start to hot reload plugins")
    plugin.load()

    if ngx_worker_id() == 0 then
        sync_local_conf_to_etcd()
    end
end


local function schema_validate()
    local uri_segs = core.utils.split_uri(ngx.var.uri)
    core.log.info("uri: ", core.json.delay_encode(uri_segs))

    local seg_res = uri_segs[6]
    local resource = resources[seg_res]
    if not resource then
        core.response.exit(404, {error_msg = "Unsupported resource type: ".. seg_res})
    end

    local req_body, err = core.request.get_body(MAX_REQ_BODY)
    if err then
        core.log.error("failed to read request body: ", err)
        core.response.exit(400, {error_msg = "invalid request body: " .. err})
    end

    if req_body then
        local data, err = core.json.decode(req_body)
        if err then
            core.log.error("invalid request body: ", req_body, " err: ", err)
            core.response.exit(400, {error_msg = "invalid request body: " .. err,
                                     req_body = req_body})
        end

        req_body = data
    end

    local ok, err = core.schema.check(resource.schema, req_body)
    if ok then
        core.response.exit(200)
    end
    core.response.exit(400, {error_msg = err})
end


local uri_route = {
    {
        paths = [[/apisix/admin]],
        methods = {"HEAD"},
        handler = head,
    },
    {
        paths = [[/apisix/admin/*]],
        methods = {"GET", "PUT", "POST", "DELETE", "PATCH"},
        handler = run,
    },
    {
        paths = [[/apisix/admin/plugins/list]],
        methods = {"GET"},
        handler = get_plugins_list,
    },
    {
        paths = [[/apisix/admin/schema/validate/*]],
        methods = {"POST"},
        handler = schema_validate,
    },
    {
        paths = reload_event,
        methods = {"PUT"},
        handler = post_reload_plugins,
    },
    -- Handle methods other than "PUT" on "/plugin/reload" to inform user
    {
        paths = reload_event,
        methods = { "GET", "POST", "DELETE", "PATCH" },
        handler = unsupported_methods_reload_plugin,
    },
}


function _M.init_worker()
    local local_conf = core.config.local_conf()
    if not local_conf.apisix or not local_conf.apisix.enable_admin then
        return
    end

    router = route.new(uri_route)

    -- register reload plugin handler
    events = require("apisix.events")
    events:register(reload_plugins, reload_event, "PUT")

    if ngx_worker_id() == 0 then
        -- check if admin_key is required
        if local_conf.deployment.admin.admin_key_required == false then
            core.log.warn("Admin key is bypassed! ",
                "If you are deploying APISIX in a production environment, ",
                "please enable `admin_key_required` and set a secure admin key!")
        end

        local ok, err = ngx_timer_at(0, function(premature)
            if premature then
                return
            end

            -- try to reset the /plugins to the current configuration in the admin
            sync_local_conf_to_etcd(true)
        end)

        if not ok then
            error("failed to sync local configure to etcd: " .. err)
        end
    end
end


function _M.get()
    return router
end


return _M
