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


local lrucache = require("resty.lrucache")
local tb_new = require("table.new")
local utils = require("kong.router.utils")
local transform = require("kong.router.transform")
local rat = require("kong.tools.request_aware_table")
local yield = require("kong.tools.yield").yield


local type = type
local assert = assert
local setmetatable = setmetatable
local pairs = pairs
local ipairs = ipairs
local next = next
local max = math.max


local ngx           = ngx
local header        = ngx.header
local var           = ngx.var
local ngx_log       = ngx.log
local get_phase     = ngx.get_phase
local ngx_ERR       = ngx.ERR


local check_select_params  = utils.check_select_params
local get_service_info     = utils.get_service_info
local route_match_stat     = utils.route_match_stat
local split_host_port      = transform.split_host_port
local split_routes_and_services_by_path = transform.split_routes_and_services_by_path


local DEFAULT_MATCH_LRUCACHE_SIZE = utils.DEFAULT_MATCH_LRUCACHE_SIZE


local is_http = ngx.config.subsystem == "http"


local get_header
if is_http then
  get_header = require("kong.tools.http").get_header
end


local get_atc_context
local get_atc_router
local get_atc_fields
do
  local schema = require("resty.router.schema")
  local context = require("resty.router.context")
  local router = require("resty.router.router")
  local fields = require("kong.router.fields")

  local function generate_schema(fields)
    local s = schema.new()

    for t, f in pairs(fields) do
      for _, v in ipairs(f) do
        assert(s:add_field(v, t))
      end
    end

    return s
  end

  -- used by validation
  local HTTP_SCHEMA   = generate_schema(fields.HTTP_FIELDS)
  local STREAM_SCHEMA = generate_schema(fields.STREAM_FIELDS)

  -- used by running router
  local CACHED_SCHEMA = is_http and HTTP_SCHEMA or STREAM_SCHEMA

  get_atc_context = function()
    return context.new(CACHED_SCHEMA)
  end

  get_atc_router = function(routes_n)
    return router.new(CACHED_SCHEMA, routes_n)
  end

  get_atc_fields = function(inst)
    return fields.new(inst:get_fields())
  end

  local protocol_to_schema = {
    http  = HTTP_SCHEMA,
    https = HTTP_SCHEMA,
    grpc  = HTTP_SCHEMA,
    grpcs = HTTP_SCHEMA,

    tcp   = STREAM_SCHEMA,
    udp   = STREAM_SCHEMA,
    tls   = STREAM_SCHEMA,

    tls_passthrough = STREAM_SCHEMA,
  }

  -- for db schema validation
  function _M.schema(protocols)
    return assert(protocol_to_schema[protocols[1]])
  end

  -- for unit testing
  function _M._set_ngx(mock_ngx)
    if type(mock_ngx) ~= "table" then
      return
    end

    if mock_ngx.header then
      header = mock_ngx.header
    end

    if mock_ngx.var then
      var = mock_ngx.var
    end

    if mock_ngx.log then
      ngx_log = mock_ngx.log
    end

    get_header = function(key)
      local mock_headers = mock_ngx.headers or {}
      local mock_var = mock_ngx.var or {}
      return mock_headers[key] or mock_var["http_" .. key]
    end

    fields._set_ngx(mock_ngx)
  end
end


local function add_atc_matcher(inst, route, route_id,
                               get_exp_and_priority,
                               remove_existing)

  local exp, priority = get_exp_and_priority(route)

  if not exp then
    return nil, "could not find expression, route: " .. route_id
  end

  if remove_existing then
    assert(inst:remove_matcher(route_id))
  end

  local ok, err = inst:add_matcher(priority, route_id, exp)
  if not ok then
    return nil, "could not add route: " .. route_id .. ", err: " .. err
  end

  return true
end


local function new_from_scratch(routes, get_exp_and_priority)
  local phase = get_phase()

  local routes_n = #routes

  local inst = get_atc_router(routes_n)

  local routes_t   = tb_new(0, routes_n)
  local services_t = tb_new(0, routes_n)

  local new_updated_at = 0

  for _, r in ipairs(routes) do
    local route = r.route
    local route_id = route.id

    if not route_id then
      return nil, "could not categorize route"
    end

    routes_t[route_id] = route
    services_t[route_id] = r.service

    local ok, err = add_atc_matcher(inst, route, route_id,
                                    get_exp_and_priority, false)
    if ok then
      new_updated_at = max(new_updated_at, route.updated_at or 0)

    else
      ngx_log(ngx_ERR, err)

      routes_t[route_id] = nil
      services_t[route_id] = nil
    end

    yield(true, phase)
  end

  return setmetatable({
      context = get_atc_context(),
      fields = get_atc_fields(inst),
      router = inst,
      routes = routes_t,
      services = services_t,
      updated_at = new_updated_at,
      rebuilding = false,
    }, _MT)
end


