--
-- 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 type         = type
local pairs        = pairs
local ipairs       = ipairs
local str_lower    = string.lower
local ngx          = ngx
local get_method   = ngx.req.get_method
local shared_dict  = ngx.shared["standalone-config"]
local table_insert = table.insert
local table_new    = require("table.new")
local yaml         = require("lyaml")
local events       = require("apisix.events")
local core         = require("apisix.core")
local config_yaml  = require("apisix.core.config_yaml")
local check_schema = require("apisix.core.schema").check
local tbl_deepcopy = require("apisix.core.table").deepcopy

local EVENT_UPDATE = "standalone-api-configuration-update"

local _M = {}

local function check_duplicate(item, key, id_set)
    local identifier, identifier_type
    if key == "consumers" then
        identifier = item.id or item.username
        identifier_type = item.id and "credential id" or "username"
    else
        identifier = item.id
        identifier_type = "id"
    end

    if id_set[identifier] then
        return true, "found duplicate " .. identifier_type .. " " .. identifier .. " in " .. key
    end
    id_set[identifier] = true
    return false
end

local function get_config()
    local config = shared_dict:get("config")
    if not config then
        return nil, "not found"
    end

    local err
    config, err = core.json.decode(config)
    if not config then
        return nil, "failed to decode json: " .. err
    end
    return config
end


local function update_and_broadcast_config(apisix_yaml)
    local raw, err = core.json.encode(apisix_yaml)
    if not raw then
        core.log.error("failed to encode json: ", err)
        return nil, "failed to encode json: " .. err
    end

    if shared_dict then
        -- the worker that handles Admin API calls is responsible for writing the shared dict
        local ok, err = shared_dict:set("config", raw)
        if not ok then
            return nil, "failed to save config to shared dict: " .. err
        end
        core.log.info("standalone config updated: ", raw)
    else
        core.log.crit(config_yaml.ERR_NO_SHARED_DICT)
    end
    return events:post(EVENT_UPDATE, EVENT_UPDATE)
end


