--
-- 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 core          = require("apisix.core")
local config_util   = require("apisix.core.config_util")
local pb            = require("pb")
local protoc        = require("protoc")
local pcall         = pcall
local ipairs        = ipairs
local decode_base64 = ngx.decode_base64


local protos
local lrucache_proto = core.lrucache.new({
    ttl = 300, count = 100
})

local proto_fake_file = "filename for loaded"

local function compile_proto_text(content)
    protoc.reload()
    local _p  = protoc.new()
    -- the loaded proto won't appears in _p.loaded without a file name after lua-protobuf=0.3.2,
    -- which means _p.loaded after _p:load(content) is always empty, so we can pass a fake file
    -- name to keep the code below unchanged, or we can create our own load function with returning
    -- the loaded DescriptorProto table additionally, see more details in
    -- https://github.com/apache/apisix/pull/4368
    local ok, res = pcall(_p.load, _p, content, proto_fake_file)
    if not ok then
        return nil, res
    end

    if not res or not _p.loaded then
        return nil, "failed to load proto content"
    end

    local compiled = _p.loaded

    local index = {}
    for _, s in ipairs(compiled[proto_fake_file].service or {}) do
        local method_index = {}
        for _, m in ipairs(s.method) do
            method_index[m.name] = m
        end

        index[compiled[proto_fake_file].package .. '.' .. s.name] = method_index
    end

    compiled[proto_fake_file].index = index

    return compiled
end


local function compile_proto_bin(content)
    content = decode_base64(content)
    if not content then
        return nil
    end

    -- pb.load doesn't return err
    local ok = pb.load(content)
    if not ok then
        return nil
    end

    local files = pb.decode("google.protobuf.FileDescriptorSet", content).file
    local index = {}
    for _, f in ipairs(files) do
        for _, s in ipairs(f.service or {}) do
            local method_index = {}
            for _, m in ipairs(s.method) do
                method_index[m.name] = m
            end

            index[f.package .. '.' .. s.name] = method_index
        end
    end

    local compiled = {}
    compiled[proto_fake_file] = {}
    compiled[proto_fake_file].index = index
    return compiled
end


local function compile_proto(content)
    -- clear pb state
    local old_pb_state = pb.state(nil)

    local compiled, err = compile_proto_text(content)
    if not compiled then
        compiled = compile_proto_bin(content)
        if not compiled then
            return nil, err
        end
    end

    -- fetch pb state
    compiled.pb_state = pb.state(old_pb_state)
    return compiled
end


local _M = {
    version = 0.1,
    compile_proto = compile_proto,
    proto_fake_file = proto_fake_file
}

local function create_proto_obj(proto_id)
    if protos.values == nil then
        return nil
    end

    local content
    for _, proto in config_util.iterate_values(protos.values) do
        if proto_id == proto.value.id then
            content = proto.value.content
            break
        end
    end

    if not content then
        return nil, "failed to find proto by id: " .. proto_id
    end

    return compile_proto(content)
end


function _M.fetch(proto_id)
    return lrucache_proto(proto_id, protos.conf_version,
                          create_proto_obj, proto_id)
end


function _M.protos()
    if not protos then
        return nil, nil
    end

    return protos.values, protos.conf_version
end


local grpc_status_proto = [[
    syntax = "proto3";

    package grpc_status;

    message Any {
      // A URL/resource name that uniquely identifies the type of the serialized
      // protocol buffer message. This string must contain at least
      // one "/" character. The last segment of the URL's path must represent
      // the fully qualified name of the type (as in
      // `path/google.protobuf.Duration`). The name should be in a canonical form
      // (e.g., leading "." is not accepted).
      //
      // In practice, teams usually precompile into the binary all types that they
      // expect it to use in the context of Any. However, for URLs which use the
      // scheme `http`, `https`, or no scheme, one can optionally set up a type
      // server that maps type URLs to message definitions as follows:
      //
      // * If no scheme is provided, `https` is assumed.
      // * An HTTP GET on the URL must yield a [google.protobuf.Type][]
      //   value in binary format, or produce an error.
      // * Applications are allowed to cache lookup results based on the
      //   URL, or have them precompiled into a binary to avoid any
      //   lookup. Therefore, binary compatibility needs to be preserved
      //   on changes to types. (Use versioned type names to manage
      //   breaking changes.)
      //
      // Note: this functionality is not currently available in the official
      // protobuf release, and it is not used for type URLs beginning with
      // type.googleapis.com.
      //
      // Schemes other than `http`, `https` (or the empty scheme) might be
      // used with implementation specific semantics.
      //
      string type_url = 1;

      // Must be a valid serialized protocol buffer of the above specified type.
      bytes value = 2;
    }

    // The `Status` type defines a logical error model that is suitable for
    // different programming environments, including REST APIs and RPC APIs. It is
    // used by [gRPC](https://github.com/grpc). Each `Status` message contains
    // three pieces of data: error code, error message, and error details.
    //
    // You can find out more about this error model and how to work with it in the
    // [API Design Guide](https://cloud.google.com/apis/design/errors).
    message ErrorStatus {
        // The status code, which should be an enum value of [google.rpc.Code][google.rpc.Code].
        int32 code = 1;

        // A developer-facing error message, which should be in English. Any
        // user-facing error message should be localized and sent in the
        // [google.rpc.Status.details][google.rpc.Status.details] field, or localized by the client.
        string message = 2;

        // A list of messages that carry the error details.  There is a common set of
        // message types for APIs to use.
        repeated Any details = 3;
    }
]]


local status_pb_state
local function init_status_pb_state()
    if not status_pb_state then
        -- clear current pb state
        local old_pb_state = pb.state(nil)

        -- initialize protoc compiler
        protoc.reload()
        local status_protoc = protoc.new()
        -- do not use loadfile here, it can not load the proto file when using a relative address
        -- after luarocks install apisix
        local ok, err = status_protoc:load(grpc_status_proto, "grpc_status.proto")
        if not ok then
            status_protoc:reset()
            pb.state(old_pb_state)
            return "failed to load grpc status protocol: " .. err
        end

        status_pb_state = pb.state(old_pb_state)
    end
end


function _M.fetch_status_pb_state()
    return status_pb_state
end


function _M.init()
    local err
    protos, err = core.config.new("/protos", {
        automatic = true,
        item_schema = core.schema.proto
    })
    if not protos then
        core.log.error("failed to create etcd instance for fetching protos: ",
                       err)
        return
    end

    if not status_pb_state then
        err = init_status_pb_state()
        if err then
            core.log.error("failed to init grpc status proto: ",
                            err)
            return
        end
    end
end

function _M.destroy()
    if protos then
        protos:close()
    end
end

return _M
