local kong_table = require "kong.tools.table"
local cjson = require "cjson"
local pl_pretty = require "pl.pretty"
local tablex = require "pl.tablex"
local app_helpers = require "lapis.application"
local arguments = require "kong.api.arguments"
local Errors = require "kong.db.errors"
local hooks = require "kong.hooks"
local decode_args = require("kong.tools.http").decode_args


local ngx = ngx
local header = ngx.header
local sub = string.sub
local find = string.find
local type = type
local pairs = pairs
local ipairs = ipairs
local concat = table.concat

local get_method = ngx.req.get_method


local _M = {}
local NO_ARRAY_INDEX_MARK = {}


local HTTP_METHODS = {
  ["GET"] = true,
  ["HEAD"] = true,
  ["POST"] = true,
  ["PUT"] = true,
  ["DELETE"] = true,
  ["CONNECT"] = true,
  ["OPTIONS"] = true,
  ["TRACE"] = true,
  ["PATCH"] = true,
}

-- Parses a form value, handling multipart/data values
-- @param `v` The value object
-- @return The parsed value
local function parse_value(v)
  return type(v) == "table" and v.content or v -- Handle multipart
end


-- given a string like "x[1].y", return an array of indices like {"x", 1, "y"}
-- the path parameter is an output-only param. the keys are added to it in order
local function key_to_path(key, path)
  -- try to match an array access like x[1].
  -- the left side of the [] is mandatory
  -- the array index can be omitted (the key will look like x[]).
  -- if that's the case we mark the path entry with a special key
  local left, array_index = key:match("^(.+)%[(%d*)]$")
  if left then
    key_to_path(left, path)
    path[#path + 1] = tonumber(array_index) or NO_ARRAY_INDEX_MARK
    return path
  end

  -- if no match, try a hash access like x.y (both x and y are mandatory)
  -- the left side of the dot is called left and the other side is right
  local left, right = key:match("^(.+)%.(.+)$")
  if left then
    key_to_path(left, path)
    key_to_path(right, path)
    return path
  end

  -- if no match found, append the whole key to the path as a single string
  path[#path + 1] = key
  return path
end

-- when NO_ARRAY_INDEX is encountered, replace it with the length of the node being parsed
local function transform_no_array_index_mark(path_entry, node)
  if path_entry == NO_ARRAY_INDEX_MARK then
    return #node + 1
  end
  return path_entry
end


-- Put nested keys in objects:
-- Normalize dotted keys in objects.
-- Example: {["key.value.sub"]=1234} becomes {key = {value = {sub=1234}}
-- @param `obj` Object to normalize
-- @return `normalized_object`
function _M.normalize_nested_params(obj)
  local new_obj = {}
  local is_array

  for k, v in pairs(obj) do
    is_array = false
    if type(v) == "table" then
      -- normalize arrays since Lapis parses ?key[1]=foo as {["1"]="foo"} instead of {"foo"}
      if kong_table.is_array(v, "lapis") then
        is_array = true
        local arr = {}
        for _, arr_v in pairs(v) do arr[#arr+1] = arr_v end
        v = arr
      else
        v = _M.normalize_nested_params(v) -- recursive call on other table values
      end
    end

    v = parse_value(v)

    -- normalize sub-keys with hash or array accesses
    if type(k) == "string" then
      local path = key_to_path(k, {})
      local path_len = #path
      local node = new_obj
      local prev = new_obj
      local path_entry
      -- create any missing tables when dealing with x.foo[1].y = "bar"
      for i = 1, path_len - 1 do
        path_entry = transform_no_array_index_mark(path[i], node)
        node[path_entry] = node[path_entry] or {}
        prev = node
        node = node[path_entry]
      end

      -- on the last item of the path (the "y" in the example above)
      if path[path_len] == NO_ARRAY_INDEX_MARK and is_array then
        -- edge case: we are assigning an array to a no-array index mark: x[] = {1,2,3}
        -- on this case we backtrack one element (we use `prev` instead of `node`)
        -- and we set it to the array (v)
        -- this edge case is needed because Lapis builds params like that (flatten_params function)
        if type(node) == "table" then
          for i, v in ipairs(v) do
            node[i] = v
          end

        else
          prev[path_entry or k] = v
        end
      elseif type(node) == "table" then
        -- regular case: the last element is similar to the loop iteration.
        -- instead of a table, we set the value (v) on the last element
        node[transform_no_array_index_mark(path[path_len], node)] = v
      end
    else
      new_obj[k] = v -- nothing special with that key, simply attaching the value
    end
  end

  return new_obj
end


-- Remove functions from a schema definition so that
-- cjson can encode the schema.
local schema_to_jsonable
do
  local insert = table.insert
  local ipairs = ipairs
  local next = next

  local fdata_to_jsonable


  local function fields_to_jsonable(fields)
    local out = {}
    for _, field in ipairs(fields) do
      local fname = next(field)
      local fdata = field[fname]
      insert(out, { [fname] = fdata_to_jsonable(fdata, "no") })
    end
    setmetatable(out, cjson.array_mt)
    return out
  end


  -- Convert field data from schemas into something that can be
  -- passed to a JSON encoder.
  -- @tparam table fdata A Lua table with field data
  -- @tparam string is_array A three-state enum: "yes", "no" or "maybe"
  -- @treturn table A JSON-convertible Lua table
  fdata_to_jsonable = function(fdata, is_array)
    local out = {}
    local iter = is_array == "yes" and ipairs or pairs

    for k, v in iter(fdata) do
      if is_array == "maybe" and type(k) ~= "number" then
        is_array = "no"
      end

      if k == "schema" then
        out[k] = schema_to_jsonable(v)

      elseif type(v) == "table" then
        if k == "fields" and fdata.type == "record" then
          out[k] = fields_to_jsonable(v)

        elseif k == "default" and fdata.type == "array" then
          out[k] = fdata_to_jsonable(v, "yes")

        else
          out[k] = fdata_to_jsonable(v, "maybe")
        end

      elseif type(v) == "number" then
        if v ~= v then
          out[k] = "nan"
        elseif v == math.huge then
          out[k] = "inf"
        elseif v == -math.huge then
          out[k] = "-inf"
        else
          out[k] = v
        end

      elseif type(v) ~= "function" then
        out[k] = v
      end
    end
    if is_array == "yes" or is_array == "maybe" then
      setmetatable(out, cjson.array_mt)
    end
    return out
  end


  schema_to_jsonable = function(schema)
    local fields = fields_to_jsonable(schema.fields)
    local entity_checks = fields_to_jsonable(schema.entity_checks or {})
    return { entity_checks = entity_checks, fields = fields,  }
  end
  _M.schema_to_jsonable = schema_to_jsonable
end


local NEEDS_BODY = tablex.readonly({ PUT = 1, POST = 2, PATCH = 3 })
local ACCEPTS_YAML = tablex.readonly({
  ["/config"] = true,
})


function _M.before_filter(self)
  if not NEEDS_BODY[get_method()] then
    return
  end

  local content_type = self.req.headers["content-type"]
  if not content_type then
    local content_length = self.req.headers["content-length"]
    if content_length == "0" then
      return
    end

    if not content_length then
      local _, err = ngx.req.socket()
      if err == "no body" then
        return
      end
    end

  elseif type(content_type) == "table" then
    return kong.response.exit(400, "Multiple Content-Type headers not allowed")

  elseif sub(content_type, 1, 16) == "application/json"
      or sub(content_type, 1, 19) == "multipart/form-data"
      or sub(content_type, 1, 33) == "application/x-www-form-urlencoded"
      or (ACCEPTS_YAML[self.route_name] and
          (sub(content_type, 1,  16) == "application/yaml" or
           sub(content_type, 1,  9)  == "text/yaml"))
  then
    return
  end

  return kong.response.exit(415)
end

function _M.cors_filter(self)
  local allowed_origins = kong.configuration.admin_gui_origin

  local function is_origin_allowed(req_origin)
    for _, allowed_origin in ipairs(allowed_origins) do
      if req_origin == allowed_origin then
        return true
      end
    end
    return false
  end

  local req_origin = self.req.headers["Origin"]

  if allowed_origins and #allowed_origins > 0 then
    if not is_origin_allowed(req_origin) then
      req_origin = allowed_origins[1]
    end
  else
    req_origin = req_origin or "*"
  end

  ngx.header["Access-Control-Allow-Origin"] = req_origin
  ngx.header["Access-Control-Allow-Credentials"] = "true"

  if ngx.req.get_method() == "OPTIONS" then
    local request_allow_headers = self.req.headers["Access-Control-Request-Headers"]
    if request_allow_headers then
      ngx.header["Access-Control-Allow-Headers"] = request_allow_headers
    end
  end
end

local function parse_params(fn)
  return app_helpers.json_params(function(self, ...)
    local content_type = self.req.headers["content-type"]
    local is_json

    if NEEDS_BODY[get_method()] then
      if content_type then
        content_type = content_type:lower()
        is_json = find(content_type, "application/json", 1, true)

        if is_json and not self.json then
          return kong.response.exit(400, { message = "Cannot parse JSON body" })

        elseif find(content_type, "application/x-www-form-urlencode", 1, true) then
          self.params = decode_args(self.params)
        end
      end
    end

    if not is_json then
      self.params = _M.normalize_nested_params(self.params)
    end

    local res, err = fn(self, ...)

    kong.worker_events.poll()

    if err then
      kong.log.err(err)
      return ngx.exit(500)
    end

    if res == nil and ngx.status >= 200 then
      return ngx.exit(0)
    end

    return res
  end)
end


-- new DB
local function new_db_on_error(self)
  local err = self.errors[1]

  if type(err) ~= "table" then
    kong.log.err(err)
    return kong.response.exit(500, { message = "An unexpected error occurred" })
  end

  if err.strategy then
    err.strategy = nil
  end

  local err_code = err.code
  local codes = Errors.codes

  if err_code == codes.SCHEMA_VIOLATION
  or err_code == codes.INVALID_PRIMARY_KEY
  or err_code == codes.FOREIGN_KEY_VIOLATION
  or err_code == codes.INVALID_OFFSET
  or err_code == codes.FOREIGN_KEYS_UNRESOLVED
  then
    return kong.response.exit(400, err)
  end

  if err_code == codes.NOT_FOUND then
    return kong.response.exit(404, err)
  end

  if err_code == codes.OPERATION_UNSUPPORTED then
    kong.log.err(err)
    return kong.response.exit(405, err)
  end

  if err_code == codes.PRIMARY_KEY_VIOLATION
  or err_code == codes.UNIQUE_VIOLATION
  then
    return kong.response.exit(409, err)
  end

  kong.log.err(err)
  return kong.response.exit(500, { message = "An unexpected error occurred" })
end


-- old DAO
local function on_error(self)
  local err = self.errors[1]

  if type(err) ~= "table" then
    kong.log.err(err)
    return kong.response.exit(500, { message = "An unexpected error occurred" })
  end

  if err.name then
    return new_db_on_error(self)
  end

  if err.db then
    kong.log.err(err.message)
    return kong.response.exit(500, { message = "An unexpected error occurred" })
  end

  if err.unique then
    return kong.response.exit(409, err.tbl)
  end

  if err.foreign then
    return kong.response.exit(404, err.tbl or { message = "Not found" })
  end

  return kong.response.exit(400, err.tbl or err.message)
end


local function options_method(methods)
  return function()
    kong.response.exit(204, nil, {
      ["Allow"] = header["Access-Control-Allow-Methods"] or methods,
      ["Access-Control-Allow-Methods"] = header["Access-Control-Allow-Methods"] or methods,
      ["Access-Control-Allow-Headers"] = header["Access-Control-Allow-Headers"] or "Content-Type"
    })
  end
end


local handler_helpers = {
  yield_error = app_helpers.yield_error,
}


function _M.attach_routes(app, routes)
  for route_path, methods in pairs(routes) do
    methods.on_error = methods.on_error or on_error

    local http_methods_array = {}
    local http_methods_count = 0

    for method_name, method_handler in pairs(methods) do
      local wrapped_handler = function(self)
        return method_handler(self, {}, handler_helpers)
      end

      methods[method_name] = parse_params(wrapped_handler)

      if HTTP_METHODS[method_name] then
        http_methods_count = http_methods_count + 1
        http_methods_array[http_methods_count] = method_name
      end
    end

    if not methods["HEAD"] and methods["GET"] then
      methods["HEAD"] = methods["GET"]
      http_methods_count = http_methods_count + 1
      http_methods_array[http_methods_count] = "HEAD"
    end

    if not methods["OPTIONS"] then
      http_methods_count = http_methods_count + 1
      http_methods_array[http_methods_count] = "OPTIONS"
      table.sort(http_methods_array)
      methods["OPTIONS"] = options_method(concat(http_methods_array, ", ", 1, http_methods_count))
    end

    app:match(route_path, route_path, app_helpers.respond_to(methods))

    assert(hooks.run_hook("api:helpers:attach_routes",
      app, route_path, methods))
  end
end


function _M.attach_new_db_routes(app, routes)
  for route_path, definition in pairs(routes) do
    local schema  = definition.schema
    local methods = definition.methods

    methods.on_error = methods.on_error or new_db_on_error

    local http_methods_array = {}
    local http_methods_count = 0

    for method_name, method_handler in pairs(methods) do
      local wrapped_handler = function(self)
        self.args = arguments.load({
          schema  = schema,
          request = self.req,
        })

        return method_handler(self, kong.db, handler_helpers)
      end

      methods[method_name] = parse_params(wrapped_handler)

      if HTTP_METHODS[method_name] then
        http_methods_count = http_methods_count + 1
        http_methods_array[http_methods_count] = method_name
      end
    end

    if not methods["HEAD"] and methods["GET"] then
      methods["HEAD"] = methods["GET"]
      http_methods_count = http_methods_count + 1
      http_methods_array[http_methods_count] = "HEAD"
    end

    if not methods["OPTIONS"] then
      http_methods_count = http_methods_count + 1
      http_methods_array[http_methods_count] = "OPTIONS"
      table.sort(http_methods_array)
      methods["OPTIONS"] = options_method(concat(http_methods_array, ", ", 1, http_methods_count))
    end

    app:match(route_path, route_path, app_helpers.respond_to(methods))

    assert(hooks.run_hook("api:helpers:attach_new_db_routes",
      app, route_path, methods))
  end
end


function _M.default_route(self)
  local path = self.req.parsed_url.path:match("^(.*)/$")

  if path and self.app.router:resolve(path, self) then
    return

  elseif self.app.router:resolve(self.req.parsed_url.path .. "/", self) then
    return
  end

  return self.app.handle_404(self)
end



function _M.handle_404(self)
  return kong.response.exit(404, { message = "Not found" })
end


function _M.handle_error(self, err, trace)
  if err then
    if type(err) ~= "string" then
      err = pl_pretty.write(err)
    end
    if find(err, "don't know how to respond to", nil, true) then
      return kong.response.exit(405, { message = "Method not allowed" })
    end
  end

  ngx.log(ngx.ERR, err, "\n", trace)

  -- We just logged the error so no need to give it to responses and log it
  -- twice
  return kong.response.exit(500, { message = "An unexpected error occurred" })
end


function _M.is_new_db_routes(routes)
  for _, verbs in pairs(routes) do
    if type(verbs) == "table" then -- ignore "before" functions
      return verbs.schema
    end
  end
end


return _M
