--
-- 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 local_conf         = require("apisix.core.config_local").local_conf()
local core               = require("apisix.core")
local core_sleep         = require("apisix.core.utils").sleep
local resty_consul       = require('resty.consul')
local cjson              = require('cjson')
local http               = require('resty.http')
local util               = require("apisix.cli.util")
local ipairs             = ipairs
local error              = error
local ngx                = ngx
local unpack             = unpack
local ngx_re_match       = ngx.re.match
local tonumber           = tonumber
local pairs              = pairs
local ipairs             = ipairs
local ngx_timer_at       = ngx.timer.at
local ngx_timer_every    = ngx.timer.every
local log                = core.log
local ngx_decode_base64  = ngx.decode_base64
local json_delay_encode  = core.json.delay_encode
local cjson_null         = cjson.null

local applications = core.table.new(0, 5)
local default_service
local default_weight
local default_prefix_rule
local skip_keys_map = core.table.new(0, 1)
local dump_params

local events
local events_list
local consul_apps

local _M = {
    version = 0.3,
}


local function discovery_consul_callback(data, event, source, pid)
    applications = data
    log.notice("update local variable application, event is: ", event,
        "source: ", source, "server pid:", pid,
        ", application: ", core.json.encode(applications, true))
end


function _M.all_nodes()
    return applications
end


function _M.nodes(service_name)
    if not applications then
        log.error("application is nil, failed to fetch nodes for : ", service_name)
        return
    end

    local resp_list = applications[service_name]

    if not resp_list then
        log.error("fetch nodes failed by ", service_name, ", return default service")
        return default_service and {default_service}
    end

    log.info("process id: ", ngx.worker.id(), ", applications[", service_name, "] = ",
        json_delay_encode(resp_list, true))

    return resp_list
end


local function parse_instance(node, server_name_prefix)
    local key = node.Key

    if key == cjson_null or not key or #key == 0 then
        log.error("consul_key_empty, server_name_prefix: ", server_name_prefix,
            ", node: ", json_delay_encode(node, true))
        return false
    end

    local result = ngx_re_match(key, default_prefix_rule, "jo")
    if not result then
        log.error("server name parse error, server_name_prefix: ", server_name_prefix,
            ", node: ", json_delay_encode(node, true))
        return false
    end

    local sn, host, port = result[1], result[2], result[3]

    -- if exist, skip special kesy
    if sn and skip_keys_map[sn] then
        return false
    end

    -- base64 value   = "IHsid2VpZ2h0IjogMTIwLCAibWF4X2ZhaWxzIjogMiwgImZhaWxfdGltZW91dCI6IDJ9"
    -- ori    value   = "{"weight": 120, "max_fails": 2, "fail_timeout": 2}"
    local metadataBase64 = node.Value
    if metadataBase64 == cjson_null or not metadataBase64 or #metadataBase64 == 0 then
        log.error("error: consul_value_empty, server_name_prefix: ", server_name_prefix,
            ", node: ", json_delay_encode(node, true))
        return false
    end

    local metadata, err = core.json.decode(ngx_decode_base64(metadataBase64))
    if err then
        log.error("invalid upstream value, server_name_prefix: ", server_name_prefix,
            ",err: ", err, ", node: ", json_delay_encode(node, true))
        return false
    elseif metadata.check_status == false or metadata.check_status == "false" then
        log.error("server node unhealthy, server_name_prefix: ", server_name_prefix,
            ", node: ", json_delay_encode(node, true))
        return false
    end

    return true, host, tonumber(port), metadata, sn
end


