--- A schema for validating schemas
-- @module kong.db.schema.metaschema

local Schema = require "kong.db.schema"
local json_lib = require "kong.db.schema.json"
local constants = require "kong.constants"


local setmetatable = setmetatable
local assert = assert
local insert = table.insert
local pairs = pairs
local find = string.find
local type = type
local next = next
local keys = require("pl.tablex").keys
local values = require("pl.tablex").values
local sub = string.sub
local fmt = string.format


local match_list = {
  type = "array",
  elements = {
    type = "record",
    fields = {
      { pattern = { type = "string", required = true } },
      { err = { type = "string" } },
    }
  }
}


local match_any_list = {
  type = "record",
  fields = {
    { patterns = {
        type = "array",
        elements = { type = "string" },
        required = true
    } },
    { err = { type = "string" } },
  }
}

-- Field attributes which match a validator function in the Schema class
local validators = {
  { between = { type = "array", elements = { type = "number" }, len_eq = 2 }, },
  { eq = { type = "any" }, },
  { ne = { type = "any" }, },
  { gt = { type = "number" }, },
  { len_eq = { type = "integer" }, },
  { len_min = { type = "integer" }, },
  { len_max = { type = "integer" }, },
  { match = { type = "string" }, },
  { not_match = { type = "string" }, },
  { match_all = match_list },
  { match_none = match_list },
  { match_any = match_any_list },
  { starts_with = { type = "string" }, },
  { one_of = { type = "array", elements = { type = "any" } }, },
  { not_one_of = { type = "array", elements = { type = "any" } }, },
  { contains = { type = "any" }, },
  { is_regex = { type = "boolean" }, },
  { timestamp = { type = "boolean" }, },
  { uuid = { type = "boolean" }, },
  { custom_validator = { type = "function" }, },
  { mutually_exclusive_subsets = { type = "array", elements = { type = "array", elements = { type = "string" } } } },
}

-- JSON schema is supported in two different methods:
--
-- * inline: the JSON schema is defined in the field itself
-- * dynamic/reference: the JSON schema is stored in the database
--
-- Inline schemas have the JSON schema definied statically within
-- the typedef's `json_schema.inline` field. Example:
--
-- ```lua
-- local field = {
--   type = "json",
--   json_schema = {
--     inline = {
--       type = "object",
--       properties = {
--         foo = { type = "string" },
--       },
--     },
--   }
-- }
--
-- local record = {
--   type = "record",
--   fields = {
--     { name = { type = "string" } },
--     { config = field },
--   },
-- }
--
-- ```
--
-- Fields with dynamic schemas function similarly to Lua subschemas, wherein
-- the contents of the input are used to generate a string key that is used
-- to lookup the schema from the schema storage. Example:
--
-- ```lua
-- local record = {
--   type = "record",
--   fields = {
--     { name = { type = "string" } },
--     { config = {
--         type = "json",
--         json_schema = {
--           namespace = "my-record-type",
--           parent_subschema_key = "name",
--           optional = true,
--         },
--       },
--     },
--   },
-- }
-- ```
--
-- In this case, an input value of `{ name = "foo", config = "foo config" }`
-- will cause the validation engine to lookup a schema by the name of
-- `my-record-type/foo`. The `optional` field determines what will happen if
-- the schema does not exist. When `optional` is `false`, a missing schema
-- means that input validation will fail. When `optional` is `true`, the input
-- is always accepted.
--
-- Schemas which use this dynamic reference format can also optionally supply
-- a default inline schema, which will be evaluated when the dynamic schema
-- does not exist:
--
-- ```lua
-- local record = {
--   type = "record",
--   fields = {
--     { name = { type = "string" } },
--     { config = {
--         type = "json",
--         json_schema = {
--           namespace = "my-record-type",
--           parent_subschema_key = "name",
--           default = {
--             { type = { "string", "null" } },
--           },
--         },
--       },
--     },
--   },
-- }
-- ```
--
local json_metaschema = {
  type = "record",
  fields = {
    { namespace = { type = "string", one_of = values(constants.SCHEMA_NAMESPACES), }, },
    { parent_subschema_key = { type = "string" }, },
    { optional = { type = "boolean", }, },
    { inline = { type = "any", custom_validator = json_lib.validate_schema, }, },
    { default = { type = "any", custom_validator = json_lib.validate_schema, }, },
  },
  entity_checks = {
    { at_least_one_of = { "inline", "namespace", "parent_subschema_key" }, },
    { mutually_required = { "namespace", "parent_subschema_key" }, },
    { mutually_exclusive_sets = {
        set1 = { "inline" },
        set2 = { "namespace", "parent_subschema_key", "optional" },
      },
    },
  },
}


