--[[
    Data/Registry.lua

    In-memory store and index builder for the static database, plus the public
    query API the rest of the addon uses (`ns.Data.API`).

    Data files call Register* at load time. Every record is validated against
    Data/Schema.lua first; a rejected record is reported through the debug
    channel and counted, never merged half-formed.

    ---------------------------------------------------------------------------
    Indexes
    ---------------------------------------------------------------------------

    Built incrementally at registration, so every query below is a hash lookup
    and nothing ever scans the database:

        families                [familyToken]   -> family
        appearances             [appearanceID]  -> appearance
        creatures               [creatureID]    -> creature
        requirements            [requirementID] -> requirement

        appearanceIDsByFamily   [familyToken]   -> { appearanceID, ... }
        appearanceIDsByExpansion[expansion]     -> { appearanceID, ... }
        creatureIDsByAppearance [appearanceID]  -> { creatureID, ... }
        creatureIDsByMap        [mapID]         -> { creatureID, ... }  deduped
        locationsByCreature     [creatureID]    -> { location, ... }
        locationsByMap          [mapID]         -> { location, ... }

    `creatureIDsByMap` is deduplicated: a creature with eight spawn points in
    one zone must appear once there, not eight times.

    ---------------------------------------------------------------------------
    Stores
    ---------------------------------------------------------------------------

    All state lives in a store table produced by `Registry:NewStore()`. The
    addon uses one shipped store; Tests/DataValidation.lua builds throwaway
    ones so its fixtures exercise this exact index-building code instead of a
    reimplementation of it.

    This module holds no collection state. Whether the player owns something is
    Collection/State.lua's problem.
]]

local _, ns = ...

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

local Schema = Data.Schema

local EMPTY = {}

--=============================================================================
-- Store
--=============================================================================

--- @param options table|nil `quiet = true` keeps rejections out of the debug
--        channel. Used by tests whose fixtures are *meant* to be rejected, so
--        an expected rejection does not look like a fault in the shipped data.
--        Rejections are still recorded in `store.rejected` either way.
-- @return table A fresh, empty store with every index initialised.
function Registry:NewStore(options)
    return {
        quiet = options ~= nil and options.quiet or false,

        families = {},
        appearances = {},
        creatures = {},
        requirements = {},
        locations = {},

        appearanceIDsByFamily = {},
        appearanceIDsByExpansion = {},
        appearanceIDByDisplayID = {},   -- Blizzard display id -> our appearance id
        creatureIDsByAppearance = {},
        creatureIDsByMap = {},
        locationsByCreature = {},
        locationsByMap = {},

        creatureSeenOnMap = {},   -- [mapID][creatureID] = true, dedupe helper
        rejected = {},            -- human readable rejection reasons
    }
end

local store = Registry:NewStore()

--=============================================================================
-- Internals
--=============================================================================

