--[[
    Data/Validator.lua

    Cross-record validation of the static database, surfaced by "/shiny validate".

    Structural checks (types, ranges, unknown fields, enum tokens) already run
    at registration time in Data/Schema.lua, and anything they reject never
    enters the store. This module checks what only becomes knowable once every
    data file has loaded: dangling references, orphans, contradictions between
    records, and provenance.

    Severity is a deliberate distinction:

      ERROR   the database is wrong and the UI would misbehave
      WARNING the database is thin but usable, e.g. an appearance whose model
              we have not verified yet

    A thin database must not be reported as a broken one.
]]

local _, ns = ...

local Validator = ns.NewModule("Validator")
ns.Data = ns.Data or {}
local Data = ns.Data
Data.Validator = Validator

local MAX_REFERENCE_DEPTH = 16

--=============================================================================
-- Report
--=============================================================================

local function NewReport()
    return { errors = {}, warnings = {} }
end

local function AddError(report, message, ...)
    if select("#", ...) > 0 then
        message = message:format(...)
    end
    report.errors[#report.errors + 1] = message
end

local function AddWarning(report, message, ...)
    if select("#", ...) > 0 then
        message = message:format(...)
    end
    report.warnings[#report.warnings + 1] = message
end

--=============================================================================
-- Checks
--=============================================================================

local function CheckRejections(store, report)
    for _, reason in ipairs(store.rejected) do
        AddError(report, "rejected at load: %s", reason)
    end
end

local function CheckRequirementReferences(store, report, ownerLabel, owner)
    for _, requirementID in ipairs(owner.requirements or {}) do
        if not store.requirements[requirementID] then
            AddError(report, "%s references unknown requirement %q", ownerLabel, tostring(requirementID))
        end
    end
end

local function CheckFamilies(store, report)
    local empty = 0

    for token, family in pairs(store.families) do
        CheckRequirementReferences(store, report, ("family %s"):format(token), family)

        local appearanceIDs = store.appearanceIDsByFamily[token]
        if not appearanceIDs or #appearanceIDs == 0 then
            empty = empty + 1
        end
    end

    -- Reported as one line rather than one per family. The family table is
    -- complete while the appearance table is deliberately not, so most
    -- families having nothing yet is the expected state, and sixty separate
    -- warnings would bury the findings that matter.
    if empty > 0 then
        AddWarning(report, "%d of %d families have no appearances yet",
            empty, ns.Util.CountKeys(store.families))
    end
end

local function CheckAppearances(store, report)
    for appearanceID, appearance in pairs(store.appearances) do
        local label = ("appearance %d"):format(appearanceID)

        if not store.families[appearance.family] then
            AddError(report, "%s references unknown family %q", label, tostring(appearance.family))
        end

        CheckRequirementReferences(store, report, label, appearance)

        local creatureIDs = store.creatureIDsByAppearance[appearanceID]
        if not creatureIDs or #creatureIDs == 0 then
            AddWarning(report, "%s has no creature providing it", label)
        end

        -- Not an error: an appearance with no verified Blizzard display id is
        -- browsable, it just cannot show a model.
        if not appearance.displayIDs or #appearance.displayIDs == 0 then
            AddWarning(report, "%s has no display id, the model preview will be unavailable", label)
        end

        if appearance.expansion and not Data.validExpansions[appearance.expansion] then
            AddError(report, "%s has unknown expansion %q", label, tostring(appearance.expansion))
        end
    end
end

local function CheckCreatures(store, report)
    for creatureID, creature in pairs(store.creatures) do
        local label = ("creature %d"):format(creatureID)

        if not store.appearances[creature.appearanceID] then
            AddError(report, "%s references unknown appearance %s", label, tostring(creature.appearanceID))
        end

        CheckRequirementReferences(store, report, label, creature)

        if creature.levelMin and creature.levelMax and creature.levelMin > creature.levelMax then
            AddError(report, "%s has levelMin %d above levelMax %d",
                label, creature.levelMin, creature.levelMax)
        end

        local locations = store.locationsByCreature[creatureID]
        if not locations or #locations == 0 then
            AddWarning(report, "%s has no known location", label)
        end
    end
end

local function CheckLocations(store, report)
    for index, location in ipairs(store.locations) do
        local label = ("location %d (creature %s)"):format(index, tostring(location.creatureID))

        if not store.creatures[location.creatureID] then
            AddError(report, "%s references unknown creature", label)
        end

        if type(location.mapID) ~= "number" or location.mapID < 1
            or location.mapID ~= math.floor(location.mapID) then
            AddError(report, "%s has an invalid mapID %s", label, tostring(location.mapID))
        end

        -- A coordinate pair is meaningless with only one half of it.
        if (location.x == nil) ~= (location.y == nil) then
            AddError(report, "%s has only one of x/y", label)
        end

        for _, axis in ipairs({ "x", "y" }) do
            local value = location[axis]
            if value ~= nil and (value < Data.COORDINATE_MIN or value > Data.COORDINATE_MAX) then
                AddError(report, "%s has %s = %s outside 0..1", label, axis, tostring(value))
            end
        end

        if location.x == nil then
            AddWarning(report, "%s has no coordinates, only a zone", label)
        end
    end
end

--- A display id may belong to exactly one appearance: it is the key the stable
--- scanner matches on, so a clash would mark the wrong pet collected.
local function CheckDisplayIDOwnership(store, report)
    local owner = {}

    for appearanceID, appearance in pairs(store.appearances) do
        for _, displayID in ipairs(appearance.displayIDs or {}) do
            if owner[displayID] and owner[displayID] ~= appearanceID then
                AddError(report, "display id %d is claimed by appearances %d and %d",
                    displayID, owner[displayID], appearanceID)
            else
                owner[displayID] = appearanceID
            end
        end
    end
end

local function CheckProvenance(store, report)
    local function Scan(collection, kind)
        for key, record in pairs(collection) do
            if record.source == Data.Source.DATA_DEV_SAMPLE then
                AddWarning(report, "%s %s is a development sample, not verified data",
                    kind, tostring(key))
            end
        end
    end

    Scan(store.families, "family")
    Scan(store.appearances, "appearance")
    Scan(store.creatures, "creature")
    Scan(store.requirements, "requirement")
end

--- The outgoing edges of one node of the reference graph.
-- Families and requirements are terminal; nothing points back up.
local function EdgesOf(store, node)
    local kind, id = node:match("^(%a+):(.+)$")

    if kind == "creature" then
        local creature = store.creatures[tonumber(id)]
        return creature and { ("appearance:%s"):format(tostring(creature.appearanceID)) } or nil
    elseif kind == "appearance" then
        local appearance = store.appearances[tonumber(id)]
        return appearance and { ("family:%s"):format(tostring(appearance.family)) } or nil
    end

    return nil
end

--- Walks the reference graph from every creature and refuses to loop.
--
-- The graph is a DAG by construction today, and every node has at most one
-- outgoing edge, so revisiting a node can only mean a cycle. The check exists
-- so that stops being an assumption the moment someone adds a field like
-- "appearance variant of another appearance".
local function CheckReferenceCycles(store, report)
    for creatureID in pairs(store.creatures) do
        local visited = {}
        local pending = { ("creature:%d"):format(creatureID) }
        local steps = 0

        while #pending > 0 do
            steps = steps + 1
            if steps > MAX_REFERENCE_DEPTH then
                AddError(report, "reference chain from creature %d exceeded depth %d",
                    creatureID, MAX_REFERENCE_DEPTH)
                break
            end

            local node = table.remove(pending)

            if visited[node] then
                AddError(report, "reference cycle reached from creature %d via %s", creatureID, node)
                break
            end
            visited[node] = true

            for _, successor in ipairs(EdgesOf(store, node) or {}) do
                pending[#pending + 1] = successor
            end
        end
    end
end

--=============================================================================
-- Public API
--=============================================================================

--- Validates a store.
-- @param target table|nil A store from Registry:NewStore(); defaults to the
--        shipped one.
-- @return table report { counts, errors, warnings }
function Validator:Run(target)
    target = target or ns.Data.Registry:GetStore()

    local report = NewReport()

    CheckRejections(target, report)
    CheckFamilies(target, report)
    CheckAppearances(target, report)
    CheckCreatures(target, report)
    CheckLocations(target, report)
    CheckDisplayIDOwnership(target, report)
    CheckProvenance(target, report)
    CheckReferenceCycles(target, report)

    report.counts = ns.Data.Registry:GetStats()

    return report
end

--- Runs the validation and prints the summary, with per-entry detail when
--- debug output is enabled.
function Validator:PrintReport()
    local L = ns.L
    local report = self:Run()
    local counts = report.counts

    -- No column padding: the chat font is proportional, so padded labels come
    -- out ragged rather than aligned.
    local function Line(labelKey, value)
        ns.Print(("%s: %s"):format(L[labelKey], tostring(value)))
    end

    ns.Print(L["VALIDATE_TITLE"])
    Line("VALIDATE_LABEL_FAMILIES", counts.families)
    Line("VALIDATE_LABEL_APPEARANCES", counts.appearances)
    Line("VALIDATE_LABEL_CREATURES", counts.creatures)
    Line("VALIDATE_LABEL_LOCATIONS", counts.locations)
    Line("VALIDATE_LABEL_REQUIREMENTS", counts.requirements)
    Line("VALIDATE_LABEL_ERRORS", #report.errors)
    Line("VALIDATE_LABEL_WARNINGS", #report.warnings)

    ns.Print(L["VALIDATE_PROVENANCE"]:format(counts.verifiedAppearances, counts.sampleAppearances))

    if #report.errors == 0 and #report.warnings == 0 then
        ns.Print(L["VALIDATE_ALL_GOOD"])
        return report
    end

    if ns.Debug:IsEnabled() then
        for _, message in ipairs(report.errors) do
            ns.Print("|cffff4040" .. message .. "|r")
        end
        for _, message in ipairs(report.warnings) do
            ns.Print("|cffff8800" .. message .. "|r")
        end
    else
        ns.Print(L["VALIDATE_DETAIL_HINT"])
    end

    return report
end