local function update(ctx)
    local content_type = core.request.header(nil, "content-type") or "application/json"

    -- read the request body
    local req_body, err = core.request.get_body()
    if err then
        return core.response.exit(400, {error_msg = "invalid request body: " .. err})
    end

    if not req_body or #req_body <= 0 then
        return core.response.exit(400, {error_msg = "invalid request body: empty request body"})
    end

    -- parse the request body
    local data
    if core.string.has_prefix(content_type, "application/yaml") then
        data = yaml.load(req_body, { all = false })
        if not data or type(data) ~= "table" then
            err = "invalid yaml request body"
        end
    else
        data, err = core.json.decode(req_body)
    end
    if err then
        core.log.error("invalid request body: ", req_body, " err: ", err)
        core.response.exit(400, {error_msg = "invalid request body: " .. err})
    end
    req_body = data

    local config, err = get_config()
    if not config then
        if err ~= "not found" then
            core.log.error("failed to get config from shared dict: ", err)
            return core.response.exit(500, {
                error_msg = "failed to get config from shared dict: " .. err
            })
        end
    end

    -- check input by jsonschema
    local apisix_yaml = {}
    local created_objs = config_yaml.fetch_all_created_obj()

    for key, obj in pairs(created_objs) do
        local conf_version_key = obj.conf_version_key
        local conf_version = config and config[conf_version_key] or obj.conf_version
        local items = req_body[key]
        local new_conf_version = req_body[conf_version_key]
        if not new_conf_version then
            new_conf_version = conf_version + 1
        else
            if type(new_conf_version) ~= "number" then
                return core.response.exit(400, {
                    error_msg = conf_version_key .. " must be a number",
                })
            end
            if new_conf_version < conf_version then
                return core.response.exit(400, {
                    error_msg = conf_version_key ..
                        " must be greater than or equal to (" .. conf_version .. ")",
                })
            end
        end

        apisix_yaml[conf_version_key] = new_conf_version
        if new_conf_version == conf_version then
            apisix_yaml[key] = config and config[key]
        elseif items and #items > 0 then
            apisix_yaml[key] = table_new(#items, 0)
            local item_schema = obj.item_schema
            local item_checker = obj.checker
            local id_set = {}

            for index, item in ipairs(items) do
                local item_temp = tbl_deepcopy(item)
                local valid, err
                -- need to recover to 0-based subscript
                local err_prefix = "invalid " .. key .. " at index " .. (index - 1) .. ", err: "
                if item_schema then
                    valid, err = check_schema(obj.item_schema, item_temp)
                    if not valid then
                        core.log.error(err_prefix, err)
                        core.response.exit(400, {error_msg = err_prefix .. err})
                    end
                end
                if item_checker then
                    local item_checker_key
                    if item.id then
                        -- credential need to check key
                        item_checker_key = "/" .. key .. "/" .. item_temp.id
                    end
                    valid, err = item_checker(item_temp, item_checker_key)
                    if not valid then
                        core.log.error(err_prefix, err)
                        core.response.exit(400, {error_msg = err_prefix .. err})
                    end
                end
                -- prevent updating resource with the same ID
                -- (e.g., service ID or other resource IDs) in a single request
                local duplicated, err = check_duplicate(item, key, id_set)
                if duplicated then
                    core.log.error(err)
                    core.response.exit(400, { error_msg = err })
                end

                table_insert(apisix_yaml[key], item)
            end
        end
    end

    local ok, err = update_and_broadcast_config(apisix_yaml)
    if not ok then
        core.response.exit(500, err)
    end

    return core.response.exit(202)
end


local function get(ctx)
    local accept = core.request.header(nil, "accept") or "application/json"
    local want_yaml_resp = core.string.has_prefix(accept, "application/yaml")

    local config, err = get_config()
    if not config then
        if err ~= "not found" then
            core.log.error("failed to get config from shared dict: ", err)
            return core.response.exit(500, {
                error_msg = "failed to get config from shared dict: " .. err
            })
        end
        config = {}
        local created_objs = config_yaml.fetch_all_created_obj()
        for _, obj in pairs(created_objs) do
            config[obj.conf_version_key] = obj.conf_version
        end
    end

    local resp, err
    if want_yaml_resp then
        core.response.set_header("Content-Type", "application/yaml")
        resp = yaml.dump({ config })
        if not resp then
            err = "failed to encode yaml"
        end

        -- remove the first line "---" and the last line "..."
        -- because the yaml.dump() will add them for multiple documents
        local m = ngx.re.match(resp, [[^---\s*([\s\S]*?)\s*\.\.\.\s*$]], "jo")
        if m and m[1] then
            resp = m[1]
        end
    else
        core.response.set_header("Content-Type", "application/json")
        resp, err = core.json.encode(config, true)
        if not resp then
            err = "failed to encode json: " .. err
        end
    end

    if not resp then
        return core.response.exit(500, {error_msg = err})
    end
    return core.response.exit(200, resp)
end


function _M.run()
    local ctx = ngx.ctx.api_ctx
    local method = str_lower(get_method())
    if method == "put" then
        return update(ctx)
    else
        return get(ctx)
    end
end


local patch_schema
do
    local resource_schema = {
        "proto",
        "global_rule",
        "route",
        "service",
        "upstream",
        "consumer",
        "consumer_group",
        "credential",
        "ssl",
        "plugin_config",
    }
    local function attach_modifiedIndex_schema(name)
        local schema = core.schema[name]
        if not schema then
            core.log.error("schema for ", name, " not found")
            return
        end
        if schema.properties and not schema.properties.modifiedIndex then
            schema.properties.modifiedIndex = {
                type = "integer",
            }
        end
    end

    local function patch_credential_schema()
        local credential_schema = core.schema["credential"]
        if credential_schema and credential_schema.properties then
            credential_schema.properties.id = {
                type = "string",
                minLength = 15,
                maxLength = 128,
                pattern = [[^[a-zA-Z0-9-_]+/credentials/[a-zA-Z0-9-_.]+$]],
            }
        end
    end

    function patch_schema()
        -- attach modifiedIndex schema to all resource schemas
        for _, name in ipairs(resource_schema) do
            attach_modifiedIndex_schema(name)
        end
        -- patch credential schema
        patch_credential_schema()
    end
end


function _M.init_worker()
    local function update_config()
        local config, err = shared_dict:get("config")
        if not config then
            core.log.error("failed to get config from shared dict: ", err)
            return
        end

        config, err = core.json.decode(config)
        if not config then
            core.log.error("failed to decode json: ", err)
            return
        end
        config_yaml._update_config(config)
    end
    events:register(update_config, EVENT_UPDATE, EVENT_UPDATE)

    patch_schema()
end


return _M
