--[[
    Core/Database.lua

    Owns the SavedVariables table `ShinyHunterDB`.

    Responsibilities:
      * create the table on a fresh install
      * apply defaults without clobbering user values
      * run forward migrations between schema versions
      * refuse to touch data written by a NEWER version of the addon

    This module knows nothing about pets. It only guarantees that a well-formed
    container exists. Collection semantics live in Collection/State.lua.

    ---------------------------------------------------------------------------
    Scope: one account-wide file, namespaced internally
    ---------------------------------------------------------------------------

      settings            account wide   preferences, not progress
      account.favorites   account wide   a wishlist belongs to the player
      account.ignored     account wide   same
      characters[key]     per character  collected / pending: taming is per char

    A single `## SavedVariables` with internal per-character namespaces was
    chosen over adding `## SavedVariablesPerCharacter`, because the roadmap
    requires account-wide aggregates ("collected on any of my hunters") and
    per-character files are not readable from each other. See
    docs/database-schema.md for the full reasoning.

    ---------------------------------------------------------------------------
    Character key
    ---------------------------------------------------------------------------

    The key is `UnitGUID("player")`, e.g. "Player-1379-0A2B3C4D".

    Name-and-realm was rejected: it breaks on a rename, and it is unavailable
    early in the login sequence. The GUID is stable across renames and is
    unique across connected realms. It is opaque, so each character record also
    stores name, realm and class as human-readable metadata.

    The GUID is resolved at PLAYER_LOGIN, not at ADDON_LOADED: unit information
    is not guaranteed to be populated when saved variables first become
    available.
]]

local _, ns = ...

local Database = ns.NewModule("Database")
ns.Database = Database

Database.CURRENT_SCHEMA_VERSION = 10

--=============================================================================
-- Defaults
--=============================================================================

local ACCOUNT_DEFAULTS = {
    schemaVersion = Database.CURRENT_SCHEMA_VERSION,
    settings = {
        debugEnabled = false,
        minimapEnabled = true,
        minimapAngle = 200,
        soundEnabled = true,
        alertScope = "catalog", -- "catalog" (604 ledger) or "all" notable beasts
        language = "auto", -- "auto" follows GetLocale(); otherwise a supported locale id
        window = {
            point = "CENTER",
            relativePoint = "CENTER",
            x = 0,
            y = 0,
        },
    },
    account = {
        favorites = {},   -- [appearanceID] = true
        ignored = {},     -- [appearanceID] = true

        -- Tameable creatures the player targeted that the database does not
        -- know. Research data gathered from play, not collection progress.
        sightings = {},   -- [creatureID] = { name, family, seenAt }
        regionCompletions = {}, -- [continentUiMapID] = first completion time
        namedRareRegionCompletions = {}, -- exact NPC-ledger territory seals
    },
    characters = {},        -- [playerGUID] = CHARACTER_DEFAULTS
    legacyCharacters = {},  -- ["Name - Realm"] = pre-schema-2 record, adopted on login
}

local CHARACTER_DEFAULTS = {
    name = "",
    realm = "",
    class = "",
    lastLogin = 0,

    -- Journal marks. `collected` is the player's own record: set by hand, and
    -- added to by the stable scanner, never removed by it.
    collected = {},           -- [appearanceID] = true
    pending = {},             -- [appearanceID] = true
    lastSeen = {},            -- [appearanceID] = serverTime

    -- Encountered in the world but not tamed. The half of a field journal that
    -- a plain collection list leaves out.
    seen = {},                -- [appearanceID] = true
    capturedRares = {},       -- [npcID] = true; exact named-rare progress
    killedRares = {},         -- [npcID] = true; exact named-rare kills
    seenRares = {},           -- [npcID] = { seenAt, mapID, x, y }

    -- Derived from the stable, and therefore only as fresh as the last read.
    -- Kept apart from `collected` so an incomplete read can never look like
    -- the player having lost a pet.
    --
    -- `stablePets` is the snapshot: what the client said, stored verbatim, so
    -- the addon can still describe the player's pets away from a stable
    -- master. `owned` is the lookup set derived from it.
    owned = {},               -- [appearanceID] = true
    stablePets = {},          -- { { slot, displayID, familyName, name }, ... }
    stableScannedAt = 0,      -- serverTime of the last successful read
}