-- Other field attributes, that do not correspond to validators
local field_schema = {
  { type = { type = "string", one_of = keys(Schema.valid_types), required = true }, },
  { required = { type = "boolean" }, },
  { reference = { type = "string" }, },
  { description = { type = "string", len_min = 10, len_max = 500}, },
  { examples = { type = "array", elements = { type = "any" } } },
  { auto = { type = "boolean" }, },
  { unique = { type = "boolean" }, },
  { unique_across_ws = { type = "boolean" }, },
  { on_delete = { type = "string", one_of = { "restrict", "cascade", "null" } }, },
  { default = { type = "self" }, },
  { abstract = { type = "boolean" }, },
  { generate_admin_api = { type = "boolean" }, },
  { immutable = { type = "boolean" }, },
  { err = { type = "string" } },
  { encrypted = { type = "boolean" }, },
  { referenceable = { type = "boolean" }, },
  { json_schema = json_metaschema },
  -- Transient attribute: used to mark a field as a non-db column
  { transient = { type = "boolean" }, },
  -- Deprecation attribute: used to mark a field as deprecated
  -- Results in `message` and `removal_in_version` to be printed in a warning
  -- (via kong.deprecation) when the field is used.
  -- If `old_default` is not set, the warning message is always printed.
  -- If `old_default` is set, the warning message is only printed when the
  -- field's value is different from the value of `old_default`.
  { deprecation = {
    type = "record",
    fields = {
      { message = { type = "string", required = true } },
      { removal_in_version = { type = "string", required = true } },
      { old_default = { type = "any", required = false } },
      { replaced_with = { type = "array", required = false,
          elements = { type = "record",
            required = false,
            fields = {
              { path = { type = "array", len_min = 1, required = true, elements = { type = "string"}} },
              { reverse_mapping_function = { type = "function", required = false }}
            },
          }
      } },
    },
  } },
}


for i = 1, #validators do
  insert(field_schema, validators[i])
end


-- Most of the above are optional
for i = 1, #field_schema do
  local field = field_schema[i]
  local data = field[next(field)]
  data.nilable = not data.required
end


local field_entity_checks = {
  -- if 'unique_across_ws' is set, then 'unique' must be set too
  {
    conditional = {
      if_field = "unique_across_ws", if_match = { eq = true },
      then_field = "unique", then_match = { eq = true, required = true },
    }
  },
}


local fields_array = {
  type = "array",
  elements = {
    type = "map",
    keys = { type = "string" },
    values = { type = "record", fields = field_schema, entity_checks = field_entity_checks },
    required = true,
    len_eq = 1,
  },
}


local transformations_array = {
  type = "array",
  nilable = true,
  elements = {
    type = "record",
    fields = {
      {
        input = {
          type = "array",
          required = false,
          elements = {
            type = "string"
          },
        },
      },
      {
        needs = {
          type = "array",
          required = false,
          elements = {
            type = "string"
          },
        }
      },
      {
        on_write = {
          type = "function",
          required = false,
        },
      },
      {
        on_read = {
          type = "function",
          required = false,
        },
      },
    },
    entity_checks = {
      {
        at_least_one_of = {
          "on_write",
          "on_read",
        },
      },
    },
  },
}


-- Recursive field attributes
insert(field_schema, { elements = { type = "record", fields = field_schema } })
insert(field_schema, { keys     = { type = "record", fields = field_schema } })
insert(field_schema, { values   = { type = "record", fields = field_schema } })
insert(field_schema, { fields   = fields_array })


