local lpack = require "lua_pack"
local protoc = require "protoc"
local pb = require "pb"
local pl_path = require "pl.path"
local date = require "date"

local bpack = lpack.pack
local bunpack = lpack.unpack

local type = type
local pcall = pcall
local error = error
local tostring = tostring
local ipairs = ipairs
local string_format = string.format
local splitpath = pl_path.splitpath
local abspath = pl_path.abspath
local ngx_log = ngx.log
local ngx_DEBUG = ngx.DEBUG

local epoch = date.epoch()

local _M = {}
local _MT = { __index = _M, }


local function safe_set_type_hook(typ, dec, enc)
  if not pcall(pb.hook, typ) then
    ngx_log(ngx_DEBUG, "no type '", typ, "' defined")
    return
  end

  if not pb.hook(typ) then
    pb.hook(typ, dec)
  end

  if not pb.encode_hook(typ) then
    pb.encode_hook(typ, enc)
  end
end

local function set_hooks()
  pb.option("enable_hooks")
  pb.option("enable_enchooks")

  safe_set_type_hook(".google.protobuf.Timestamp", function (t)
    if type(t) ~= "table" then
      error(string_format("expected table, got (%s)%q", type(t), tostring(t)))
    end

    return date(t.seconds):fmt("${iso}")
  end,
  function (t)
    if type(t) ~= "string" then
      error(string_format(
        "expected time string, got (%s)%q", type(t), tostring(t)))
    end

    local ds = date(t) - epoch
    return {
      seconds = ds:spanseconds(),
      nanos = ds:getticks() * 1000,
    }
  end)
end

function _M.new()
  local protoc_instance = protoc.new()
  -- order by priority
  for _, v in ipairs {
    "/usr/local/kong/include",
    "/usr/local/opt/protobuf/include/", -- homebrew
    "/usr/include",
    "kong/include",
    "spec/fixtures/grpc",
  } do
    protoc_instance:addpath(v)
  end
  protoc_instance.include_imports = true

  return setmetatable({
    protoc_instance = protoc_instance,
  }, _MT)
end

function _M:addpath(path)
  local protoc_instance = self.protoc_instance
  if type(path) == "table" then
    for _, v in ipairs(path) do
      protoc_instance:addpath(v)
    end

  else
    protoc_instance:addpath(path)
  end
end

function _M:get_proto_file(name)
  for _, path in ipairs(self.protoc_instance.paths) do
    local fn = path ~= "" and path .. "/" .. name or name
    local fh, _ = io.open(fn)
    if fh then
      return fh
    end
  end
  return nil
end

local function each_method_recur(protoc_instance, fname, f, recurse)
  local parsed = protoc_instance:parsefile(fname)
  if f then
    if recurse and parsed.dependency then
      if parsed.public_dependency then
        for _, dependency_index in ipairs(parsed.public_dependency) do
          local sub = parsed.dependency[dependency_index + 1]
          each_method_recur(protoc_instance, sub, f, true)
        end
      end
    end

    for _, srvc in ipairs(parsed.service or {}) do
      for _, mthd in ipairs(srvc.method or {}) do
        f(parsed, srvc, mthd)
      end
    end
  end

  return parsed
end

--- loads a .proto file optionally applies a function on each defined method.
function _M:each_method(fname, f, recurse)
  local protoc_instance = self.protoc_instance
  local dir = splitpath(abspath(fname))
  protoc_instance:addpath(dir)
  protoc_instance:loadfile(fname)
  set_hooks()

  return each_method_recur(protoc_instance, fname, f, recurse)
end

--- wraps a binary payload into a grpc stream frame.
function _M.frame(ftype, msg)
  -- byte 0: frame type
  -- byte 1-4: frame size in big endian (could be zero)
  -- byte 5-: frame content
  return bpack("C>I", ftype, #msg) .. msg
end

--- unwraps one frame from a grpc stream.
--- If success, returns `content, rest`.
--- If heading frame isn't complete, returns `nil, body`,
--- try again with more data.
function _M.unframe(body)
  -- must be at least 5 bytes(frame header)
  if not body or #body < 5 then
    return nil, body
  end

  local pos, ftype, sz = bunpack(body, "C>I") -- luacheck: ignore ftype
  local frame_end = pos + sz - 1
  if frame_end > #body then
    return nil, body
  end

  return body:sub(pos, frame_end), body:sub(frame_end + 1)
end

return _M
