local addonName, TOAST = ...

-- Local, account-wide M+ score observation cache. Reads the logged-in
-- character's own score directly from the WoW API and remembers the latest
-- valid value per character in TOAST_DB.scoreObservations, so a score seen
-- on one character stays visible (in Trends) after relogging to another.
-- Local-only in this pass: nothing here is broadcast over comms.

local function NormalizeRealm(realm)
    if type(realm) ~= "string" then return "" end
    return (realm:gsub("[%s%-']", ""))
end

local function IsSecret(v)
    return type(issecretvalue) == "function" and issecretvalue(v) == true
end

local function FindMemberByIdentity(name, normRealm)
    if type(TOAST.members) ~= "table" then return nil end
    for _, m in ipairs(TOAST.members) do
        if type(m) == "table" and m.name == name and NormalizeRealm(m.realm) == normRealm then
            return m
        end
    end
    return nil
end

-- Generic finite/non-negative score validator for ordinary generated or
-- SavedVariables values (members.lua/leaderboard.lua/trends.lua rows, a
-- cached observation record) — i.e. values already known not to be a
-- secret userdata. Never used on a raw live API return; RefreshOwnScore's
-- own pcall -> issecretvalue -> type/NaN/negative ordering stays fully
-- inline and untouched for that. Zero is a valid score.
local function ValidScore(v)
    local n = tonumber(v)
    if not n then return nil end
    if n ~= n then return nil end   -- NaN
    if n < 0 then return nil end
    return n
end

-- SavedVariables is user-editable and can outlive addon versions, so the
-- cached record is re-validated on every read rather than trusted blindly.
local function ValidCachedScore(cached)
    if type(cached) ~= "table" then return nil end
    return ValidScore(cached.score)
end

local function ValidCachedSeasonID(cached)
    if type(cached) ~= "table" then return nil end
    local season = tonumber(cached.seasonID)
    if not season or season ~= season or season <= 0 then return nil end
    return season
end

-- Session-cached once successfully read, so repeated calls from
-- RefreshOwnScore() don't re-hit the API. Never persisted.
local cachedCurrentSeasonID = nil

function TOAST.GetCurrentMythicPlusSeasonID()
    if cachedCurrentSeasonID ~= nil then return cachedCurrentSeasonID end
    if InCombatLockdown() then return nil end
    if type(C_MythicPlus) ~= "table" or type(C_MythicPlus.GetCurrentSeason) ~= "function" then
        return nil
    end

    local ok, raw = pcall(C_MythicPlus.GetCurrentSeason)
    if not ok then return nil end
    if IsSecret(raw) then return nil end
    if type(raw) ~= "number" then return nil end
    if raw ~= raw then return nil end   -- NaN
    if raw <= 0 then return nil end

    cachedCurrentSeasonID = raw
    return cachedCurrentSeasonID
end

-- Read-only peek at the session cache — never calls the API, unlike
-- TOAST.GetCurrentMythicPlusSeasonID() above. The resolver (below) must
-- never trigger a WoW API read, so it uses this instead of the public
-- accessor; RefreshOwnScore() (via the public accessor) remains the only
-- normal path that actively obtains/refreshes the season ID. nil here just
-- means nothing has actively refreshed it yet this session — the resolver
-- falls back to its normal date/score/source rules rather than treating
-- that as either a match or a mismatch.
local function CachedCurrentSeasonID()
    return cachedCurrentSeasonID
end