local conditional_validators = {
  { required = { type = "boolean" } },
  { elements = { type = "record", fields = field_schema } },
  { keys     = { type = "record", fields = field_schema } },
  { values   = { type = "record", fields = field_schema } },
}
for i = 1, #validators do
  insert(conditional_validators, validators[i])
end


local entity_checkers = {
  { at_least_one_of = { type = "array", elements = { type = "string" } } },
  { conditional_at_least_one_of = {
      type = "record",
      fields = {
        { if_field = { type = "string" } },
        { if_match = { type = "record", fields = conditional_validators } },
        { then_at_least_one_of = { type = "array", elements = { type = "string" } } },
        { then_err = { type = "string" } },
        { else_match = { type = "record", fields = conditional_validators } },
        { else_then_at_least_one_of = { type = "array", elements = { type = "string" } } },
        { else_then_err = { type = "string" } },
      },
    },
  },
  { only_one_of     = { type = "array", elements = { type = "string" } } },
  { distinct        = { type = "array", elements = { type = "string" } }, },
  { conditional     = {
      type = "record",
      fields = {
        { if_field = { type = "string" } },
        { if_match = { type = "record", fields = conditional_validators } },
        { then_field = { type = "string" } },
        { then_match = { type = "record", fields = conditional_validators } },
        { then_err = { type = "string" } },
      },
    },
  },
  { custom_entity_check = {
      type = "record",
      fields = {
        { field_sources = { type = "array", elements = { type = "string" } } },
        { fn = { type = "function" } },
        { run_with_missing_fields = { type = "boolean" } },
        { run_with_invalid_fields = { type = "boolean" } },
      }
    }
  },
  { mutually_required = { type = "array", elements = { type = "string" } } },
  { mutually_exclusive = { type = "array", elements = { type = "string" } } },
  { mutually_exclusive_sets = {
      type = "record",
      fields = {
        { set1 = {type = "array", elements = {type = "string"} } },
        { set2 = {type = "array", elements = {type = "string"} } },
      }
    }
  },
}


local entity_check_names = {}


for i = 1, #entity_checkers do
  local field = entity_checkers[i]
  local name = next(field)
  --field[name].nilable = true
  insert(entity_check_names, name)
end


local entity_checks_schema = {
  type = "array",
  elements = {
    type = "record",
    fields = entity_checkers,
    entity_checks = {
      { only_one_of = keys(Schema.entity_checkers) }
    }
  },
  nilable = true,
}


local shorthand_fields_array = {
  type = "array",
  elements = {
    type = "map",
    keys = { type = "string" },
    -- values are defined below after field_schema definition is complete
    required = true,
    len_eq = 1,
  },
  nilable = true,
}


insert(field_schema, { entity_checks = entity_checks_schema })
insert(field_schema, { shorthand_fields = shorthand_fields_array })


local meta_errors = {
  ATTRIBUTE = "field of type '%s' cannot have attribute '%s'",
  REQUIRED = "field of type '%s' must declare '%s'",
  TABLE = "'%s' must be a table",
  BOOLEAN = "'%s' must be a boolean",
  TYPE = "missing type declaration",
  FIELD_EMPTY = "field entry table is empty",
  FIELDS_ARRAY = "each entry in fields must be a sub-table",
  FIELDS_KEY = "each key in fields must be a string",
  ENDPOINT_KEY = "value must be a field name",
  CACHE_KEY = "values must be field names",
  CACHE_KEY_UNIQUE = "a field used as a single cache key must be unique",
  TTL_RESERVED = "ttl is a reserved field name when ttl is enabled",
  SUBSCHEMA_KEY = "value must be a field name",
  SUBSCHEMA_KEY_TYPE = "must be a string or set field",
  JSON_PARENT_KEY = "value must be a field name of the parent schema",
  JSON_PARENT_KEY_TYPE = "value must be a string field of the parent schema",
}


local required_attributes = {
  array = { "elements" },
  set = { "elements" },
  map = { "keys", "values" },
  record = { "fields" },
  json = { "json_schema" },
}


