--- A simple validation library, loosely inspired by JSON Schema

local Schema = {}
Schema.__index = Schema

local function is_schema(tbl)
  return getmetatable(tbl) == Schema
end
Schema.is_schema = is_schema

local validators, initializers = {}, {}

local function to_schema(tbl)
  if is_schema(tbl) then return tbl end
  for i = 1, #tbl do
    tbl[i] = to_schema(tbl[i])
  end
  -- local fields, items = tbl.fields, tbl.items
  -- if fields then
  --   for k, v in pairs(fields) do
  --     fields[k] = to_schema(v)
  --   end
  -- end
  -- if items then
  --   tbl.items = to_schema(items)
  -- end
  for k, fun in pairs(initializers) do
    if tbl[k] then tbl[k] = fun(tbl[k]) end
  end
  return setmetatable(tbl, Schema)
end
setmetatable(Schema, {__call = function(_, tbl) return to_schema(tbl) end})

local function msg_type(expect, got)
  return ("Type: expected %q, got %q"):format(expect, got)
end

local function msg_where(msg, loc_type, loc_name)
  return ("%s in %s %q"):format(msg, loc_type, loc_name)
end

initializers.items = to_schema
initializers.keys = to_schema
initializers.values = to_schema

function initializers.enum(tbl)
  local set = {}
  for i = 1, #tbl do
    set[tbl[i]] = true
  end
  return set
end

function initializers.fields(fields)
  for k, v in pairs(fields) do
    fields[k] = to_schema(v)
  end
  return fields
end

function validators.type(s, obj)
  local ok = (type(obj) == s)
  return ok, ok or msg_type(s, type(obj))
end

function validators.predicate(f, obj)
  return f(obj)
end

function validators.keys(schema, obj)
  if type(obj) ~= "table" then
    return false, msg_type("table", type(obj))
  end
  for k in pairs(obj) do
    local ok, msg = schema:validate(k)
    if not ok then
      return false, msg_where(msg, "key", k)
    end
  end
  return true
end

function validators.values(schema, obj)
  if type(obj) ~= "table" then
    return false, msg_type("table", type(obj))
  end
  for k, v in pairs(obj) do
    local ok, msg = schema:validate(v)
    if not ok then
      return false, msg_where(msg, "field", k)
    end
  end
  return true
end

function validators.fields(fields, obj)
  if type(obj) ~= "table" then
    return false, msg_type("table", type(obj))
  end
  for k, schema in pairs(fields) do
    local ok, msg = schema:validate(obj[k])
    if not ok then
      return false, msg_where(msg, "field", k)
    end
  end
  return true
end

function validators.items(schema, obj)
  if type(obj) ~= "table" then
    return false, msg_type("table", type(obj))
  end
  for i, item in ipairs(obj) do
    local ok, msg = schema:validate(item)
    if not ok then
      return false, msg_where(msg, "item", i)
    end
  end
  return true
end

function validators.enum(set, obj)
  local ok = set[obj]
  return ok, ok or ('Enum: %q not allowed'):format(obj)
end

function Schema:validate(obj)
  if self.optional and obj == nil then return true end
  for k, v in pairs(self) do
    local fun = validators[k]
    if fun then
      local ok, msg = fun(v, obj)
      if not ok then return false, msg end
    end
  end
  local len = #self
  if len > 0 then
    for i = 1, len do
      if self[i]:validate(obj) then break end
      if i == len then return false, "Alternatives: no match" end
    end
  end
  return true
end

function Schema:assert(obj)
  return assert(self:validate(obj))
end

function Schema:assert_fail(obj)
  assert(not self:validate(obj))
end

local schema_of_schema = Schema { -- luacheck: ignore schema_of_schema
  fields = {
    description = {
      type = "string",
      optional = true,
      description = "A docstring for the schema"
    },
    fields = {
      type = "table",
      values = {predicate = is_schema}
    },
    enum = {
      type = "table"
    },
    items = {
      predicate = is_schema
    },
    optional = {
      type = "boolean",
      optional = true,
      description = "Whether nil values are accepted"
    },
    predicate = {
      type = "function",
      optional = true,
      description = "A function to test on the object"
    },
    type = {
      type = "string",
      optional = true,
      description = "Check if the object is of a given type"
    },
  }
}

return Schema