function TOAST.RefreshOwnScore()
    if InCombatLockdown() then return nil end
    if type(C_ChallengeMode) ~= "table" or type(C_ChallengeMode.GetOverallDungeonScore) ~= "function" then
        return nil
    end

    local ok, rawScore = pcall(C_ChallengeMode.GetOverallDungeonScore)
    if not ok then return nil end
    if IsSecret(rawScore) then return nil end
    if type(rawScore) ~= "number" then return nil end
    if rawScore ~= rawScore then return nil end   -- NaN
    if rawScore < 0 then return nil end

    local seasonID = TOAST.GetCurrentMythicPlusSeasonID()

    -- A self observation is trusted self-data authority: ResolveCharacterScore
    -- lets a current-season "self" record short-circuit every other candidate.
    -- Only a real roster character may claim that, so a roster external
    -- character reads its live score for display but records nothing.
    local active = TOAST.GetActiveCharacter and TOAST.GetActiveCharacter()
    if active and not active.isRosterMember then return nil end

    local selfName = UnitName("player")
    if type(selfName) ~= "string" or selfName == "" then return nil end
    local selfRealm = NormalizeRealm(GetRealmName())

    local name, realm, discordID = selfName, selfRealm, nil
    local matched = FindMemberByIdentity(selfName, selfRealm)
    if matched then
        name = matched.name
        realm = NormalizeRealm(matched.realm)
        discordID = matched.discord_id
    end

    TOAST_DB = TOAST_DB or {}
    TOAST_DB.scoreObservations = TOAST_DB.scoreObservations or {}
    local key = name .. "-" .. realm
    local cached = TOAST_DB.scoreObservations[key]
    local cachedScore = ValidCachedScore(cached)
    local cachedSeasonID = ValidCachedSeasonID(cached)

    local accept
    if cachedScore == nil then
        accept = true   -- absent or malformed cached record: treat as replaceable
    elseif cachedSeasonID ~= nil and seasonID ~= nil and cachedSeasonID ~= seasonID then
        accept = true   -- known season change: replace unconditionally, even lower/zero
    else
        accept = rawScore >= cachedScore   -- same/unknown season: never regress
    end

    if accept then
        TOAST_DB.scoreObservations[key] = {
            name = name,
            realm = realm,
            discordID = discordID,
            score = rawScore,
            seasonID = seasonID,
            observedAt = GetServerTime(),
            observedDate = date("%Y-%m-%d"),
            source = "self",
        }
    end

    return TOAST_DB.scoreObservations[key]
end

-- Shape only, not calendar validity — Trends still runs the real
-- DateStringToDays check on whatever this returns.
local function ValidObservedDateShape(s)
    if type(s) ~= "string" then return nil end
    if not s:match("^%d%d%d%d%-%d%d%-%d%d$") then return nil end
    return s
end

-- Never hands out the raw SavedVariables record: a hand-edited or
-- older-version TOAST_DB must not be able to smuggle a NaN/negative score,
-- a bogus season, or a garbage observedAt into a caller (e.g. Trends'
-- date("%H:%M", snap.observedAt) tooltip formatting). Returns a fresh,
-- fully-validated copy, or nil if the record isn't usable at all.
function TOAST.GetScoreObservation(name, realm)
    if type(name) ~= "string" or name == "" then return nil end
    if not TOAST_DB or type(TOAST_DB.scoreObservations) ~= "table" then return nil end

    local record = TOAST_DB.scoreObservations[name .. "-" .. NormalizeRealm(realm)]
    if type(record) ~= "table" then return nil end

    local score = ValidCachedScore(record)
    if not score then return nil end

    local observedDate = ValidObservedDateShape(record.observedDate)
    if not observedDate then return nil end

    local observedAt = tonumber(record.observedAt)
    if not observedAt or observedAt ~= observedAt or observedAt < 0 then
        observedAt = nil
    end

    return {
        name = record.name,
        realm = record.realm,
        discordID = record.discordID,
        score = score,
        seasonID = ValidCachedSeasonID(record),
        observedAt = observedAt,
        observedDate = observedDate,
        source = "self",
    }
end

-- ── Central character-score resolver ────────────────────────────────
-- One pure, best-known-score selector, so Trends (and later Dashboard/
-- Leaderboard/comms) all agree on the same answer instead of each UI
-- module re-implementing its own source precedence. Never reads the WoW
-- API, never touches SavedVariables, never throws on malformed generated
-- data, and always hands back a fresh table — never a row alias.