--=============================================================================
-- Migrations
--
-- migrations[n] upgrades a database at schema version n to version n + 1.
-- A migration converts data. It never deletes it.
--=============================================================================

local migrations = {}

-- 1 -> 2: character records move from "Name - Realm" keys to player GUIDs.
--
-- Only the GUID of the character logging in right now is knowable, so the old
-- records are parked in `legacyCharacters` and adopted one at a time, as each
-- character logs in (see AdoptLegacyRecord). Nothing is discarded here.
migrations[1] = function(db)
    if type(db.characters) ~= "table" then
        return
    end

    db.legacyCharacters = db.legacyCharacters or {}

    local moved = 0
    for key, record in pairs(db.characters) do
        db.legacyCharacters[key] = record
        moved = moved + 1
    end

    db.characters = {}
    ns.Debug:Log("DB", "schema 1->2: parked %d character record(s) for adoption", moved)
end

-- 2 -> 3: character records gain `owned`, `stableDisplayIDs` and
-- `stableScannedAt` for the stable scanner.
--
-- Purely additive, so ApplyDefaults fills the new tables in. The migration
-- still has to exist: the loop advances one version at a time and a missing
-- step stops it, leaving the database stranded at an old version.
migrations[2] = function()
    ns.Debug:Log("DB", "schema 2->3: stable tracking fields added by defaults")
end

