--[[
    Data/Schema.lua

    Declarative contract for every record type, plus the structural validator
    that enforces it at registration time.

    ---------------------------------------------------------------------------
    The model
    ---------------------------------------------------------------------------

        FAMILY  1 ── n  APPEARANCE  1 ── n  CREATURE  1 ── n  LOCATION
           │                 │                  │
           └──── n ──────────┴──── REQUIREMENT ─┘

    An APPEARANCE is the collectible unit: what the pet looks like.
    A CREATURE is one NPC in the world that can provide that appearance.
    Several creatures may share one appearance; that is the whole point.

    ---------------------------------------------------------------------------
    Shiny Hunter Appearance ID  vs.  Blizzard Display ID
    ---------------------------------------------------------------------------

    These are different things and must never be conflated.

      * `appearance.id` is a SHINY HUNTER APPEARANCE ID. It is invented
        and owned by this addon. Blizzard exposes no pet-appearance concept and
        no API returns one. It is stable across releases and is the key of the
        collection journal and of every saved variable flag.

      * `appearance.displayIDs` are BLIZZARD CREATURE DISPLAY IDs. They come
        from the game's own data, identify a model, and are the only value
        `PlayerModel:SetDisplayInfo` accepts. We store them; we do not mint
        them, and we never guess one.

    One Shiny Hunter Appearance ID maps to zero or more Blizzard Display
    IDs. Zero is legal and means "we have not verified a model for this yet" —
    the model panel degrades to a placeholder rather than showing the wrong pet.

    ---------------------------------------------------------------------------
    No duplicated facts
    ---------------------------------------------------------------------------

    A fact lives in exactly one record and is reached from the others:

      * a creature has no `familyID` — its family comes from its appearance
      * an appearance has no `creatureIDs` — that is an index, built by the
        registry from the creature records themselves
      * a creature has no `locations` — locations are their own records that
        point back at the creature
      * an appearance has no `displayID` singular — creatures never carry one

    Requirements are the exception, deliberately: an appearance-level
    requirement applies to every source of that look (an exotic family needs
    Beast Mastery no matter which NPC you tame), while a creature-level one
    applies to that NPC alone. Those are different statements, not duplicates.
]]

local _, ns = ...

ns.Data = ns.Data or {}
local Data = ns.Data

local Schema = {}
Data.Schema = Schema

--=============================================================================
-- Field specification
--
--   Field("number", { required = true })          a required number
--   Field({"number", "string"}, {})               either type
--   Field("table", { element = "number" })        an array of numbers
--   Field("number", { min = 0, max = 1 })         a bounded number
--   Field("string", { enum = Data.validExpansions })
--
-- Expressing constraints as data rather than as validator code is what lets
-- one loop check "display ids must be numbers", "coordinates must be within
-- range" and "expansion keys must be known" without special cases.
--=============================================================================

local function Field(types, options)
    options = options or {}

    if type(types) == "string" then
        types = { types }
    end

    local allowed = {}
    for _, typeName in ipairs(types) do
        allowed[typeName] = true
    end

    options.allowedTypes = allowed
    options.typeNames = table.concat(types, "/")

    return options
end

--=============================================================================
-- Records
--=============================================================================

Schema.records = {
    --  A pet family: Wolf, Cat, Spirit beast...
    family = {
        fields = {
            id = Field("string", { required = true }),
            familyID = Field("number"),
            nameKey = Field("string", { required = true }),
            icon = Field({ "number", "string" }),
            exotic = Field("boolean"),
            specialization = Field("string", { enum = Data.validSpecializations }),
            requirements = Field("table", { element = "string" }),
            notes = Field("string"),
            source = Field("string", { required = true, enum = Data.validSources }),
        },
    },

    --  A look. The collectible unit of the journal.
    appearance = {
        fields = {
            id = Field("number", { required = true }),
            nameKey = Field("string", { required = true }),
            family = Field("string", { required = true }),
            displayIDs = Field("table", { element = "number" }),
            expansion = Field("string", { enum = Data.validExpansions }),
            colors = Field("table", { element = "string" }),
            tags = Field("table", { element = "string" }),
            exotic = Field("boolean"),
            requirements = Field("table", { element = "string" }),
            patchIntroduced = Field("string"),
            notes = Field("string"),
            source = Field("string", { required = true, enum = Data.validSources }),
        },
    },

    --  One NPC in the world that provides an appearance.
    creature = {
        fields = {
            creatureID = Field("number", { required = true }),
            appearanceID = Field("number", { required = true }),
            name = Field("string"),
            rare = Field("boolean"),
            elite = Field("boolean"),
            tameable = Field("boolean"),
            levelMin = Field("number"),
            levelMax = Field("number"),
            requirements = Field("table", { element = "string" }),
            notes = Field("string"),
            source = Field("string", { required = true, enum = Data.validSources }),
        },
    },

    --  One spawn point. A creature with eight spawn points has eight of these.
    --
    --  The zone name is deliberately absent: it is derived at display time
    --  from C_Map.GetMapInfo(mapID).name, which the client already returns
    --  localized. Storing it would duplicate a fact and force us to translate
    --  every zone name ourselves.
    location = {
        fields = {
            creatureID = Field("number", { required = true }),
            mapID = Field("number", { required = true, min = 1 }),
            x = Field("number", { min = Data.COORDINATE_MIN, max = Data.COORDINATE_MAX }),
            y = Field("number", { min = Data.COORDINATE_MIN, max = Data.COORDINATE_MAX }),
            subzone = Field("string"),
            sourceType = Field("string", { enum = Data.validSourceTypes }),
            notes = Field("string"),
            source = Field("string", { required = true, enum = Data.validSources }),
        },
    },

    --  A condition. Referenced by id from families, appearances and creatures.
    requirement = {
        fields = {
            id = Field("string", { required = true }),
            kind = Field("string", { required = true, enum = Data.validRequirementKinds }),
            params = Field("table"),
            descriptionKey = Field("string"),
            source = Field("string", { required = true, enum = Data.validSources }),
        },
    },
}