local DAYS_IN_MONTH = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }

local function IsLeapYear(y)
    return (y % 4 == 0 and y % 100 ~= 0) or (y % 400 == 0)
end

-- Full strict calendar validity for "YYYY-MM-DD" — a standalone copy of
-- trends_view.lua's own date parser. scores.lua must stand on its own (the
-- resolver is meant to be callable from Dashboard/Leaderboard rows without
-- Trends loaded), so this isn't shared via a cross-module local.
local function IsValidISODate(s)
    if type(s) ~= "string" then return false end
    local y, m, d = s:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
    if not y then return false end
    y, m, d = tonumber(y), tonumber(m), tonumber(d)
    if m < 1 or m > 12 then return false end
    local maxDay = DAYS_IN_MONTH[m]
    if m == 2 and IsLeapYear(y) then maxDay = 29 end
    if d < 1 or d > maxDay then return false end
    return true
end

-- First strict YYYY-MM-DD prefix of a bot "updated" timestamp string
-- ("2026-07-04 08:58 UTC" -> "2026-07-04"), validated as a real calendar
-- date; nil if missing/malformed. Shared by the members.lua and
-- leaderboard.lua candidate builders.
local function ExportedDatePrefix(s)
    if type(s) ~= "string" then return nil end
    local y, m, d = s:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)")
    if not y then return nil end
    local candidate = y .. "-" .. m .. "-" .. d
    if IsValidISODate(candidate) then return candidate end
    return nil
end

-- Lazy, session-only "name-normRealm" -> row lookup tables for the
-- immutable generated TOAST.members/TOAST.leaderboard arrays, so the
-- resolver (called per-row by future Dashboard/Leaderboard UI) never has
-- to linearly scan either array on every call. Built once on first use,
-- never persisted, never mutated; a fresh /reload naturally rebuilds them
-- since these are plain module locals. Malformed rows are skipped.
local memberIndex = nil
local function EnsureMemberIndex()
    if memberIndex then return end
    memberIndex = {}
    if type(TOAST.members) ~= "table" then return end
    for _, m in ipairs(TOAST.members) do
        if type(m) == "table" and type(m.name) == "string" then
            memberIndex[m.name .. "-" .. NormalizeRealm(m.realm)] = m
        end
    end
end

local leaderboardIndex = nil
local function EnsureLeaderboardIndex()
    if leaderboardIndex then return end
    leaderboardIndex = {}
    if type(TOAST.leaderboard) ~= "table" then return end
    for _, e in ipairs(TOAST.leaderboard) do
        if type(e) == "table" and type(e.name) == "string" then
            leaderboardIndex[e.name .. "-" .. NormalizeRealm(e.realm)] = e
        end
    end
end

-- Candidate shape (all four builders): { score=, date=, source=, trust=,
-- seasonID=, observedAt= } or nil. seasonID/observedAt are only ever set
-- by the self candidate.

-- Reuses the already-validated accessor rather than reading
-- TOAST_DB.scoreObservations directly, so this candidate inherits all of
-- GetScoreObservation's sanitization for free. A known season mismatch
-- against the live current season excludes the candidate outright (not
-- just a tie-break loss) — a stale prior-season score is not "current"
-- regardless of how recent its date looks.
local function BuildSelfCandidate(name, realm, currentSeasonID)
    local obs = TOAST.GetScoreObservation(name, realm)
    if not obs then return nil end
    if not IsValidISODate(obs.observedDate) then return nil end
    if obs.seasonID ~= nil and currentSeasonID ~= nil and obs.seasonID ~= currentSeasonID then
        return nil
    end
    return {
        score = obs.score,
        date = obs.observedDate,
        source = "self",
        trust = "self",
        seasonID = obs.seasonID,
        observedAt = obs.observedAt,
    }
