--
-- 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 ngx = ngx
local yaml = require("lyaml")
local profile = require("apisix.core.profile")
local util = require("apisix.cli.util")
local schema = require("apisix.cli.schema")
local default_conf = require("apisix.cli.config")
local dkjson = require("dkjson")
local pl_path = require("pl.path")

local pairs = pairs
local type = type
local tonumber = tonumber
local getenv = os.getenv
local str_gmatch = string.gmatch
local str_find = string.find
local str_sub = string.sub
local print = print

local _M = {}
local exported_vars


function _M.get_exported_vars()
    return exported_vars
end


local function is_empty_yaml_line(line)
    return line == '' or str_find(line, '^%s*$') or str_find(line, '^%s*#')
end


local function tab_is_array(t)
    local count = 0
    for k, v in pairs(t) do
        count = count + 1
    end

    return #t == count
end


local function var_sub(val)
    local err
    local var_used = false
    -- we use '${{var}}' because '$var' and '${var}' are taken
    -- by Nginx
    local new_val = val:gsub("%$%{%{%s*([%w_]+[%:%=]?.-)%s*%}%}", function(var)
        local i, j = var:find("%:%=")
        local default
        if i and j then
            default = var:sub(i + 2, #var)
            default = default:gsub('^%s*(.-)%s*$', '%1')
            var = var:sub(1, i - 1)
        end

        local v = getenv(var) or default
        if v then
            if not exported_vars then
                exported_vars = {}
            end

            exported_vars[var] = v
            var_used = true
            return v
        end

        err = "failed to handle configuration: " ..
              "can't find environment variable " .. var
        return ""
    end)
    return new_val, var_used, err
end


-- Substitute env vars in raw text before parsing. YAML parser then infers
-- types naturally: `"${{VAR}}"` stays string, `${{VAR}}` infers from value.
local function resolve_conf_var_in_text(text)
    local new_text, _, err = var_sub(text)
    if err then
        return nil, err
    end
    return new_text
end


local function resolve_conf_var(conf)
    local new_keys = {}
    for key, val in pairs(conf) do
        -- avoid re-iterating the table for already iterated key
        if new_keys[key] then
            goto continue
        end
        -- substitute environment variables from conf keys
        if type(key) == "string" then
            local new_key, _, err = var_sub(key)
            if err then
                return nil, err
            end
            if new_key ~= key then
                new_keys[new_key] = "dummy" -- we only care about checking the key
                conf.key = nil
                conf[new_key] = val
                key = new_key
            end
        end
        if type(val) == "table" then
            local ok, err = resolve_conf_var(val)
            if not ok then
                return nil, err
            end

        elseif type(val) == "string" then
            local new_val, var_used, err = var_sub(val)

            if err then
                return nil, err
            end

            if var_used then
                if tonumber(new_val) ~= nil then
                    new_val = tonumber(new_val)
                elseif new_val == "true" then
                    new_val = true
                elseif new_val == "false" then
                    new_val = false
                end
            end

            conf[key] = new_val
        end
        ::continue::
    end

    return true
end


_M.resolve_conf_var = resolve_conf_var
_M.resolve_conf_var_in_text = resolve_conf_var_in_text


local function replace_by_reserved_env_vars(conf)
    -- TODO: support more reserved environment variables
    local v = getenv("APISIX_DEPLOYMENT_ETCD_HOST")
    if v and conf["deployment"] and conf["deployment"]["etcd"] then
        local val, _, err = dkjson.decode(v)
        if err or not val then
            print("parse ${APISIX_DEPLOYMENT_ETCD_HOST} failed, error:", err)
            return
        end

        conf["deployment"]["etcd"]["host"] = val
    end
end


local function path_is_multi_type(path, type_val)
    if str_sub(path, 1, 14) == "nginx_config->" and
            (type_val == "number" or type_val == "string") then
        return true
    end

    if path == "apisix->node_listen" and type_val == "number" then
        return true
    end

    if path == "apisix->data_encryption->keyring" then
        return true
    end

    return false
end


local function merge_conf(base, new_tab, ppath)
    ppath = ppath or ""

    for key, val in pairs(new_tab) do
        if type(val) == "table" then
            if val == yaml.null then
                base[key] = nil

            elseif tab_is_array(val) then
                base[key] = val

            else
                if base[key] == nil then
                    base[key] = {}
                end

                local ok, err = merge_conf(
                    base[key],
                    val,
                    ppath == "" and key or ppath .. "->" .. key
                )
                if not ok then
                    return nil, err
                end
            end
        else
            local type_val = type(val)

            if base[key] == nil then
                base[key] = val
            elseif type(base[key]) ~= type_val then
                local path = ppath == "" and key or ppath .. "->" .. key

                if path_is_multi_type(path, type_val) then
                    base[key] = val
                else
                    return nil, "failed to merge, path[" .. path ..  "] expect: " ..
                                type(base[key]) .. ", but got: " .. type_val
                end
            else
                base[key] = val
            end
        end
    end

    return base
end


function _M.read_yaml_conf(apisix_home)
    if apisix_home then
        profile.apisix_home = apisix_home .. "/"
    end

    local local_conf_path = profile:customized_yaml_path()
    if not local_conf_path then
        local_conf_path = profile:yaml_path("config")
    end
    local user_conf_yaml, err = util.read_file(local_conf_path)
    if not user_conf_yaml then
        return nil, err
    end

    local is_empty_file = true
    for line in str_gmatch(user_conf_yaml .. '\n', '(.-)\r?\n') do
        if not is_empty_yaml_line(line) then
            is_empty_file = false
            break
        end
    end

    if not is_empty_file then
        local user_conf = yaml.load(user_conf_yaml)
        if not user_conf then
            return nil, "invalid config.yaml file"
        end

        local ok, err = resolve_conf_var(user_conf)
        if not ok then
            return nil, err
        end

        ok, err = merge_conf(default_conf, user_conf)
        if not ok then
            return nil, err
        end
    end

    -- fill the default value by the schema
    local ok, err = schema.validate(default_conf)
    if not ok then
        return nil, err
    end
    if default_conf.deployment then
        default_conf.deployment.config_provider = "etcd"
        if default_conf.deployment.role == "traditional" then
            default_conf.etcd = default_conf.deployment.etcd
            if default_conf.deployment.role_traditional.config_provider == "yaml" then
                default_conf.deployment.config_provider = "yaml"
            end

        elseif default_conf.deployment.role == "control_plane" then
            default_conf.etcd = default_conf.deployment.etcd
            default_conf.apisix.enable_admin = true

        elseif default_conf.deployment.role == "data_plane" then
            default_conf.etcd = default_conf.deployment.etcd
            if default_conf.deployment.role_data_plane.config_provider == "yaml" then
                default_conf.deployment.config_provider = "yaml"
            elseif default_conf.deployment.role_data_plane.config_provider == "json" then
                default_conf.deployment.config_provider = "json"
            elseif default_conf.deployment.role_data_plane.config_provider == "xds" then
                default_conf.deployment.config_provider = "xds"
            end
            default_conf.apisix.enable_admin = false
        end
    end

    --- using `not ngx` to check whether the current execution environment is apisix cli module,
    --- because it is only necessary to parse and validate `apisix.yaml` in apisix cli.
    if default_conf.deployment.config_provider == "yaml" and not ngx then
        local apisix_conf_path = profile:yaml_path("apisix")
        local apisix_conf_yaml, _ = util.read_file(apisix_conf_path)
        if apisix_conf_yaml then
            -- Pre-parse substitution: `"${{VAR}}"` stays string, `${{VAR}}` infers type from value.
            local resolved, err = resolve_conf_var_in_text(apisix_conf_yaml)
            if not resolved then
                return nil, err
            end
            local apisix_conf = yaml.load(resolved)
            if not apisix_conf then
                return nil, "invalid apisix.yaml file"
            end
        end
    end

    local apisix_ssl = default_conf.apisix.ssl
    if apisix_ssl and apisix_ssl.ssl_trusted_certificate then
        -- default value is set to "system" during schema validation
        if apisix_ssl.ssl_trusted_certificate == "system" then
            local trusted_certs_path, err = util.get_system_trusted_certs_filepath()
            if not trusted_certs_path then
                util.die(err)
            end

            apisix_ssl.ssl_trusted_certificate = trusted_certs_path
        else
            -- During validation, the path is relative to PWD
            -- When Nginx starts, the path is relative to conf
            -- Therefore we need to check the absolute version instead
            local cert_path = pl_path.abspath(apisix_ssl.ssl_trusted_certificate)
            if not pl_path.exists(cert_path) then
                util.die("certificate path", cert_path, "doesn't exist\n")
            end
            apisix_ssl.ssl_trusted_certificate = cert_path
        end
    end

    replace_by_reserved_env_vars(default_conf)

    return default_conf
end


return _M