--=============================================================================
-- Structural validation
--
-- Everything checkable by looking at one record in isolation. Cross-record
-- checks (dangling references, orphans) belong to Data/Validator.lua.
--=============================================================================

local function CheckValue(fieldName, field, value, errors)
    if not field.allowedTypes[type(value)] then
        errors[#errors + 1] = ("field %q should be %s, got %s")
            :format(fieldName, field.typeNames, type(value))
        return
    end

    if field.enum and not field.enum[value] then
        errors[#errors + 1] = ("field %q has unknown value %q"):format(fieldName, tostring(value))
    end

    if field.min and type(value) == "number" and value < field.min then
        errors[#errors + 1] = ("field %q is %s, below the minimum %s")
            :format(fieldName, tostring(value), tostring(field.min))
    end

    if field.max and type(value) == "number" and value > field.max then
        errors[#errors + 1] = ("field %q is %s, above the maximum %s")
            :format(fieldName, tostring(value), tostring(field.max))
    end

    if field.element and type(value) == "table" then
        for index, element in ipairs(value) do
            if type(element) ~= field.element then
                errors[#errors + 1] = ("field %q entry %d should be %s, got %s")
                    :format(fieldName, index, field.element, type(element))
            end
        end
    end
end

--- Checks one record against its contract.
-- @param recordType string A key of Schema.records.
-- @param record table
-- @return boolean ok, table errors
function Schema:Validate(recordType, record)
    local definition = self.records[recordType]
    local errors = {}

    if not definition then
        return false, { ("unknown record type %q"):format(tostring(recordType)) }
    end

    if type(record) ~= "table" then
        return false, { ("%s record is not a table"):format(recordType) }
    end

    for fieldName, field in pairs(definition.fields) do
        local value = record[fieldName]

        if value == nil then
            if field.required then
                errors[#errors + 1] = ("missing required field %q"):format(fieldName)
            end
        else
            CheckValue(fieldName, field, value, errors)
        end
    end

    -- Unknown fields are rejected rather than ignored: in a generated file a
    -- typo would otherwise become a filter that silently never matches.
    for fieldName in pairs(record) do
        if not definition.fields[fieldName] then
            errors[#errors + 1] = ("unknown field %q"):format(tostring(fieldName))
        end
    end

    return #errors == 0, errors
end

--- Checks a requirement's params against the contract of its kind.
-- @return boolean ok, table errors
function Schema:ValidateRequirementParams(requirement)
    local contract = Data.requirementParams[requirement.kind]
    local errors = {}

    if not contract then
        return true, errors
    end

    local params = requirement.params or {}

    for _, name in ipairs(contract.required) do
        if params[name] == nil then
            errors[#errors + 1] = ("requirement %q of kind %s is missing param %q")
                :format(tostring(requirement.id), requirement.kind, name)
        end
    end

    for name, value in pairs(params) do
        local expected = contract.params[name]
        if not expected then
            errors[#errors + 1] = ("requirement %q has unknown param %q")
                :format(tostring(requirement.id), tostring(name))
        elseif type(value) ~= expected then
            errors[#errors + 1] = ("requirement %q param %q should be %s, got %s")
                :format(tostring(requirement.id), name, expected, type(value))
        end
    end

    return #errors == 0, errors
end