local attribute_types = {
  between = {
    ["integer"] = true,
    ["number"] = true,
  },
  len_eq = {
    ["array"]  = true,
    ["set"]    = true,
    ["string"] = true,
    ["map"]    = true,
  },
  match = {
    ["string"] = true,
  },
  one_of = {
    ["string"] = true,
    ["number"] = true,
    ["integer"] = true,
  },
  contains = {
    ["array"] = true,
    ["set"]   = true,
  },
  is_regex = {
    ["string"] = true,
  },
  timestamp = {
    ["number"] = true,
    ["integer"] = true,
  },
  uuid = {
    ["string"] = true,
  },
  unique = {
    ["string"] = true,
    ["number"] = true,
    ["integer"] = true,
    ["foreign"] = true,
  },
  unique_across_ws = {
    ["string"] = true,
    ["number"] = true,
    ["integer"] = true,
    ["foreign"] = true,
  },
  abstract = {
    ["string"] = true,
    ["number"] = true,
    ["integer"] = true,
    ["record"] = true,
    ["array"] = true,
    ["set"] = true,
    ["map"] = true,
  },
  json_schema = {
    ["json"] = true,
  },
  transient = {
    ["string"] = true,
    ["number"] = true,
    ["integer"] = true,
    ["array"] = true,
    ["set"] = true,
    ["boolean"] = true,
  },
}


local nested_attributes = {
  ["elements" ] = true,
  ["keys" ] = true,
  ["values" ] = true,
}


local check_field


local function has_schema_field(schema, name)
  if schema == nil then
    return false
  end

  local fields = schema.fields
  local fields_count = #fields

  local dot = find(name, ".", 1, true)
  if not dot then
    for i = 1, fields_count do
      local field = fields[i]
      local k = next(field)
      if k == name then
        return true
      end
    end

    return false
  end

  local hd, tl = sub(name, 1, dot - 1), sub(name, dot + 1)
  for i = 1, fields_count do
    local field = fields[i]
    local k = next(field)
    if k == hd then
      if field[hd] and field[hd].type == "foreign" then
        -- metaschema has no access to foreign schemas
        -- so we just trust the developer of the schema.

        return true
      end

      return has_schema_field(field[hd], tl)
    end
  end

  return false
end

local check_fields = function(schema, errors)
  local transformations = schema.transformations
  if transformations then
    for i = 1, #transformations do
      local transformation = transformations[i]
      if transformation.input then
        for j = 1, #transformation.input do
          local input = transformation.input[j]
          if not has_schema_field(schema, input) then
            errors.transformations = errors.transformations or {}
            errors.transformations.input = errors.transformations.input or {}
            errors.transformations.input[i] = errors.transformations.input[i] or {}
            errors.transformations.input[i][j] = fmt("invalid field name: %s", input)
          end
        end
      end

      if transformation.needs then
        for j = 1, #transformation.needs do
          local need = transformation.needs[j]
          if not has_schema_field(schema, need) then
            errors.transformations = errors.transformations or {}
            errors.transformations.needs = errors.transformations.needs or {}
            errors.transformations.needs[i] = errors.transformations.needs[i] or {}
            errors.transformations.needs[i][j] = fmt("invalid field name: %s", need)
          end
        end
      end
    end
  end

  for i = 1, #schema.fields do
    local item = schema.fields[i]
    if type(item) ~= "table" then
      errors["fields"] = meta_errors.FIELDS_ARRAY
      break
    end
    local k = next(item)
    if not k then
      errors["fields"] = meta_errors.FIELD_EMPTY
      break
    end
    local field = item[k]
    if type(field) == "table" then
      check_field(k, field, errors, schema)
    else
      errors[k] = meta_errors.TABLE:format(k)
    end
  end
  if next(errors) then
    return nil, errors
  end
  return true
end