local function new_from_previous(routes, get_exp_and_priority, old_router)
  if old_router.rebuilding then
    return nil, "concurrent incremental router rebuild without mutex, this is unsafe"
  end

  old_router.rebuilding = true

  local phase = get_phase()

  local inst = old_router.router
  local old_routes = old_router.routes
  local old_services = old_router.services

  local updated_at = old_router.updated_at
  local new_updated_at = 0

  -- create or update routes
  for _, r in ipairs(routes) do
    local route = r.route
    local route_id = route.id

    if not route_id then
      old_router.rebuilding = false
      return nil, "could not categorize route"
    end

    local old_route = old_routes[route_id]
    local route_updated_at = route.updated_at

    route.seen = true

    old_routes[route_id] = route
    old_services[route_id] = r.service

    local ok = true
    local err

    if not old_route then
      -- route is new
      ok, err = add_atc_matcher(inst, route, route_id, get_exp_and_priority, false)

    elseif route_updated_at >= updated_at or
           route_updated_at ~= old_route.updated_at then

      -- route is modified (within a sec)
      ok, err = add_atc_matcher(inst, route, route_id, get_exp_and_priority, true)
    end

    if ok then
      new_updated_at = max(new_updated_at, route_updated_at)

    else
      ngx_log(ngx_ERR, err)

      old_routes[route_id] = nil
      old_services[route_id] = nil
    end

    yield(true, phase)
  end

  -- remove routes
  for id, r in pairs(old_routes) do
    if r.seen  then
      r.seen = nil

    else
      assert(inst:remove_matcher(id))

      old_routes[id] = nil
      old_services[id] = nil
    end

    yield(true, phase)
  end

  old_router.fields = get_atc_fields(inst)
  old_router.updated_at = new_updated_at
  old_router.rebuilding = false

  return old_router
end


function _M.new(routes, cache, cache_neg, old_router, get_exp_and_priority)
  -- routes argument is a table with [route] and [service]
  if type(routes) ~= "table" then
    return error("expected arg #1 routes to be a table")
  end

  if is_http then
    routes = split_routes_and_services_by_path(routes)
  end

  local router, err

  if not old_router then
    router, err = new_from_scratch(routes, get_exp_and_priority)

  else
    router, err = new_from_previous(routes, get_exp_and_priority, old_router)
  end

  if not router then
    return nil, err
  end

  router.cache = cache or lrucache.new(DEFAULT_MATCH_LRUCACHE_SIZE)
  router.cache_neg = cache_neg or lrucache.new(DEFAULT_MATCH_LRUCACHE_SIZE)

  return router
end


local CACHE_PARAMS


if is_http then


local sanitize_uri_postfix = utils.sanitize_uri_postfix
local strip_uri_args       = utils.strip_uri_args
local add_debug_headers    = utils.add_debug_headers
local get_upstream_uri_v0  = utils.get_upstream_uri_v0