end

-- Crew State observations arrive through the new TOAST protocol or LibKS.
-- They are already roster-validated and season-checked by crew_state.lua;
-- this adapter keeps the resolver pure and never reads a live WoW API.
local function BuildPeerCandidate(name, realm, currentSeasonID)
    if type(TOAST.GetPeerScoreObservation) ~= "function" then return nil end
    local obs = TOAST.GetPeerScoreObservation(name, realm, currentSeasonID)
    if not obs or not obs.fresh or not obs.observedAt then return nil end
    local observedDate = date("%Y-%m-%d", obs.observedAt)
    if not IsValidISODate(observedDate) then return nil end
    return {
        score = obs.score,
        date = observedDate,
        source = "peer",
        trust = obs.source,
        seasonID = obs.seasonID,
        observedAt = obs.observedAt,
    }
end

local function BuildMembersCandidate(name, normRealm)
    EnsureMemberIndex()
    local m = memberIndex[name .. "-" .. normRealm]
    if not m then return nil end
    local score = ValidScore(m.rio)
    if not score then return nil end
    local exportedDate = ExportedDatePrefix(TOAST.members_updated)
    if not exportedDate then return nil end
    return { score = score, date = exportedDate, source = "members", trust = "bot" }
end

local function BuildLeaderboardCandidate(name, normRealm)
    EnsureLeaderboardIndex()
    local e = leaderboardIndex[name .. "-" .. normRealm]
    if not e then return nil end
    local score = ValidScore(e.rio)
    if not score then return nil end
    local exportedDate = ExportedDatePrefix(TOAST.leaderboard_updated)
    if not exportedDate then return nil end
    return { score = score, date = exportedDate, source = "leaderboard", trust = "bot" }
end