check_field = function(k, field, errors, parent_schema)
  if not field.type then
    errors[k] = meta_errors.TYPE
    return nil
  end
  if required_attributes[field.type] then
    local req_attrs = required_attributes[field.type]
    if field.abstract and field.type == "record" then
      req_attrs = {}
    end
    for i = 1, #req_attrs do
      local required = req_attrs[i]
      if not field[required] then
        errors[k] = meta_errors.REQUIRED:format(field.type, required)
      end
    end
  end
  for attr, _ in pairs(field) do
    if attribute_types[attr] and not attribute_types[attr][field.type] then
      errors[k] = meta_errors.ATTRIBUTE:format(field.type, attr)
    end
  end
  for name, _ in pairs(nested_attributes) do
    if field[name] then
      if type(field[name]) == "table" then
        check_field(k, field[name], errors, field)
      else
        errors[k] = meta_errors.TABLE:format(name)
      end
    end
  end

  if field.type == "json"
    and field.json_schema
    and field.json_schema.parent_subschema_key
  then
    local parent_subschema_key = field.json_schema.parent_subschema_key
    local found = false

    for i = 1, #parent_schema.fields do
      local item = parent_schema.fields[i]
      local parent_field_name = next(item)
      local parent_field = item[parent_field_name]

      if parent_subschema_key == parent_field_name then
        if parent_field.type ~= "string" then
          errors[k] = errors[k] or {}
          errors[k].json_schema = {
            parent_subschema_key = meta_errors.JSON_PARENT_KEY_TYPE
          }
        end
        found = true
        break
      end
    end

    if not found then
      errors[k] = errors[k] or {}
      errors[k].json_schema = {
        parent_subschema_key = meta_errors.JSON_PARENT_KEY
      }
      return
    end
  end

  if field.fields then
    return check_fields(field, errors)
  end
end


-- Build a variant of the field_schema, adding a 'func' attribute
-- and restricting the set of valid types.
local function make_shorthand_field_schema()
  local shorthand_field_schema = {}
  for k, v in pairs(field_schema) do
    shorthand_field_schema[k] = v
  end

  -- do not accept complex/recursive types
  -- which require additional schema processing as shorthands
  local invalid_as_shorthand = {
    record = true,
    foreign = true,
    ["function"] = true,
  }

  local shorthand_field_types = {}
  for k in pairs(Schema.valid_types) do
    if not invalid_as_shorthand[k] then
      insert(shorthand_field_types, k)
    end
  end

  assert(next(shorthand_field_schema[1]) == "type")
  shorthand_field_schema[1] = { type = { type = "string", one_of = shorthand_field_types, required = true }, }

  insert(shorthand_field_schema, { func = { type = "function", required = true } })
  insert(shorthand_field_schema, { translate_backwards = { type = "array", elements = { type = "string" }, required = false } })
  return shorthand_field_schema
end


shorthand_fields_array.elements.values = {
  type = "record",
  fields = make_shorthand_field_schema(),
  entity_checks = field_entity_checks
}