local function Append(index, key, value)
    if key == nil then
        return
    end

    local bucket = index[key]
    if not bucket then
        bucket = {}
        index[key] = bucket
    end

    bucket[#bucket + 1] = value
end

local function Reject(target, recordType, identifier, errors)
    local reason = ("%s %s: %s")
        :format(recordType, tostring(identifier), table.concat(errors, "; "))

    target.rejected[#target.rejected + 1] = reason

    if not target.quiet then
        ns.Debug:Warn("DATA", "rejected %s", reason)
    end
end

--- Structural validation shared by every record type.
local function Accept(target, recordType, record, identifier)
    local ok, errors = Schema:Validate(recordType, record)

    if not ok then
        Reject(target, recordType, identifier, errors)
        return false
    end

    return true
end

--=============================================================================
-- Registration, against an explicit store
--=============================================================================

local adders = {}

adders.family = function(target, record)
    if not Accept(target, "family", record, record and record.id) then
        return false
    end

    if target.families[record.id] then
        Reject(target, "family", record.id, { "duplicate id" })
        return false
    end

    target.families[record.id] = record
    return true
end

adders.appearance = function(target, record)
    if not Accept(target, "appearance", record, record and record.id) then
        return false
    end

    if target.appearances[record.id] then
        Reject(target, "appearance", record.id, { "duplicate id" })
        return false
    end

    target.appearances[record.id] = record
    Append(target.appearanceIDsByFamily, record.family, record.id)
    Append(target.appearanceIDsByExpansion, record.expansion, record.id)

    -- The reverse map that lets the stable scanner turn a display id read from
    -- the player's own pets into one of our appearances. A display id claimed
    -- by two appearances is a data error; the first claim wins and the
    -- validator reports the clash rather than silently picking one.
    for _, displayID in ipairs(record.displayIDs or {}) do
        local owner = target.appearanceIDByDisplayID[displayID]

        if owner and owner ~= record.id then
            if not target.quiet then
                ns.Debug:Warn("DATA", "display id %d claimed by appearances %d and %d",
                    displayID, owner, record.id)
            end
        else
            target.appearanceIDByDisplayID[displayID] = record.id
        end
    end

    return true
end

adders.creature = function(target, record)
    if not Accept(target, "creature", record, record and record.creatureID) then
        return false
    end

    if target.creatures[record.creatureID] then
        Reject(target, "creature", record.creatureID, { "duplicate creatureID" })
        return false
    end

    target.creatures[record.creatureID] = record
    Append(target.creatureIDsByAppearance, record.appearanceID, record.creatureID)

    return true
end

adders.location = function(target, record)
    if not Accept(target, "location", record, record and record.creatureID) then
        return false
    end

    target.locations[#target.locations + 1] = record
    Append(target.locationsByCreature, record.creatureID, record)
    Append(target.locationsByMap, record.mapID, record)

    local seen = target.creatureSeenOnMap[record.mapID]
    if not seen then
        seen = {}
        target.creatureSeenOnMap[record.mapID] = seen
    end

    if not seen[record.creatureID] then
        seen[record.creatureID] = true
        Append(target.creatureIDsByMap, record.mapID, record.creatureID)
    end

    return true
end

adders.requirement = function(target, record)
    if not Accept(target, "requirement", record, record and record.id) then
        return false
    end

    local ok, errors = Schema:ValidateRequirementParams(record)
    if not ok then
        Reject(target, "requirement", record.id, errors)
        return false
    end

    if target.requirements[record.id] then
        Reject(target, "requirement", record.id, { "duplicate id" })
        return false
    end

    target.requirements[record.id] = record
    return true
end

--- Registers a record into an explicit store. The seam the tests use.
-- @param target table A store from Registry:NewStore().
-- @param recordType string "family" | "appearance" | "creature" | "location" | "requirement"
-- @param record table
-- @return boolean accepted
function Registry:RegisterInto(target, recordType, record)
    local add = adders[recordType]

    if not add then
        error(("unknown record type %q"):format(tostring(recordType)), 2)
    end

    return add(target, record)
end

--=============================================================================
-- Registration, against the shipped store. Called by the Data/*.lua files.
--=============================================================================

function Registry:RegisterFamily(record)      return adders.family(store, record) end
function Registry:RegisterAppearance(record)  return adders.appearance(store, record) end
function Registry:RegisterCreature(record)    return adders.creature(store, record) end
function Registry:RegisterLocation(record)    return adders.location(store, record) end
function Registry:RegisterRequirement(record) return adders.requirement(store, record) end

--=============================================================================
-- Store access, for the validator and the tests
--=============================================================================

--- @return table The shipped store, indexes included. Read only for callers.
function Registry:GetStore()
    return store
end

--- @return table Rejection reasons collected while loading the data files.
function Registry:GetRejections()
    return store.rejected
end

--- @return table Record counts and the verified/sample split.
function Registry:GetStats()
    local verified, sample = 0, 0

    for _, appearance in pairs(store.appearances) do
        if appearance.source == Data.Source.DATA_REAL_VERIFIED then
            verified = verified + 1
        else
            sample = sample + 1
        end
    end

    return {
        families = ns.Util.CountKeys(store.families),
        appearances = ns.Util.CountKeys(store.appearances),
        creatures = ns.Util.CountKeys(store.creatures),
        locations = #store.locations,
        requirements = ns.Util.CountKeys(store.requirements),
        rejected = #store.rejected,
        verifiedAppearances = verified,
        sampleAppearances = sample,
    }
end

function Registry:OnInitialize()
    local stats = self:GetStats()
    ns.Debug:Log("DATA",
        "%d families, %d appearances, %d creatures, %d locations, %d requirements (%d rejected)",
        stats.families, stats.appearances, stats.creatures,
        stats.locations, stats.requirements, stats.rejected)
end

--=============================================================================
-- Query API
--
-- The single interface the rest of the addon uses. Every call is a hash lookup
-- plus the size of the result; none of them scans the database.
--
-- Lists are returned by reference and must be treated as read only. Misses
-- return a shared empty table, never nil, so callers can always ipairs().
--=============================================================================

local API = {}
Data.API = API

--- @param familyToken string @return table|nil
function API:GetFamily(familyToken)
    return store.families[familyToken]
end

--- @param appearanceID number @return table|nil
function API:GetAppearance(appearanceID)
    return store.appearances[appearanceID]
end

--- @param creatureID number @return table|nil
function API:GetCreature(creatureID)
    return store.creatures[creatureID]
end

--- @param requirementID string @return table|nil
function API:GetRequirement(requirementID)
    return store.requirements[requirementID]
end

--- @param familyToken string @return table Appearance ids, possibly empty.
function API:GetAppearanceIDsByFamily(familyToken)
    return store.appearanceIDsByFamily[familyToken] or EMPTY
end

--- @param expansionToken string @return table Appearance ids, possibly empty.
function API:GetAppearanceIDsByExpansion(expansionToken)
    return store.appearanceIDsByExpansion[expansionToken] or EMPTY
end

--- Resolves a Blizzard creature display id to one of our appearances.
-- This is the bridge the stable scanner crosses: the client tells us what a
-- pet looks like, and this says which journal entry that is.
-- @param displayID number @return number|nil appearanceID
function API:GetAppearanceIDByDisplayID(displayID)
    return store.appearanceIDByDisplayID[displayID]
end

--- @param appearanceID number @return table Creature ids, possibly empty.
function API:GetCreatureIDsByAppearance(appearanceID)
    return store.creatureIDsByAppearance[appearanceID] or EMPTY
end

--- @param mapID number @return table Creature ids on that map, deduplicated.
function API:GetCreatureIDsByMap(mapID)
    return store.creatureIDsByMap[mapID] or EMPTY
end

--- @param creatureID number @return table Location records, possibly empty.
function API:GetLocationsByCreature(creatureID)
    return store.locationsByCreature[creatureID] or EMPTY
end

--- @param mapID number @return table Location records, possibly empty.
function API:GetLocationsByMap(mapID)
    return store.locationsByMap[mapID] or EMPTY
end

--- Every collectible appearance represented by a rare, tameable creature on
--- one map.  The result is deduplicated because several NPC records may share
--- one appearance and one NPC may have many spawn points.
-- @param mapID number
-- @return table Array of appearance ids.
function API:GetRareAppearanceIDsByMap(mapID)
    local result, seen = {}, {}

    for _, creatureID in ipairs(store.creatureIDsByMap[mapID] or EMPTY) do
        local creature = store.creatures[creatureID]
        if creature and creature.rare == true and creature.tameable == true
            and not seen[creature.appearanceID] then
            seen[creature.appearanceID] = true
            result[#result + 1] = creature.appearanceID
        end
    end

    table.sort(result)
    return result
end

--- Map ids on which an appearance has at least one location record.
-- @param appearanceID number
-- @return table Array of map ids.
function API:GetMapIDsByAppearance(appearanceID)
    local result, seen = {}, {}

    for _, creatureID in ipairs(store.creatureIDsByAppearance[appearanceID] or EMPTY) do
        for _, location in ipairs(store.locationsByCreature[creatureID] or EMPTY) do
            if not seen[location.mapID] then
                seen[location.mapID] = true
                result[#result + 1] = location.mapID
            end
        end
    end

    table.sort(result)
    return result
end

--- @return table Every appearance, keyed by id. Read only.
function API:GetAllAppearances()
    return store.appearances
end

--- @return table Every family, keyed by token. Read only.
function API:GetAllFamilies()
    return store.families
end

-- Localized family name -> family, built on first use.
local familiesByLocalizedName

--- Resolves the family name the client reports for a unit back to our record.
--
-- `UnitCreatureFamily` returns a localized name and no id, so this is the only
-- bridge available. It works because our own family names come from the
-- client's own CreatureFamily.db2 in the same locale, which makes them
-- identical strings rather than translations that merely agree.
--
-- It therefore only resolves in locales we ship: elsewhere the names fall back
-- to English, will not match, and the caller gets nil. That degrades to "we do
-- not know whether this family is exotic", which is the honest answer.
--
-- @param localizedName string @return table|nil family
function API:GetFamilyByLocalizedName(localizedName)
    if not localizedName then
        return nil
    end

    if not familiesByLocalizedName then
        familiesByLocalizedName = {}

        for _, family in pairs(store.families) do
            familiesByLocalizedName[ns.L[family.nameKey]] = family
        end
    end

    return familiesByLocalizedName[localizedName]
end

--=============================================================================
-- Derived lookups
--
-- These exist because facts are not duplicated across records: a creature has
-- no family field, so its family is reached through its appearance.
--=============================================================================

--- @param creatureID number @return table|nil The creature's appearance.
function API:GetAppearanceOfCreature(creatureID)
    local creature = store.creatures[creatureID]
    return creature and store.appearances[creature.appearanceID] or nil
end

--- @param appearanceID number @return table|nil The appearance's family.
function API:GetFamilyOfAppearance(appearanceID)
    local appearance = store.appearances[appearanceID]
    return appearance and store.families[appearance.family] or nil
end

--- @param creatureID number @return table|nil The creature's family.
function API:GetFamilyOfCreature(creatureID)
    local appearance = self:GetAppearanceOfCreature(creatureID)
    return appearance and store.families[appearance.family] or nil
end

--- Whether an appearance is exotic, honouring an appearance level override
--- before falling back to its family.
-- @param appearanceID number @return boolean
function API:IsAppearanceExotic(appearanceID)
    local appearance = store.appearances[appearanceID]
    if not appearance then
        return false
    end

    if appearance.exotic ~= nil then
        return appearance.exotic
    end

    local family = store.families[appearance.family]
    return family ~= nil and family.exotic == true
end

--- Every requirement id that applies to a creature: those of its family, then
--- of its appearance, then its own, without duplicates.
-- @param creatureID number @return table Requirement ids.
function API:GetEffectiveRequirements(creatureID)
    local creature = store.creatures[creatureID]
    if not creature then
        return EMPTY
    end

    local appearance = store.appearances[creature.appearanceID]
    local family = appearance and store.families[appearance.family]

    local result, seen = {}, {}

    local function Collect(list)
        for _, requirementID in ipairs(list or EMPTY) do
            if not seen[requirementID] then
                seen[requirementID] = true
                result[#result + 1] = requirementID
            end
        end
    end

    Collect(family and family.requirements)
    Collect(appearance and appearance.requirements)
    Collect(creature.requirements)

    return result
end