-- The normalized trends adapter resolves character+realm and returns a
-- defensive chronological copy. Its final point is therefore the latest
-- exact bot snapshot, including when several snapshots share a date.
local function BuildTrendsCandidate(name, realm)
    if type(TOAST.GetCharacterTrend) ~= "function" then return nil end
    local rows = TOAST.GetCharacterTrend(name, realm)
    local latest = rows[#rows]
    if type(latest) ~= "table" or not IsValidISODate(latest.date) then return nil end
    local score = ValidScore(latest.rio)
    if not score then return nil end
    return {
        score = score,
        date = latest.date,
        source = "trends",
        trust = "bot",
        observedAt = latest.timestamp,
    }
end

-- true iff candidate.date is not older than any date among others (nils
-- skipped). Used only for the self-authoritative shortcut below.
local function NewerOrEqualToAll(candidate, others)
    for _, c in ipairs(others) do
        if c and c.date > candidate.date then return false end
    end
    return true
end

local SOURCE_PRIORITY = { self = 1, peer = 2, members = 3, leaderboard = 4, trends = 5 }

local function CloneCandidate(c)
    return {
        score = c.score,
        date = c.date,
        source = c.source,
        trust = c.trust,
        seasonID = c.seasonID,
        observedAt = c.observedAt,
    }
end

-- The one place that decides "what is this character's best-known score
-- right now" — self/members/leaderboard/trends, in that tie-break order,
-- with a known-current-season self observation short-circuiting the whole
-- comparison when it's at least as new as every generated candidate. Pure:
-- no API reads, no SavedVariables writes, never throws, always returns a
-- fresh table (or nil for a completely unknown character).
function TOAST.ResolveCharacterScore(name, realm)
    if type(name) ~= "string" or name == "" then return nil end
    local normRealm = NormalizeRealm(realm)
    local currentSeasonID = CachedCurrentSeasonID()

    local selfC = BuildSelfCandidate(name, realm, currentSeasonID)
    local peerC = BuildPeerCandidate(name, realm, currentSeasonID)
    local membersC = BuildMembersCandidate(name, normRealm)
    local leaderboardC = BuildLeaderboardCandidate(name, normRealm)
    local trendsC = BuildTrendsCandidate(name, realm)

    local generated = {}
    if peerC then tinsert(generated, peerC) end
    if membersC then tinsert(generated, membersC) end
    if leaderboardC then tinsert(generated, leaderboardC) end
    if trendsC then tinsert(generated, trendsC) end

    if selfC then
        local selfIsCurrentSeason = selfC.seasonID ~= nil and currentSeasonID ~= nil
            and selfC.seasonID == currentSeasonID
        if selfIsCurrentSeason and NewerOrEqualToAll(selfC, generated) then
            return CloneCandidate(selfC)
        end
    end

    local all = {}
    if selfC then tinsert(all, selfC) end
    for _, c in ipairs(generated) do tinsert(all, c) end
    if #all == 0 then return nil end

    local best = all[1]
    for i = 2, #all do
        local c = all[i]
        if c.date > best.date then
            best = c
        elseif c.date == best.date then
            if c.score > best.score then
                best = c
            elseif c.score == best.score and SOURCE_PRIORITY[c.source] < SOURCE_PRIORITY[best.source] then
                best = c
            end
        end
    end

    return CloneCandidate(best)
end

-- The locally best-known leaderboard ordering: every TOAST.leaderboard
-- entry paired with its own TOAST.ResolveCharacterScore() result, then
-- re-sorted by that resolved score instead of trusting the bot-generated
-- entry.rank/entry.rio (which can go stale between bot exports whenever a
-- local observation or a fresher roster/leaderboard export knows better).
-- Pure, same as the resolver itself: no API reads, no SavedVariables
-- writes, never mutates entry/TOAST.leaderboard, and always a fresh array
-- of fresh row tables on every call.
function TOAST.GetResolvedLeaderboardSnapshot()
    local rows = {}
    if type(TOAST.leaderboard) ~= "table" then return rows end

    for i, entry in ipairs(TOAST.leaderboard) do
        if type(entry) == "table" then
            tinsert(rows, {
                entry = entry,
                resolved = TOAST.ResolveCharacterScore(entry.name, entry.realm),
                originalIndex = i,
            })
        end
    end

    -- Resolved rows first, sorted by score descending; equal scores (or
    -- rows that share "unresolved") fall back to generated array order via
    -- originalIndex, which also doubles as the tie-breaker that keeps
    -- table.sort's comparator a valid strict weak ordering.
    table.sort(rows, function(a, b)
        local aHas, bHas = a.resolved ~= nil, b.resolved ~= nil
        if aHas ~= bHas then return aHas end
        if aHas and a.resolved.score ~= b.resolved.score then
            return a.resolved.score > b.resolved.score
        end
        return a.originalIndex < b.originalIndex
    end)

    for i, row in ipairs(rows) do
        row.rank = i
    end

    return rows
end

local pendingCompletionRefresh = false

local ev = CreateFrame("Frame")
ev:RegisterEvent("ADDON_LOADED")
ev:RegisterEvent("CHALLENGE_MODE_MAPS_UPDATE")
ev:RegisterEvent("CHALLENGE_MODE_COMPLETED")
ev:SetScript("OnEvent", function(self, event, prefix)
    if event == "ADDON_LOADED" then
        if prefix ~= addonName then return end
        TOAST_DB = TOAST_DB or {}
        TOAST_DB.scoreObservations = TOAST_DB.scoreObservations or {}
        return
    end
    if event == "CHALLENGE_MODE_MAPS_UPDATE" then
        TOAST.RefreshOwnScore()
        return
    end
    if event == "CHALLENGE_MODE_COMPLETED" then
        if pendingCompletionRefresh then return end
        pendingCompletionRefresh = true
        C_Timer.NewTimer(2, function()
            pendingCompletionRefresh = false
            TOAST.RefreshOwnScore()
        end)
        return
    end
end)