local function update_application(server_name_prefix, data)
    local sn
    local up_apps = core.table.new(0, #data)
    local weight = default_weight

    for _, node in ipairs(data) do
        local succ, ip, port, metadata, server_name = parse_instance(node, server_name_prefix)
        if succ then
            sn = server_name_prefix .. server_name
            local nodes = up_apps[sn]
            if not nodes then
                nodes = core.table.new(1, 0)
                up_apps[sn] = nodes
            end
            core.table.insert(nodes, {
                host = ip,
                port = port,
                weight = metadata and metadata.weight or weight,
            })
        end
    end

    -- clean old unused data
    local old_apps = consul_apps[server_name_prefix] or {}
    for k, _ in pairs(old_apps) do
        applications[k] = nil
    end
    core.table.clear(old_apps)

    for k, v in pairs(up_apps) do
        applications[k] = v
    end
    consul_apps[server_name_prefix] = up_apps

    log.info("update applications: ", core.json.encode(applications))
end


local function read_dump_srvs()
    local data, err = util.read_file(dump_params.path)
    if not data then
        log.notice("read dump file get error: ", err)
        return
    end

    log.info("read dump file: ", data)
    data = util.trim(data)
    if #data == 0 then
        log.error("dump file is empty")
        return
    end

    local entity, err  = core.json.decode(data)
    if not entity then
        log.error("decoded dump data got error: ", err, ", file content: ", data)
        return
    end

    if not entity.services or not entity.last_update then
        log.warn("decoded dump data miss fields, file content: ", data)
        return
    end

    local now_time = ngx.time()
    log.info("dump file last_update: ", entity.last_update, ", dump_params.expire: ",
        dump_params.expire, ", now_time: ", now_time)
    if dump_params.expire ~= 0  and (entity.last_update + dump_params.expire) < now_time then
       log.warn("dump file: ", dump_params.path, " had expired, ignored it")
       return
    end

    applications = entity.services
    log.info("load dump file into memory success")
end


local function write_dump_srvs()
    local entity = {
        services = applications,
        last_update = ngx.time(),
        expire = dump_params.expire, -- later need handle it
    }
    local data = core.json.encode(entity)
    local succ, err =  util.write_file(dump_params.path, data)
    if not succ then
        log.error("write dump into file got error: ", err)
    end
end


local function show_dump_file()
    if not dump_params then
        return 503, "dump params is nil"
    end

    local data, err = util.read_file(dump_params.path)
    if not data then
        return 503, err
    end

    return 200, data
end


function _M.connect(premature, consul_server, retry_delay)
    if premature then
        return
    end

    local consul_client = resty_consul:new({
        host = consul_server.host,
        port = consul_server.port,
        connect_timeout = consul_server.connect_timeout,
        read_timeout = consul_server.read_timeout,
        default_args = consul_server.default_args,
    })

    log.info("consul_server: ", json_delay_encode(consul_server, true))
    local result, err = consul_client:get(consul_server.consul_key)
    local error_info = (err ~= nil and err)
            or ((result ~= nil and result.status ~= 200)
            and result.status)
    if error_info then
        log.error("connect consul: ", consul_server.server_name_key,
            " by key: ", consul_server.consul_key,
            ", got result: ", json_delay_encode(result, true),
            ", with error: ", error_info)

        if not retry_delay then
            retry_delay = 1
        else
            retry_delay = retry_delay * 4
        end

        log.warn("retry connecting consul after ", retry_delay, " seconds")
        core_sleep(retry_delay)

        goto ERR
    end

    log.info("connect consul: ", consul_server.server_name_key,
        ", result status: ", result.status,
        ", result.headers.index: ", result.headers['X-Consul-Index'],
        ", result body: ", json_delay_encode(result.body))

    -- if current index different last index then update application
    if consul_server.index ~= result.headers['X-Consul-Index'] then
        consul_server.index = result.headers['X-Consul-Index']
        -- only long connect type use index
        if consul_server.keepalive then
            consul_server.default_args.index = result.headers['X-Consul-Index']
        end

        -- decode body, decode json, update application, error handling
        if result.body and #result.body ~= 0 then
            log.notice("server_name: ", consul_server.server_name_key,
                ", header: ", core.json.encode(result.headers, true),
                ", body: ", core.json.encode(result.body, true))

            update_application(consul_server.server_name_key, result.body)
            --update events
            local ok, err = events:post(events_list._source, events_list.updating, applications)
            if not ok then
                log.error("post_event failure with ", events_list._source,
                    ", update application error: ", err)
            end

            if dump_params then
                ngx_timer_at(0, write_dump_srvs)
            end
        end
    end

    :: ERR ::
    local keepalive = consul_server.keepalive
    if keepalive then
        local ok, err = ngx_timer_at(0, _M.connect, consul_server, retry_delay)
        if not ok then
            log.error("create ngx_timer_at got error: ", err)
            return
        end
    end
end


local function format_consul_params(consul_conf)
    local consul_server_list = core.table.new(0, #consul_conf.servers)
    local args = {
        token = consul_conf.token,
        recurse = true
    }

    if consul_conf.keepalive then
        args.wait = consul_conf.timeout.wait --blocked wait!=0; unblocked by wait=0
        args.index = 0
    end

    for _, v in pairs(consul_conf.servers) do
        local scheme, host, port, path = unpack(http.parse_uri(nil, v))
        if scheme ~= "http" then
            return nil, "only support consul http schema address, eg: http://address:port"
        elseif path ~= "/" or core.string.has_suffix(v, '/') then
            return nil, "invalid consul server address, the valid format: http://address:port"
        end

        core.table.insert(consul_server_list, {
            host = host,
            port = port,
            connect_timeout = consul_conf.timeout.connect,
            read_timeout = consul_conf.timeout.read,
            consul_key = "/kv/" .. consul_conf.prefix,
            server_name_key = v .. "/v1/kv/",
            weight = consul_conf.weight,
            keepalive = consul_conf.keepalive,
            default_args = args,
            index = 0,
            fetch_interval = consul_conf.fetch_interval -- fetch interval to next connect consul
        })
    end

    return consul_server_list
end


function _M.init_worker()
    local consul_conf = local_conf.discovery.consul_kv

    if consul_conf.dump then
      local dump = consul_conf.dump
      dump_params = dump

      if dump.load_on_init then
          read_dump_srvs()
      end
    end

    events = require("apisix.events")
    events_list = events:event_list(
        "discovery_consul_update_application",
        "updating"
    )

    if 0 ~= ngx.worker.id() then
        events:register(discovery_consul_callback, events_list._source, events_list.updating)
        return
    end

    log.notice("consul_conf: ", core.json.encode(consul_conf))
    default_weight = consul_conf.weight
    -- set default service, used when the server node cannot be found
    if consul_conf.default_service then
        default_service = consul_conf.default_service
        default_service.weight = default_weight
    end
    default_prefix_rule = "(" .. consul_conf.prefix .. "/.*/)([a-zA-Z0-9.]+):([0-9]+)"
    log.info("default params, default_weight: ", default_weight,
            ", default_prefix_rule: ", default_prefix_rule)
    if consul_conf.skip_keys then
        skip_keys_map = core.table.new(0, #consul_conf.skip_keys)
        for _, v in ipairs(consul_conf.skip_keys) do
            skip_keys_map[v] = true
        end
    end

    local consul_servers_list, err = format_consul_params(consul_conf)
    if err then
        error(err)
        return
    end
    log.info("consul_server_list: ", core.json.encode(consul_servers_list))

    consul_apps = core.table.new(0, 1)
    -- success or failure
    for _, server in ipairs(consul_servers_list) do
        local ok, err = ngx_timer_at(0, _M.connect, server)
        if not ok then
            error("create consul_kv got error: " .. err)
            return
        end

        if server.keepalive == false then
            ngx_timer_every(server.fetch_interval, _M.connect, server)
        end
    end
end


function _M.dump_data()
    return {config = local_conf.discovery.consul_kv, services = applications}
end


function _M.control_api()
    return {
        {
            methods = {"GET"},
            uris = {"/show_dump_file"},
            handler = show_dump_file,
        }
    }
end


return _M