-- 3 -> 4: `stableDisplayIDs`, a bare set of ids, becomes `stablePets`, a
-- snapshot that also keeps the slot, family and pet name.
--
-- The old set carried less information than the new list, so the conversion
-- cannot invent the missing fields. It keeps what there was -- the display id,
-- which is the part that matters for matching -- and the next stable visit
-- fills in the rest.
migrations[3] = function(db)
    for _, record in pairs(db.characters or {}) do
        if type(record.stableDisplayIDs) == "table" then
            record.stablePets = record.stablePets or {}

            for displayID in pairs(record.stableDisplayIDs) do
                record.stablePets[#record.stablePets + 1] = { displayID = displayID }
            end

            record.stableDisplayIDs = nil
        end
    end

    ns.Debug:Log("DB", "schema 3->4: stable display ids converted to snapshots")
end

-- 4 -> 5: `seen` per character and `sightings` account wide, for the target
-- tracker. Additive, so the defaults fill them in; the step exists because the
-- migration loop advances one version at a time.
migrations[4] = function()
    ns.Debug:Log("DB", "schema 4->5: sighting fields added by defaults")
end

-- 5 -> 6: account-wide, achievement-like regional completion stamps.
migrations[5] = function()
    ns.Debug:Log("DB", "schema 5->6: regional hunt completion stamps added")
end

-- 6 -> 7: exact named-rare captures. An appearance cannot represent this
-- because several named beasts may share the same display id.
migrations[6] = function()
    ns.Debug:Log("DB", "schema 6->7: exact named-rare capture fields added")
end

-- 7 -> 8: useful named-rare encounter history for the sightings page.
migrations[7] = function()
    ns.Debug:Log("DB", "schema 7->8: named-rare sighting history added")
end

-- 8 -> 9: player-selectable encounter alert scope. The field is additive;
-- keeping the step explicit preserves the one-migration-per-schema contract.
migrations[8] = function()
    ns.Debug:Log("DB", "schema 8->9: encounter alert scope added")
end

-- 9 -> 10: named rares can complete the field journal either by capture or
-- by defeat. The two facts remain separate so the dossier can show both.
migrations[9] = function()
    ns.Debug:Log("DB", "schema 9->10: named-rare kill records added")
end

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

local characterKey
local isReadOnly = false

local function DescribePlayer()
    local name, realm = UnitFullName("player")

    if not realm or realm == "" then
        realm = GetNormalizedRealmName() or GetRealmName()
    end

    local _, class = UnitClass("player")

    return name, realm, class
end

--- Moves a pre-schema-2 record onto its GUID key, the first time this
--- character logs in after the migration.
local function AdoptLegacyRecord(db, name, realm)
    if not name or not realm then
        return nil
    end

    -- Schema 1 built its key with the localized realm name, so try both that
    -- and the normalized form.
    local candidates = {
        name .. " - " .. realm,
        name .. " - " .. (GetRealmName() or ""),
    }

    for _, candidate in ipairs(candidates) do
        local record = db.legacyCharacters[candidate]
        if record then
            db.legacyCharacters[candidate] = nil
            ns.Debug:Log("DB", "adopted legacy record %q", candidate)
            return record
        end
    end

    return nil
end

local function RunMigrations(db)
    local from = db.schemaVersion

    while from < Database.CURRENT_SCHEMA_VERSION do
        local migrate = migrations[from]

        if not migrate then
            ns.Debug:Warn("DB", "no migration from schema %d, stopping", from)
            break
        end

        migrate(db)
        from = from + 1
        db.schemaVersion = from
        ns.Debug:Log("DB", "migrated schema to %d", from)
    end
end

--=============================================================================
-- Lifecycle
--=============================================================================

--- Prepares `ShinyHunterDB`. Runs on ADDON_LOADED, before unit
--- information can be trusted, so it does not touch the character record.
function Database:OnInitialize()
    if type(ShinyHunterDB) ~= "table" then
        ShinyHunterDB = {}
        ns.Debug:Log("DB", "fresh database created")
    end

    local db = ShinyHunterDB

    if db.schemaVersion == nil then
        db.schemaVersion = self.CURRENT_SCHEMA_VERSION
    elseif db.schemaVersion > self.CURRENT_SCHEMA_VERSION then
        -- Saved by a newer build. Writing to it could destroy data this
        -- version does not understand, so we stay out of the way.
        isReadOnly = true
        ns.Debug:Warn("DB", "saved schema %d is newer than supported %d",
            db.schemaVersion, self.CURRENT_SCHEMA_VERSION)
        ns.Print(ns.L["DB_NEWER_SCHEMA"])
    elseif db.schemaVersion < self.CURRENT_SCHEMA_VERSION then
        RunMigrations(db)
    end

    ns.Util.ApplyDefaults(db, ACCOUNT_DEFAULTS)

    ns.Debug:Log("DB", "ready (schema %d, %d stored character(s))",
        db.schemaVersion, ns.Util.CountKeys(db.characters))
end

--- Resolves the character record. Runs on PLAYER_LOGIN, once UnitGUID and
--- UnitFullName are populated.
function Database:OnEnable()
    if isReadOnly then
        return
    end

    local db = ShinyHunterDB
    local guid = UnitGUID("player")

    if not guid then
        ns.Debug:Warn("DB", "UnitGUID unavailable at login; character data disabled this session")
        return
    end

    characterKey = guid

    local name, realm, class = DescribePlayer()

    if not db.characters[guid] then
        db.characters[guid] = AdoptLegacyRecord(db, name, realm) or {}
    end

    local record = db.characters[guid]
    ns.Util.ApplyDefaults(record, CHARACTER_DEFAULTS)

    record.name = name or record.name
    record.realm = realm or record.realm
    record.class = class or record.class
    record.lastLogin = time()

    ns.Debug:Log("DB", "character %s-%s (%s) resolved to %s",
        tostring(record.name), tostring(record.realm), tostring(record.class), guid)
end

--=============================================================================
-- Accessors
--=============================================================================

--- @return boolean Whether the stored data was written by a newer addon build.
function Database:IsReadOnly()
    return isReadOnly
end

--- @return table The account wide database root.
function Database:GetRoot()
    return ShinyHunterDB
end

--- @return table The account wide settings table.
function Database:GetSettings()
    return ShinyHunterDB.settings
end

--- @return table The account wide collection data (favorites, ignored).
function Database:GetAccountData()
    return ShinyHunterDB.account
end

--- @return string|nil The GUID of the logged in character, once resolved.
function Database:GetCharacterKey()
    return characterKey
end

--- @return table|nil The per character data of the logged in character.
-- Nil before PLAYER_LOGIN, and nil if the GUID could not be resolved. Callers
-- must treat that as "no collection data available", never as "nothing
-- collected".
function Database:GetCharacterData()
    if not characterKey then
        return nil
    end

    return ShinyHunterDB.characters[characterKey]
end

--- @return table Every known character record, keyed by GUID.
-- Backs account-wide aggregates such as "collected on any of my hunters".
function Database:GetAllCharacters()
    return ShinyHunterDB.characters
end

--- Wipes stored data and rebuilds it from defaults.
-- Only ever called from an explicitly confirmed slash command.
function Database:ResetAll()
    ShinyHunterDB = nil
    isReadOnly = false
    characterKey = nil

    self:OnInitialize()
    self:OnEnable()

    ns.Events:Send("DATABASE_RESET")
end