local function set_upstream_uri(req_uri, match_t)
  local matched_route = match_t.route

  local request_prefix = match_t.prefix or "/"
  local request_postfix = sanitize_uri_postfix(req_uri:sub(#request_prefix + 1))

  local upstream_base = match_t.upstream_url_t.path or "/"

  match_t.upstream_uri = get_upstream_uri_v0(matched_route, request_postfix,
                                             req_uri, upstream_base)
end


-- captures has the form { [0] = full_path, [1] = capture1, [2] = capture2, ..., ["named1"] = named1, ... }
-- and captures[0] will be the full matched path
-- this function tests if there are captures other than the full path
-- by checking if there are 2 or more than 2 keys
local function has_capture(captures)
  if not captures then
    return false
  end
  local next_i = next(captures)
  return next_i and next(captures, next_i) ~= nil
end


function _M:matching(params)
  local req_uri = params.uri
  local req_host = params.host

  check_select_params(params.method, req_uri, req_host, params.scheme,
                      params.src_ip, params.src_port,
                      params.dst_ip, params.dst_port,
                      params.sni, params.headers, params.queries)

  local host, port = split_host_port(req_host)

  params.host = host
  params.port = port

  self.context:reset()

  local c, err = self.fields:fill_atc_context(self.context, params)

  if not c then
    return nil, err
  end

  local matched = self.router:execute(c)
  if not matched then
    return nil
  end

  local uuid, matched_path, captures = c:get_result("http.path")

  local service = self.services[uuid]
  local matched_route = self.routes[uuid].original_route or self.routes[uuid]

  local service_protocol, _,  --service_type
        service_host, service_port,
        service_hostname_type, service_path = get_service_info(service)

  local request_prefix = matched_route.strip_path and matched_path or nil

  return {
    route           = matched_route,
    service         = service,
    prefix          = request_prefix,
    matches = {
      uri_captures = has_capture(captures) and captures or nil,
    },
    upstream_url_t = {
      type = service_hostname_type,
      host = service_host,
      port = service_port,
      path = service_path,
    },
    upstream_scheme = service_protocol,
    upstream_host   = matched_route.preserve_host and req_host or nil,
  }
end


-- only for unit-testing
function _M:select(req_method, req_uri, req_host, req_scheme,
                   src_ip, src_port,
                   dst_ip, dst_port,
                   sni, req_headers, req_queries)

  local params = {
    method  = req_method,
    uri     = req_uri,
    host    = req_host,
    scheme  = req_scheme,
    sni     = sni,
    headers = req_headers,
    queries = req_queries,

    src_ip   = src_ip,
    src_port = src_port,
    dst_ip   = dst_ip,
    dst_port = dst_port,
  }

  return self:matching(params)
end


function _M:exec(ctx)
  local fields = self.fields

  local req_uri = ctx and ctx.request_uri or var.request_uri
  local req_host = get_header("host", ctx)

  req_uri = strip_uri_args(req_uri)

  -- cache key calculation

  if not CACHE_PARAMS then
    CACHE_PARAMS = rat.new()
  end

  CACHE_PARAMS:clear()

  CACHE_PARAMS.uri  = req_uri
  CACHE_PARAMS.host = req_host

  local cache_key = fields:get_cache_key(CACHE_PARAMS)

  -- cache lookup

  local match_t = self.cache:get(cache_key)
  if not match_t then
    if self.cache_neg:get(cache_key) then
      route_match_stat(ctx, "neg")
      return nil
    end

    CACHE_PARAMS.scheme = ctx and ctx.scheme or var.scheme

    local err
    match_t, err = self:matching(CACHE_PARAMS)
    if not match_t then
      if err then
        ngx_log(ngx_ERR, "router returned an error: ", err,
                         ", 404 Not Found will be returned for the current request")
      end

      self.cache_neg:set(cache_key, true)
      return nil
    end

    self.cache:set(cache_key, match_t)

  else
    route_match_stat(ctx, "pos")

    -- preserve_host header logic, modify cache result
    if match_t.route.preserve_host then
      match_t.upstream_host = req_host
    end
  end

  -- found a match

  -- update upstream_uri in cache result
  set_upstream_uri(req_uri, match_t)

  -- debug HTTP request header logic
  add_debug_headers(ctx, header, match_t)

  return match_t
end


else  -- is stream subsystem


function _M:matching(params)
  local sni = params.sni

  check_select_params(nil, nil, nil, params.scheme,
                      params.src_ip, params.src_port,
                      params.dst_ip, params.dst_port,
                      sni)

  self.context:reset()

  local c, err = self.fields:fill_atc_context(self.context, params)
  if not c then
    return nil, err
  end

  local matched = self.router:execute(c)
  if not matched then
    return nil
  end

  local uuid = c:get_result()

  local service = self.services[uuid]
  local matched_route = self.routes[uuid]

  local service_protocol, _,  --service_type
        service_host, service_port,
        service_hostname_type = get_service_info(service)

  return {
    route          = matched_route,
    service        = service,
    upstream_url_t = {
      type = service_hostname_type,
      host = service_host,
      port = service_port,
    },
    upstream_scheme = service_protocol,
    upstream_host = matched_route.preserve_host and sni or nil,
  }
end


-- only for unit-testing
function _M:select(_, _, _, scheme,
                   src_ip, src_port,
                   dst_ip, dst_port,
                   sni)

  local params = {
    scheme    = scheme,
    src_ip    = src_ip,
    src_port  = src_port,
    dst_ip    = dst_ip,
    dst_port  = dst_port,
    sni       = sni,
  }

  return self:matching(params)
end


function _M:exec(ctx)
  local fields = self.fields

  -- cache key calculation

  if not CACHE_PARAMS then
    CACHE_PARAMS = rat.new()
  end

  CACHE_PARAMS:clear()

  local cache_key = fields:get_cache_key(CACHE_PARAMS, ctx)

  -- cache lookup

  local match_t = self.cache:get(cache_key)
  if match_t then
    route_match_stat(ctx, "pos")

  else
    if self.cache_neg:get(cache_key) then
      route_match_stat(ctx, "neg")
      return nil
    end

    local scheme
    if var.protocol == "UDP" then
      scheme = "udp"

    else
      scheme = CACHE_PARAMS.sni and "tls" or "tcp"
    end

    CACHE_PARAMS.scheme = scheme

    local err
    match_t, err = self:matching(CACHE_PARAMS)
    if not match_t then
      if err then
        ngx_log(ngx_ERR, "router returned an error: ", err)
      end

      self.cache_neg:set(cache_key, true)
      return nil
    end

    self.cache:set(cache_key, match_t)
  end

  -- preserve_host logic, modify cache result
  if match_t.route.preserve_host and match_t.upstream_host == nil then
    match_t.upstream_host = fields:get_value("tls.sni", CACHE_PARAMS)
  end

  return match_t
end


end   -- if is_http


return _M