local MetaSchema = Schema.new({
  name = "metaschema",
  fields = {
    {
      name = {
        type = "string",
        required = true
      },
    },
    {
      primary_key = {
        type = "array",
        elements = { type = "string" },
        required = true,
      },
    },
    {
      workspaceable = {
        type = "boolean",
        nilable = true
      },
    },
    {
      endpoint_key = {
        type = "string",
        nilable = true,
      },
    },
    {
      cache_key = {
        type = "array",
        elements = {
          type = "string",
        },
        nilable = true,
      },
    },
    {
      ttl = {
        type = "boolean",
        nilable = true,
      }
    },
    {
      db_export = {
        type = "boolean",
        nilable = true,
        default = true,
      }
    },
    {
      subschema_key = {
        type = "string",
        nilable = true,
      },
    },
    {
      subschema_error = {
        type = "string",
        nilable = true,
      },
    },
    {
      generate_admin_api = {
        type = "boolean",
        nilable = true,
        default = true,
      },
    },
    {
      admin_api_name = {
        type = "string",
        nilable = true,
      },
    },
    {
      table_name = {
        type = "string",
        nilable = true,
      },
    },
    {
      admin_api_nested_name = {
        type = "string",
        nilable = true,
      },
    },
    {
      fields = fields_array,
    },
    {
      entity_checks = entity_checks_schema,
    },
    {
      shorthand_fields = shorthand_fields_array,
    },
    {
      transformations = transformations_array,
    },
    {
      check = {
        type = "function",
        nilable = true,
      },
    },
    {
      dao = {
        type = "string",
        nilable = true
      },
    },
  },

  check = function(schema)
    local errors = {}
    local fields = schema.fields

    if not fields then
      errors["fields"] = meta_errors.TABLE:format("fields")
      return nil, errors
    end

    if not schema.table_name then
      schema.table_name = schema.name
    end

    if schema.endpoint_key then
      local found = false
      for i = 1, #fields do
        local k = next(fields[i])
        if schema.endpoint_key == k then
          found = true
          break
        end
      end
      if not found then
        errors["endpoint_key"] = meta_errors.ENDPOINT_KEY
      end
    end

    local cache_key = schema.cache_key
    if cache_key then
      local found
      for i = 1, #cache_key do
        found = nil
        for j = 1, #fields do
          local item = fields[j]
          local k = next(item)
          if cache_key[i] == k then
            found = item[k]
            break
          end
        end
        if not found then
          errors["cache_key"] = meta_errors.CACHE_KEY
          break
        end
      end

      if #cache_key == 1 then
        if found and not found.unique then
          errors["cache_key"] = meta_errors.CACHE_KEY_UNIQUE
        end
      end
    end

    if schema.subschema_key then
      local found = false
      for i = 1, #fields do
        local item = fields[i]
        local k = next(item)
        local field = item[k]
        if schema.subschema_key == k then
          if field.type ~= "string" and field.type ~= "set" then
            errors["subschema_key"] = meta_errors.SUBSCHEMA_KEY_TYPE
          end
          found = true
          break
        end
      end
      if not found then
        errors["subschema_key"] = meta_errors.SUBSCHEMA_KEY
      end
    end

    if schema.ttl then
      for i = 1, #fields do
        local k = next(fields[i])
        if k == "ttl" then
          errors["ttl"] = meta_errors.TTL_RESERVED
          break
        end
      end
    end

    local transformations = schema.transformations
    if transformations then
      for i = 1, #transformations do
        local input = transformations[i].input
        if input then
          for j = 1, #input do
            if not has_schema_field(schema, input[j]) then
              if not errors.transformations then
                errors.transformations = {}
              end

              if not errors.transformations.input then
                errors.transformations.input = {}
              end


              if not errors.transformations.input[i] then
                errors.transformations.input[i] = {}
              end

              errors.transformations.input[i][j] = fmt("invalid field name: %s", input)
            end
          end
        end

        local needs = transformations[i].needs
        if needs then
          for j = 1, #needs do
            if not has_schema_field(schema, needs[j]) then
              if not errors.transformations then
                errors.transformations = {}
              end

              if not errors.transformations.needs then
                errors.transformations.needs = {}
              end


              if not errors.transformations.needs[i] then
                errors.transformations.needs[i] = {}
              end

              errors.transformations.needs[i][j] = fmt("invalid field name: %s", needs[j])
            end
          end
        end
      end
    end

    return check_fields(schema, errors)
  end,
})


MetaSchema.valid_types = setmetatable({
  ["function"] = true,
}, { __index = Schema.valid_types })


--- Produce a list of validators understood by the MetaSchema.
-- This list is produced from the MetaSchema definition and
-- is used for cross-checking against the Schema validators.
-- @return a set of validator names.
function MetaSchema.get_supported_validator_set()
  local set = {}
  for i = 1, #validators do
    local name = next(validators[i])
    set[name] = true
  end
  return set
end


MetaSchema.MetaSubSchema = Schema.new({
  name = "metasubschema",
  fields = {
    {
      name = {
        type = "string",
        required = true,
      },
    },
    {
      fields = fields_array,
    },
    {
      entity_checks = entity_checks_schema,
    },
    {
      shorthand_fields = shorthand_fields_array,
    },
    {
      transformations = transformations_array,
    },
    {
      check = {
        type = "function",
        nilable = true,
      },
    },
  },
  check = function(schema)
    local errors = {}

    if not schema.fields then
      errors["fields"] = meta_errors.TABLE:format("fields")
      return nil, errors
    end

    return check_fields(schema, errors)
  end,
})


return MetaSchema
