---@diagnostic disable: undefined-field, undefined-global
---@type string
local AddonName = ...
---@class Private
local Private = select(2, ...)

-- SHARE-01..06 (Phase 37): timeline share protocol + persistence core. Home of
-- the payload schema validator and the single persist/reset entry point that
-- both the wire-receive handler (Plan 37-02) and the late-joiner sync
-- (Plan 37-03) will funnel through. Ships in every packaged build (payload
-- carries only Name-Realm canonical keys, never per-account linking data).
--
-- Expected Private.* dependencies (all already loaded above in WildThings.toc):
-- Private.commHandlers/RegisterCommEvent (Init.lua), Private.CAPABILITIES/
-- SenderHasCapability (Access.lua), Private:CanonicalKey (RosterKeys.lua).

-- Schema version for the TIMELINE payload wire shape. Bump only alongside a
-- coordinated ValidateTimelinePayload change.
local SCHEMA_VERSION = 1

-- Exposed on Private (IN-01 review fix) so other files -- e.g. WildThings.lua's
-- /wt timelinetest dev harness -- reference the same source of truth instead
-- of duplicating a bare literal that could silently drift from SCHEMA_VERSION.
Private.TIMELINE_SCHEMA_VERSION = SCHEMA_VERSION

-- Size caps -- generous vs. a 20-27 raid, but bounded so a malformed/hostile
-- payload can never grow WildThingsSavedVars.timeline.payload unbounded.
local MAX_PLAYERS = 60
local MAX_BOSSES = 40
local MAX_LIST = 40

-- Per-field string length cap (WR-02 review fix): a payload conforming to
-- every array-size cap above could still smuggle a multi-megabyte string in
-- a single player key/boss name/instance field. Generous vs. a Name-Realm
-- key or boss title.
local MAX_STRING_LEN = 100

-- LIVE-01 prerequisite fix: enc.difficulty is the raw export difficulty
-- NAME string (e.g. "Mythic"), not a numeric-ID string -- tonumber() of it is
-- nil, which silently fails ValidateTimelinePayload's `type(boss.diff) == "number"`
-- check for every real payload. Map name -> numeric difficultyID here, once.
-- Mythic=16, Heroic=15, Normal=14, LFR=17 (Core.lua:134's own confirmed comment
-- + Wowpedia DifficultyID). Unknown/unmapped name -> nil -> that boss's payload
-- fails validation the same fail-closed way it does today -- not a regression.
-- Keys are lower-cased; LookupDifficultyID below normalizes the incoming name
-- (case + surrounding whitespace) before indexing, so a differently-cased
-- export value (e.g. "mythic", "MYTHIC") still resolves instead of silently
-- dropping that boss's diff (WR-01 review fix).
local DIFFICULTY_NAME_TO_ID = {
    mythic = 16,
    heroic = 15,
    normal = 14,
    lfr = 17,
}

-- Normalizes a difficulty NAME string for lookup: trims surrounding
-- whitespace and lower-cases. Returns nil (not a crash) for any non-string
-- input, preserving the fail-closed contract -- an unrecognized/nil name
-- still yields a nil diff, which ValidateTimelinePayload rejects.
local function LookupDifficultyID(name)
    if type(name) ~= "string" then return nil end
    return DIFFICULTY_NAME_TO_ID[name:match("^%s*(.-)%s*$"):lower()]
end

-- CharInfoSpecID(charInfo): reads the numeric specID the generator resolved into
-- charInfo.specID. Returns 0 (never nil, never errors) for a missing charInfo, a
-- charInfo predating spec emission, or a non-number/negative value, so the build
-- path can never error and payload.specs can always default to a dense 0-filled
-- entry (ValidateDenseArray requirement, mirrors roles' "DAMAGER" default).
--
-- This replaces the former ResolveSpecID(classFile, specName), which matched the
-- generator's ENGLISH spec display name against GetSpecializationInfoForClassID's
-- LOCALIZED name. That comparison could only ever succeed on an English client --
-- on any other locale it silently returned 0 and no spec icon rendered. The
-- generator now resolves the id itself from the (class, English name) pair it
-- already holds, so nothing locale-dependent is left on this side.
local function CharInfoSpecID(charInfo)
    local specID = charInfo and charInfo.specID
    if type(specID) ~= "number" or specID < 0 then
        return 0
    end
    return specID
end

-- Returns the count n if t is a dense 1..n integer-keyed array with no extra
-- (hash/holey) keys and n <= maxLen; returns false otherwise. Never trust
-- Lua's `#` operator for this decision -- it is undefined/misleading for a
-- table with no sequential-integer keys (CR-01 review fix: a payload built
-- entirely of non-1-based keys made `#t` report 0, silently skipping every
-- size cap and the `for i = 1, #t` type-check loop below).
local function ValidateDenseArray(t, maxLen)
    if type(t) ~= "table" then return false end
    local n = 0
    for k in pairs(t) do
        if type(k) ~= "number" or k ~= math.floor(k) or k < 1 then return false end
        n = n + 1
    end
    if n > maxLen then return false end
    for i = 1, n do
        if t[i] == nil then return false end -- catches gaps `#` would hide
    end
    return n
end

-- Private:ValidateTimelinePayload(payload): fail-closed, guard-clause-per-line
-- schema predicate (SHARE-02/SHARE-03 gate). Returns true only if every rule
-- passes; returns `false, reason` on the first violation -- this is the sole
-- gate between an untrusted (wire OR /wt timelinetest) payload and any
-- WildThingsSavedVars write. Never mutates or deep-copies the payload.
--
-- WR-11 (38-REVIEW.md): the reason string exists for the LOCAL build path. A wire
-- payload failing validation is an attack or a corrupt message and is still discarded
-- silently -- the wire handlers call this as a bare predicate and drop the second
-- return. But BroadcastTimeline failing means a bug or a data problem in the officer's
-- own export, and "built payload failed validation" with no field name is close to
-- useless in a project whose QA model is manual in-game testing. Reasons name the
-- failing field and index; they never echo VALUES, so a hostile payload cannot use
-- them to inject text into anyone's chat frame.
function Private:ValidateTimelinePayload(payload)
    if type(payload) ~= "table" then return false, "payload is not a table" end
    if payload.v ~= SCHEMA_VERSION then return false, "schema version mismatch" end
    if type(payload.sharedAt) ~= "number" then return false, "sharedAt is not a number" end

    local numPlayers = ValidateDenseArray(payload.players, MAX_PLAYERS)
    if not numPlayers then return false, "players is not a dense array within its size cap" end
    for i = 1, numPlayers do
        local player = payload.players[i]
        -- WR-03 review fix: fail closed on a literal "|" -- both this field and
        -- boss.name below flow straight into FontString:SetText() (RaidTimelineScreen.lua),
        -- and WoW's text engine interprets "|c"/"|T...|t"/"|H...|h" escape sequences in any
        -- string passed to SetText, letting a crafted name corrupt layout or inject a
        -- texture/hyperlink into every raider's timeline page.
        if type(player) ~= "string" or #player > MAX_STRING_LEN or player:find("|", 1, true) then
            return false, "players[" .. i .. "] is not a plain string within the length cap"
        end
    end

    -- roles is additive + OPTIONAL (G-38-9 sub-issue 5, no SCHEMA_VERSION bump): a
    -- payload built before this change simply has no roles field and still validates.
    -- When present it must be dense, index-aligned to players, and string/length-capped
    -- exactly like players -- an untrusted/malformed roles array fails closed like any
    -- other bad field (T-38-09).
    if payload.roles ~= nil then
        local numRoles = ValidateDenseArray(payload.roles, MAX_PLAYERS)
        if not numRoles then return false, "roles is not a dense array within its size cap" end
        if numRoles ~= numPlayers then return false, "roles length does not match players" end
        for i = 1, numRoles do
            if type(payload.roles[i]) ~= "string" or #payload.roles[i] > MAX_STRING_LEN then
                return false, "roles[" .. i .. "] is not a string within the length cap"
            end
        end
    end

    -- specs is additive + OPTIONAL (G-38-15, no SCHEMA_VERSION bump): mirrors the roles
    -- block above -- a payload built before this change (or with no resolvable spec data)
    -- simply has no specs field and still validates. When present it must be dense,
    -- index-aligned to players, with every entry a number >= 0 (an integer specID; 0
    -- means "no spec"). A malformed/holey/non-numeric specs array fails closed like any
    -- other bad field (T-38-15-02).
    if payload.specs ~= nil then
        local numSpecs = ValidateDenseArray(payload.specs, MAX_PLAYERS)
        if not numSpecs then return false, "specs is not a dense array within its size cap" end
        if numSpecs ~= numPlayers then return false, "specs length does not match players" end
        for i = 1, numSpecs do
            if type(payload.specs[i]) ~= "number" or payload.specs[i] < 0 then
                return false, "specs[" .. i .. "] is not a number >= 0"
            end
        end
    end

    -- mains/mainRanks are additive + OPTIONAL (G-5nd-01, D-01, no SCHEMA_VERSION bump):
    -- mirrors the roles/specs blocks above -- a payload built before this change simply has
    -- neither field and still validates. mains[i] carries the owner's main character key;
    -- mainRanks[i] carries that character's 1-based position among the owner's characters.
    if (payload.mains ~= nil) ~= (payload.mainRanks ~= nil) then
        return false, "mains and mainRanks must both be present or both absent"
    end

    if payload.mains ~= nil then
        local numMains = ValidateDenseArray(payload.mains, MAX_PLAYERS)
        if not numMains then return false, "mains is not a dense array within its size cap" end
        if numMains ~= numPlayers then return false, "mains length does not match players" end
        for i = 1, numMains do
            -- The pipe rejection is not optional and has no precedent in roles/specs: those
            -- never reach FontString:SetText, but mains[i] is composed into the info-icon
            -- tooltip (Task 3) and reaches the text engine on every raider's screen. Same
            -- reasoning as players[i] (:133) and boss.name (:185) above.
            if type(payload.mains[i]) ~= "string" or #payload.mains[i] > MAX_STRING_LEN
                or payload.mains[i]:find("|", 1, true) then
                return false, "mains[" .. i .. "] is not a plain string within the length cap"
            end
        end
    end

    if payload.mainRanks ~= nil then
        local numRanks = ValidateDenseArray(payload.mainRanks, MAX_PLAYERS)
        if not numRanks then return false, "mainRanks is not a dense array within its size cap" end
        if numRanks ~= numPlayers then return false, "mainRanks length does not match players" end
        for i = 1, numRanks do
            -- Do not length-cap or value-cap a rank against MAX_STRING_LEN or any
            -- string-shaped bound. Real character lists (RosterAlts.lua) run past 90
            -- entries, so a legitimate rank exceeds 100. Integer-and-at-least-1 is the
            -- whole rule.
            if type(payload.mainRanks[i]) ~= "number"
                or payload.mainRanks[i] ~= math.floor(payload.mainRanks[i])
                or payload.mainRanks[i] < 1 then
                return false, "mainRanks[" .. i .. "] is not an integer >= 1"
            end
        end
    end

    local numBosses = ValidateDenseArray(payload.bosses, MAX_BOSSES)
    if not numBosses then return false, "bosses is not a dense array within its size cap" end

    for i = 1, numBosses do
        local boss = payload.bosses[i]
        local at = "bosses[" .. i .. "]"
        if type(boss) ~= "table" then return false, at .. " is not a table" end
        -- The likeliest real-world failure on the LOCAL build path: LookupDifficultyID
        -- knows only mythic/heroic/normal/lfr, so any other difficulty string leaves
        -- diff nil for that one boss and used to sink the whole share with no clue which.
        if type(boss.id) ~= "number" then return false, at .. ".id is not a number" end
        if type(boss.diff) ~= "number" then
            return false, at .. ".diff is not a number (unrecognized difficulty name?)"
        end
        if type(boss.name) ~= "string" or #boss.name > MAX_STRING_LEN or boss.name:find("|", 1, true) then
            return false, at .. ".name is not a plain string within the length cap"
        end
        if boss.instance ~= nil
            and (type(boss.instance) ~= "string" or #boss.instance > MAX_STRING_LEN) then
            return false, at .. ".instance is not a string within the length cap"
        end

        local numList = ValidateDenseArray(boss.list, MAX_LIST)
        if not numList then return false, at .. ".list is not a dense array within its size cap" end
        for j = 1, numList do
            local idx = boss.list[j]
            if type(idx) ~= "number" then return false, at .. ".list[" .. j .. "] is not a number" end
            if idx ~= math.floor(idx) then return false, at .. ".list[" .. j .. "] is not an integer" end
            if idx < 1 or idx > numPlayers then
                return false, at .. ".list[" .. j .. "] is out of players range"
            end
        end
    end

    return true
end

-- ============================================================
-- Per-boss pull tally: WildThingsSavedVars.timeline.pullCounts
-- (LIVE-02, made reload-safe by debug session dashboard-pull-count-reload, 2026-07-30)
--
-- This used to be a module-local `pullCounts = {}` -- in-memory, per-session, and
-- documented here as "NOT persisted -- no SavedVar, no migration" (an accepted
-- LIVE-02 per-client-divergence tradeoff). It therefore read 0 again after every
-- /reload, which the user rejected: the tally now gets EXACTLY tl.defeated's
-- lifecycle. Persisted beside it, and cleared beside it by the same two sites, on
-- the same two triggers -- a genuinely new boss plan (ApplyTimelinePayload's
-- isNewNight branch below) and the weekly lockout rolling over
-- (Private:ApplyTimelineLockoutReset). Neither reset RULE changed; only where the
-- numbers live. The fresh-install default is the `pullCounts = {}` leaf in
-- Migrations.lua's timeline table, and ApplyDefaults' recursion carries it to
-- existing installs, so there is no numbered migration and none is needed.
--
-- There is deliberately NO module-local alias left. Both reset sites cleared the old
-- local by REBINDING it (`pullCounts = {}`), so an alias kept alongside a persisted
-- table would have each rebind point the upvalue at a fresh table nobody saves and
-- nobody reads -- every later write would vanish silently, and only from the first
-- reset onwards, so it would look correct right up until it wasn't. Routing every
-- read and write through the tl field leaves no upvalue to rebind. A load-time alias
-- could not work anyway: this file executes well before wt:OnInitialize creates the
-- leaf, so it would capture nil.
--
-- Bounded by #payload.bosses (<= MAX_BOSSES = 40) on disk now, not just in memory:
-- HandleTimelineEncounterEnd only ever keys a boss present in tl.payload.bosses, and
-- both reset sites empty the whole table, so a key for a boss dropped from the plan
-- cannot accumulate across weeks. Never reaches the wire -- it sits on the tl leaf,
-- not inside tl.payload, and SanitizeTimelinePayload copies only schema fields, so
-- it can neither leak outwards nor be injected by a hostile sender.
--
-- Every access type-checks the table rather than trusting it. A hand-edited or
-- corrupted SavedVariables file can make tl.pullCounts a non-table, and the same
-- defensiveness tl.defeated gets in ApplyTimelineLockoutReset applies here.
-- ============================================================

-- Returns true if two payloads' boss plans (id + difficulty, in order) are
-- identical -- i.e. represent the SAME raid night's encounter list, regardless
-- of sharedAt or player-roster contents (CR-02 review fix). A roster change
-- (someone reconnects, an alt swaps in) or a plain re-send of the same plan
-- must not, by itself, count as a new night; only an actually different set
-- of bosses/difficulties does. Player-list differences are deliberately NOT
-- compared here: tl.defeated is keyed by boss id, not by player, so who's on
-- the roster has no bearing on whether progress should carry over.
local function SameBossPlan(oldBosses, newBosses)
    if type(oldBosses) ~= "table" or type(newBosses) ~= "table" then return false end
    if #oldBosses ~= #newBosses then return false end
    for i = 1, #oldBosses do
        local a, b = oldBosses[i], newBosses[i]
        if not a or not b or a.id ~= b.id or a.diff ~= b.diff then return false end
    end
    return true
end

-- Private:ApplyTimelinePayload(payload): the single persist + reset-decision
-- entry point (SHARE-04 single entry, SHARE-06 reset rule). Always overwrites
-- tl.payload; resets tl.defeated/tl.sharedAt/tl.pullCounts ONLY when the
-- incoming payload's boss plan actually differs from the currently-stored
-- one (SameBossPlan above) -- NOT merely because sharedAt changed. This is
-- the CR-02 review fix: BuildTimelinePayload mints a fresh sharedAt on every
-- call, including a manual re-share of the SAME night (e.g. fixing a roster
-- typo, subbing in a replacement, or just re-sending because someone missed
-- it), so a raw sharedAt-changed check wiped everyone's recorded kills on any
-- re-share, not just a genuinely new night. A relayed re-send of the same
-- night's payload (Plan 37-03's TIMELINE_REQ reply, identical boss plan)
-- still never wipes an already-present client's recorded kills or
-- in-progress pull tally.
--
-- This used to be the ONLY place `defeated` (and, since WR-02, `tl.pullCounts`) was
-- ever reset. It is now ONE OF TWO: Private:ApplyTimelineLockoutReset below is the
-- other, and it fires on a different trigger entirely (the weekly raid lockout
-- rolling over, rather than the boss plan changing). Keep both in mind when
-- reasoning about when progress disappears -- the SameBossPlan rule here is
-- deliberately blind to the calendar, which is exactly why the second site had to
-- exist (debug session dashboard-kill-scope-and-reset, 2026-07-29).
-- SanitizeTimelinePayload(payload): returns a FRESH table holding only the
-- fields this file's schema defines, copied out of `payload` field by field.
--
-- CR-03 (38-REVIEW.md) / T-38-18-01: ValidateTimelinePayload inspects only the
-- keys it knows and never rejects EXTRA ones, and ApplyTimelinePayload used to
-- store the deserialized wire table by reference. So `payload.junk = <2 MB
-- string>` or `boss.junk = <deeply nested table>` passed every check and was
-- persisted verbatim into SavedVariables -- defeating the size caps this file
-- asserts at the top, and re-broadcast to the whole raid by ReshareTimeline
-- below, which sends the stored table. Copying instead of aliasing means only
-- schema fields can ever reach disk or the wire, whatever the sender attached.
--
-- Deliberately a sanitizer, NOT an unknown-key rejection in the validator:
-- `roles` and `specs` were both added as additive + OPTIONAL with no
-- SCHEMA_VERSION bump precisely so an older client tolerates a newer sender's
-- payload. Failing closed on unknown keys would invert that -- the next
-- additive field would make every not-yet-updated client reject the raid's
-- timeline outright. Ignoring unknown fields keeps that forward compatibility
-- while still bounding what gets stored.
--
-- Only ever called on an already-validated payload (both call sites gate on
-- ValidateTimelinePayload first), so the arrays are known dense and ipairs
-- walks all of them.
local function SanitizeTimelinePayload(payload)
    local clean = {
        v = payload.v,
        sharedAt = payload.sharedAt,
        players = {},
        bosses = {},
    }

    for i, player in ipairs(payload.players) do
        clean.players[i] = player
    end

    if payload.roles ~= nil then
        clean.roles = {}
        for i, role in ipairs(payload.roles) do
            clean.roles[i] = role
        end
    end

    if payload.specs ~= nil then
        clean.specs = {}
        for i, specID in ipairs(payload.specs) do
            clean.specs[i] = specID
        end
    end

    -- G-5nd-01 (D-01): omitting this is the silent-failure mode of the whole feature --
    -- ApplyTimelinePayload sanitizes BEFORE it stores, so a field missing here never reaches
    -- WildThingsSavedVars.timeline.payload on ANY client, including the sender's own, and
    -- the TIMELINE_REQ relay re-broadcasts the stored, stripped copy to late joiners.
    if payload.mains ~= nil then
        clean.mains = {}
        for i, mainKey in ipairs(payload.mains) do
            clean.mains[i] = mainKey
        end
    end

    if payload.mainRanks ~= nil then
        clean.mainRanks = {}
        for i, rank in ipairs(payload.mainRanks) do
            clean.mainRanks[i] = rank
        end
    end

    for i, boss in ipairs(payload.bosses) do
        local cleanBoss = {
            id = boss.id,
            diff = boss.diff,
            name = boss.name,
            instance = boss.instance, -- optional; nil stays nil
            list = {},
        }
        for j, idx in ipairs(boss.list) do
            cleanBoss.list[j] = idx
        end
        clean.bosses[i] = cleanBoss
    end

    return clean
end

function Private:ApplyTimelinePayload(payload)
    local tl = WildThingsSavedVars.timeline
    -- Sanitize BEFORE the new-night comparison so every downstream read -- the
    -- comparison, the persisted table, and ReshareTimeline's re-broadcast --
    -- sees the same schema-only copy, never the raw wire table.
    payload = SanitizeTimelinePayload(payload)
    local isNewNight = (tl.payload == nil) or not SameBossPlan(tl.payload.bosses, payload.bosses)

    tl.payload = payload
    if isNewNight then
        tl.defeated = {}
        tl.sharedAt = payload.sharedAt
        tl.pullCounts = {}
        -- WR-12 (38-REVIEW.md): the screen's view state (expanded panel, pan offset) is
        -- built to survive repaints, but was surviving payload REPLACEMENT too -- a
        -- boss.id recurring across nights auto-expanded a panel nobody opened, on a strip
        -- still at the old night's offset. Reset it alongside defeated/pullCounts, on the
        -- same isNewNight decision. Guarded like the RefreshTimelineScreen call below:
        -- RaidTimelineScreen.lua may not be loaded.
        if Private.ResetTimelineView then
            Private:ResetTimelineView()
        end
    end

    -- No-op-safe: Private.RefreshTimelineScreen is defined in Phase 38. Guard
    -- so this phase's ApplyTimelinePayload is a true no-op on render until then.
    if Private.RefreshTimelineScreen then
        Private:RefreshTimelineScreen()
    end
end

-- ============================================================
-- Weekly-lockout reset
-- (debug session dashboard-kill-scope-and-reset, 2026-07-29)
--
-- tl.defeated lives in SavedVariables and, before this, NOTHING ever expired it:
-- the weekly raid-lockout reset (Wednesday in EU) frees every boss again while the
-- dashboard went on showing last week's kills.
--
-- ApplyTimelinePayload above cannot cover this, and re-sharing the plan is not a
-- workaround. Its isNewNight test is SameBossPlan, i.e. "the boss/difficulty list
-- changed" -- a condition orthogonal to the lockout rolling over, and FALSE for the
-- same tier shared again in the new week. So even a fresh re-share next Wednesday
-- left every marker standing. That is why this is a second reset site rather than a
-- new condition folded into the first one: the two triggers are unrelated, and
-- ApplyTimelinePayload's rule is deliberately roster/re-share-tolerant (CR-02).
--
-- The lockout window is identified by the epoch second its reset happens at:
-- GetServerTime() + C_DateAndTime.GetSecondsUntilWeeklyReset(). It is COMPUTED, not
-- written down, on purpose -- every client in the region derives the same instant
-- from the client's own schedule, so there is no hardcoded weekday, no reset hour,
-- no region table and nothing to rot when Blizzard moves a reset. Persisted as
-- tl.lockoutEnd (nil-by-design on a fresh install -- see Migrations.lua's timeline
-- leaf).
--
-- KEPT across a reset: tl.payload and tl.sharedAt. The shared boss plan is still the
-- plan; only the progress against it expires (user decision 2026-07-29 -- "clear the
-- Killed markers AND the pull counts, but KEEP the shared boss plan").
-- CLEARED: tl.defeated and tl.pullCounts. Both are persisted, which is what makes
-- this site necessary -- see the pull-tally banner further up this file for why the
-- tally moved out of memory onto the saved leaf (dashboard-pull-count-reload,
-- 2026-07-30).
-- ============================================================

local SECONDS_PER_WEEK = 7 * 24 * 60 * 60

-- Epoch second of the reset that closes the CURRENT lockout window, or nil when the
-- API is unavailable or returns something nonsensical. Nil is the fail-closed
-- answer and the caller must then leave BOTH the stamp and the recorded progress
-- untouched -- wiping a raid's kills off an unreadable clock would be a worse bug
-- than the one this fixes. The upper bound rejects a garbage reading as firmly as
-- the lower one: a real seconds-until-weekly-reset can never exceed one week.
local function CurrentLockoutEnd()
    local getSeconds = C_DateAndTime and C_DateAndTime.GetSecondsUntilWeeklyReset
    if not getSeconds then return nil end
    local seconds = getSeconds()
    if type(seconds) ~= "number" or seconds <= 0 or seconds > SECONDS_PER_WEEK then
        return nil
    end
    return GetServerTime() + seconds
end

-- Private:ApplyTimelineLockoutReset(): clears the dashboard's recorded progress when
-- the stored stamp names a reset that has already passed, then (re)stamps the
-- current window. Returns true only when it actually cleared recorded progress.
--
-- IDEMPOTENT, and that is a requirement rather than a nicety: the caller is
-- PLAYER_ENTERING_WORLD, which fires on every loading screen and not just at login.
-- Inside one lockout window every call after the first is a no-op. The upside of
-- that hook is that a player who never logs out still gets caught at their next
-- zone change instead of carrying last week's markers until they relog.
--
-- A saved file with NO stamp at all -- every install predating this change -- counts
-- as a DIFFERENT window, so the first login after the update clears once. That is
-- deliberate, not an oversight: the old shape recorded nothing about WHICH lockout
-- its markers belonged to, so they cannot be attributed to this week, and of the two
-- possible wrong answers a marker wrongly showing Killed is the worse one (it hides
-- a boss the raid still has to pull, whereas a marker wrongly showing open is
-- re-stamped by the next kill and is visible to everyone in the meantime).
function Private:ApplyTimelineLockoutReset()
    local tl = WildThingsSavedVars and WildThingsSavedVars.timeline
    if not tl then return false end

    local lockoutEnd = CurrentLockoutEnd()
    if not lockoutEnd then return false end

    -- Same window iff the stored stamp is still in the future AND is not absurdly
    -- far beyond the freshly computed one. The second clause is what keeps a
    -- corrupted or garbage far-future stamp from switching this check off
    -- permanently; a full week of slack is far more than the few seconds of read
    -- drift (or the hour a DST shift could move a reset) ever needs, while still
    -- being narrower than the 7-day gap between two real consecutive windows.
    local stored = tl.lockoutEnd
    if type(stored) == "number" and stored > GetServerTime()
        and stored <= lockoutEnd + SECONDS_PER_WEEK then
        return false
    end

    tl.lockoutEnd = lockoutEnd

    -- Measured BEFORE the clear, so a fresh install (or a second window with nothing
    -- recorded in the first) stamps silently instead of announcing a reset that threw
    -- nothing away.
    local hadProgress = (type(tl.defeated) == "table" and next(tl.defeated) ~= nil)
        or (type(tl.pullCounts) == "table" and next(tl.pullCounts) ~= nil)

    tl.defeated = {}
    tl.pullCounts = {}

    if not hadProgress then return false end

    -- The same two follow-ups ApplyTimelinePayload's isNewNight branch runs, for the
    -- same reason: the current boss has just moved back to the top of the plan, so a
    -- selection still pointing at a boss that is no longer defeated -- on a strip
    -- still panned to it -- would disagree with the dashboard it sits under. Both
    -- guarded exactly like the calls up there: RaidTimelineScreen.lua may not be
    -- loaded, and both are safe before the screen has ever been built.
    if Private.ResetTimelineView then
        Private:ResetTimelineView()
    end
    if Private.RefreshTimelineScreen then
        Private:RefreshTimelineScreen()
    end

    print("|cff99ff00Wild Things:|r new raid lockout -- dashboard kill markers and pull counts cleared, boss plan kept")
    return true
end

-- Private:BuildTimelinePayload(): builds an index-normalized payload, free of
-- any per-account identity-linking data, from the dev-only parsed roster
-- export (SHARE-01). Defensively sources Private.parsedRosterExport --
-- lazily builds it once if absent but
-- Private.ParseRosterExport/Private.rosterExport exist (mirrors Roster.lua's
-- own lazy-build call), and returns nil (caller no-ops) if the dev Roster
-- authoring tier is entirely unavailable (packaged build) -- never errors.
-- players[] is dedup-populated exclusively from Private:CanonicalKey
-- (Name-Realm) tokens; this function never reads the dev-only per-account
-- alt-linking map (privacy, T-37-05). `instance` ships always-nil this phase
-- per RESEARCH.md Open Question 2 (Phase 38 sources real instance metadata).
function Private:BuildTimelinePayload()
    if not Private.parsedRosterExport and Private.ParseRosterExport and Private.rosterExport then
        Private.parsedRosterExport = Private:ParseRosterExport(Private.rosterExport)
    end
    if not Private.parsedRosterExport then return nil end

    local payload = {
        v = SCHEMA_VERSION, sharedAt = GetServerTime(),
        players = {}, roles = {}, specs = {}, bosses = {},
    }
    local keyToIndex = {}

    -- G-5nd-01 (Dashboard alt resolution, D-01): read the two dev-only battletag-linking
    -- maps ONCE, guarded exactly the way ResolveAltSubstitutions guards them
    -- (RosterArrange.lua:564-568). Absent on a packaged build -- that absence is what makes
    -- payload.mains/mainRanks never get attached below (D-06, byte-for-byte older-client
    -- payload).
    local btOf = Private.RosterAlts and Private.RosterAlts.charToBattletag
    local charsOf = Private.RosterAlts and Private.RosterAlts.battletagToChars
    local mains, mainRanks = {}, {}

    for _, enc in ipairs(Private.parsedRosterExport) do
        local list = {}
        -- WR-03 review fix: a malformed/legacy parsed-roster-export entry
        -- with invitelist == nil would otherwise raise a Lua error here,
        -- violating this function's "never errors" contract. Skip (empty
        -- list) any boss whose invitelist isn't a table.
        if type(enc.invitelist) == "table" then
            for _, token in ipairs(enc.invitelist) do
                local name, realm = token:match("^(.-)%-(.+)$")
                local key = Private:CanonicalKey(name or token, realm or Private:GetSelfRealm())
                local idx = keyToIndex[key]
                if not idx then
                    table.insert(payload.players, key)
                    idx = #payload.players
                    keyToIndex[key] = idx
                    -- G-38-9 sub-issue 5: role resolved ONLY from the sharer's own
                    -- class/role map (never the battletag-linking table) so the receiver
                    -- can render role icons/grouping. "DAMAGER" default on a nil/absent
                    -- charInfo entry keeps payload.roles dense (ValidateDenseArray
                    -- requirement) -- a discretionary, freely-changeable choice.
                    local charInfo = Private.RosterAlts and Private.RosterAlts.charInfo
                        and Private.RosterAlts.charInfo[key]
                    payload.roles[idx] = (charInfo and charInfo.role) or "DAMAGER"
                    -- G-38-15: specID read ONLY from the sharer's own class/spec map
                    -- (same privacy boundary as role above). 0 (not nil) on a nil/absent
                    -- charInfo entry or unregenerated RosterAlts.lua keeps payload.specs
                    -- dense (ValidateDenseArray requirement) -- 0 means "no spec icon".
                    payload.specs[idx] = CharInfoSpecID(charInfo)

                    -- G-5nd-01 (D-01): owner identity + rank-within-owner. Dense default
                    -- FIRST -- key is its own owner at rank 1 -- covers a pug, a key absent
                    -- from charToBattletag, a battletag with no battletagToChars entry, and
                    -- a scan miss, all in one fallthrough. This default is mandatory, not
                    -- defensive politeness: a nil entry here makes the array holey,
                    -- ValidateDenseArray returns false, and BroadcastTimeline fails the
                    -- ENTIRE share.
                    mains[idx] = key
                    mainRanks[idx] = 1
                    if btOf and charsOf then
                        local bt = btOf[key]
                        local chars = bt and charsOf[bt]
                        if type(chars) == "table" then
                            -- Never re-sort: generate_rosteralts.py encodes mains as
                            -- "a"-prefixed and alts as "b"-prefixed order tags, natural-
                            -- sorted, so position 1 is the main by construction
                            -- (RosterArrange.lua:574-577 states the same rule).
                            for p, altKey in ipairs(chars) do
                                if altKey == key then
                                    mains[idx] = chars[1]
                                    mainRanks[idx] = p
                                    break
                                end
                            end
                        end
                    end
                end
                table.insert(list, idx)
            end
        end

        table.insert(payload.bosses, {
            id = tonumber(enc.id),
            diff = LookupDifficultyID(enc.difficulty),
            name = enc.name,
            list = list,
            instance = nil,
        })
    end

    -- G-5nd-01 (D-01, D-06): attached ONLY behind one guard on both maps -- a lazily
    -- attached pair is unambiguous about whether the feature is live, whereas a literal
    -- field in the payload{} table above set to nil here would not be. Absent maps means
    -- the fields never exist, which IS the byte-for-byte older-client payload, made literal
    -- at the source rather than patched at the receiver. The battletag itself never reaches
    -- this table: `bt` above is read as a lookup key and discarded.
    if btOf and charsOf then
        payload.mains = mains
        payload.mainRanks = mainRanks
    end

    return payload
end

-- Returns the first boss.id in payload.bosses order whose timeline.defeated
-- entry is falsy -- pure computation, no separate persisted "current boss" field.
local function GetCurrentBossID(tl)
    if not tl.payload or not tl.payload.bosses then return nil end
    for _, boss in ipairs(tl.payload.bosses) do
        if not (tl.defeated and tl.defeated[boss.id]) then
            return boss.id
        end
    end
    return nil
end

-- Delegate target for mainFrame:ENCOUNTER_END (Core.lua). Fail-closed: no
-- shared plan / no matching boss / off-difficulty / not an official Wild Things
-- raid -> silent no-op. On success==1 at the plan's exact difficulty, persists
-- the Defeated flip (LIVE-01). On success==0 at the plan's exact difficulty for
-- the CURRENT boss, tallies a persisted pull count into tl.pullCounts (LIVE-02,
-- reload-safe since dashboard-pull-count-reload 2026-07-30).
-- Either way, guard-calls the no-op-safe RefreshTimelineScreen so an open page
-- repaints live.
--
-- The raid-context gate is the fix for debug session dashboard-kill-scope-and-reset
-- (2026-07-29). Before it, the three guards below were a CONTENT filter and nothing
-- else -- Core.lua:216/:257 forwards every ENCOUNTER_END of the whole session with
-- no group context, and BroadcastTimeline deliberately keeps a stored plan armed
-- outside a raid, so killing a planned boss at the planned difficulty in ANY group
-- (a pug, an alt run, a twink run) wrote the guild's Killed marker. Reported live:
-- Chimaerus, 2026-07-28.
function Private:HandleTimelineEncounterEnd(encounterID, difficultyID, success)
    local tl = WildThingsSavedVars.timeline
    if not tl or not tl.payload or not tl.payload.bosses then return end

    local boss
    for _, b in ipairs(tl.payload.bosses) do
        if b.id == encounterID then boss = b break end
    end
    if not boss then return end
    if difficultyID ~= boss.diff then return end

    -- Placed AFTER the boss/difficulty match on purpose, not before it. Only an
    -- encounter that would OTHERWISE have been recorded reaches here, so the roster
    -- sweep inside the gate is paid at most once per planned boss instead of on
    -- every world-boss/dungeon/other-tier ENCOUNTER_END, and the notice below can
    -- never fire for content the dashboard has no opinion about.
    --
    -- Covers BOTH branches below deliberately: a wipe in a pug run must not add to
    -- the guild dashboard's pull count either -- "the dashboard only updates in an
    -- official WT raid" is the whole requirement, and a pull count is an update.
    --
    -- Existence-guarded like every cross-file call in this codebase, but fail-OPEN
    -- rather than fail-closed, which is the opposite of the guards above and is a
    -- considered choice: RaidSnapshot.lua ships in every build and sits ABOVE this
    -- file in WildThings.toc, so a nil here can only mean a load-order regression,
    -- never a packaged-build strip. Degrading to the previous ungated behaviour
    -- beats silently recording nothing at all for the rest of the raid night.
    if Private.IsOfficialWildThingsRaid then
        local isOfficialRaid, reason = Private:IsOfficialWildThingsRaid()
        if not isOfficialRaid then
            -- Notice for a KILL only. A rejected wipe is the same decision, but it
            -- would repeat on every single pull of a pug run through the same tier,
            -- and the pull count it declines to add is invisible anyway. A KILL
            -- silently missing from the dashboard is the thing a raider would report
            -- as a bug, so that one is worth one line of chat.
            if success == 1 then
                print(("|cff99ff00Wild Things:|r %s kill not recorded on the dashboard -- %s")
                    :format(tostring(boss.name), tostring(reason)))
            end
            return
        end
    end

    if success == 1 then
        tl.defeated[boss.id] = true
        -- Carry the detail row forward onto the next boss -- the screen owns that call,
        -- since the selection is its own state. Must run AFTER the line above: "current
        -- boss" is the first non-defeated one, so it only names the next boss once this
        -- kill is recorded. Guarded exactly like the RefreshTimelineScreen call below
        -- (this file loads whether or not the screen file did).
        if Private.AdvanceTimelineSelectionAfterKill then
            Private:AdvanceTimelineSelectionAfterKill()
        end
    elseif success == 0 and boss.id == GetCurrentBossID(tl) then
        -- Self-healing rather than trusting the leaf: Migrations.lua defaults it on
        -- every login, so a non-table here means a hand-edited or corrupted saved
        -- file. Replacing it beats erroring mid-encounter or dropping every
        -- subsequent pull of the night on the floor.
        if type(tl.pullCounts) ~= "table" then tl.pullCounts = {} end
        tl.pullCounts[boss.id] = (tl.pullCounts[boss.id] or 0) + 1
    else
        return -- neither branch matched -- nothing changed, skip the repaint
    end

    if Private.RefreshTimelineScreen then
        Private:RefreshTimelineScreen()
    end
end

-- Read accessor for the UI layer (Plan 39-02 render consumes this, never
-- tl.pullCounts directly). Returns 0 (not nil) for a boss with no tallied wipes in
-- the current lockout window. Defensive on every hop -- same shape as
-- GetTimelineCurrentBossID below -- so a render before wt:OnInitialize has created
-- the leaf, or a corrupted saved file holding a non-table, reads 0 instead of
-- erroring inside the screen's repaint. Deliberately a pure read: unlike the write
-- site it never repairs the leaf, because a repaint must not mutate SavedVariables.
function Private:GetTimelinePullCount(bossID)
    local tl = WildThingsSavedVars and WildThingsSavedVars.timeline
    local counts = tl and tl.pullCounts
    if type(counts) ~= "table" then return 0 end
    return counts[bossID] or 0
end

-- Read accessor: the dungeonEncounterID of the first non-defeated boss in
-- payload.bosses order, or nil if no shared plan or all bosses defeated.
function Private:GetTimelineCurrentBossID()
    return GetCurrentBossID(WildThingsSavedVars.timeline or {})
end

-- Private:BroadcastTimeline(): gated Share entry point (SHARE-01). Cosmetic/
-- local gate only -- Private:ViewerHasCapability("timeline.share") decides
-- whether THIS client offers the Share action; the receiver independently
-- re-checks via SenderHasCapability (the TIMELINE handler below), never
-- trusting this gate. Validates the locally-built payload through the same
-- ValidateTimelinePayload gate a wire payload would face (a bug in the local
-- builder is caught exactly like a bad wire payload -- only the SENDER-
-- capability check is skipped on the local path, never validation), then
-- broadcasts AND self-populates: DispatchCommEvent's self-skip
-- (Transmission.lua) means the admin never receives their own broadcast, so
-- the self-populate call is mandatory, not redundant.
-- IsInRaid() is NOT a precondition any more (user request): outside a raid the
-- action still builds, validates and self-populates the local timeline, and
-- only the wire send is skipped -- there is no group to send to, and
-- SendCommMessage on "RAID" with no raid is a Blizzard error, not a no-op. The
-- second return value reports which of the two happened ("broadcast" vs
-- "local") so the caller can word its chat message accordingly.
-- WR-04 review fix: returns true, mode on success or false, reason on any
-- failure (instead of a bare silent `return`) so the button handler can
-- surface a chat message either way -- previously every failure path here was
-- indistinguishable from a successful, silent-by-design broadcast.
function Private:BroadcastTimeline()
    if not Private:ViewerHasCapability("timeline.share") then return false, "missing timeline.share capability" end

    local payload = Private:BuildTimelinePayload()
    if not payload then return false, "no roster export available to build a timeline from" end
    local ok, reason = Private:ValidateTimelinePayload(payload)
    if not ok then
        return false, "built payload failed validation: " .. (reason or "unknown")
    end

    local inRaid = IsInRaid()
    if inRaid then
        Private:Broadcast("TIMELINE", "RAID", payload)
    end
    Private:ApplyTimelinePayload(payload)
    return true, inRaid and "broadcast" or "local"
end

-- File-local, shared by the TIMELINE handler's suppression cancel below and
-- the TIMELINE_REQ handler's first-responder debounce (declared here, before
-- both RegisterCommEvent closures, so the same upvalue is captured by each).
local pendingReplyTimer = nil

-- TIMELINE comm receive handler (SHARE-02, the phase's attack surface).
-- Registered at file-load time (Private.commHandlers already exists via
-- Init.lua); the handler body only runs later, at actual comm-receive time,
-- reached exclusively through Transmission.lua's DispatchCommEvent chokepoint
-- (self-skip + in-raid ValidateCommSender already applied before this runs --
-- TIMELINE distributes over RAID, so no GUILD_WHITELIST entry is needed).
-- Fail-closed, non-negotiable guard order: SenderHasCapability (the
-- AUTHORITATIVE trust boundary -- the sender's own client-asserted identity/
-- rank, or anything read from the payload itself, is NEVER trusted here) ->
-- ValidateTimelinePayload (schema/size/index bounds) -> ApplyTimelinePayload
-- (persist, last). Any failure is a silent return -- no chat, no partial
-- SavedVars write. Do NOT call ViewerHasCapability here -- it is a cosmetic,
-- bypassable, send-side-only gate.
-- SHARE-06 suppression hook (first-responder debounce, T-37-03): observing a
-- TRUSTED, VALID wire TIMELINE cancels our own scheduled TIMELINE_REQ reply,
-- if one is pending -- another holder answered first, so ours is redundant.
-- This runs AFTER the trust/validate gate (WR-01 review fix: cancelling on
-- a raw, unauthenticated TIMELINE let any raid member suppress a legitimate
-- pending reply with a garbage message) -- it never runs before it.
Private:RegisterCommEvent("TIMELINE", function(sender, payload)
    if not Private:SenderHasCapability(sender, "timeline.share") then return end
    if not Private:ValidateTimelinePayload(payload) then return end

    if pendingReplyTimer then
        pendingReplyTimer:Cancel()
        pendingReplyTimer = nil
    end

    Private:ApplyTimelinePayload(payload)
end)

-- TIMELINE_REQ comm receive handler + first-responder debounce (SHARE-06,
-- T-37-03 DoS mitigation). Registered at file-load time, same shape as the
-- TIMELINE handler above -- no new transport, no WHISPER case added to
-- ValidateCommSender. Fail-closed: replies only if THIS client is an
-- authorized holder (ViewerHasCapability is the correct check here -- we are
-- the one about to send, and the receiving client independently re-runs the
-- authoritative SenderHasCapability through the ordinary TIMELINE handler
-- above when our reply arrives) AND actually holds a stored payload. At most
-- one reply is scheduled at a time (pendingReplyTimer guard, set immediately);
-- the randomized 0-1.5s window (RESEARCH.md Open Question 3) plus the
-- TIMELINE handler's cancel-on-observed-reply above bounds a room full of
-- authorized holders to effectively one reply. C_Timer.NewTimer (never
-- C_Timer.After, which has no :Cancel()) is required for suppression --
-- PartyMode.lua's cancellable-timer precedent. The reply relays the stored
-- payload VERBATIM (unchanged sharedAt) over the same TIMELINE event/handler,
-- so ApplyTimelinePayload's isNewNight check is false on receivers that
-- already hold this night's plan -- defeated is never reset here. The reset sites
-- are ApplyTimelinePayload (Plan 37-01) and Private:ApplyTimelineLockoutReset
-- (dashboard-kill-scope-and-reset, 2026-07-29); neither is reachable from this
-- relay, which is the property that matters here.
--
-- WR-03 (38-REVIEW.md): pendingReplyTimer debounces CONCURRENT requests, not repeated
-- ones -- once the timer fires it nils itself, so the next request schedules another
-- full-payload broadcast. Sending TIMELINE_REQ needs no capability at all, only raid
-- membership, so any raid member (or a client stuck in a reload loop) could drive the
-- whole payload onto the addon channel roughly every 1.5s indefinitely: small request
-- in, large payload out, to everyone. REPLY_COOLDOWN bounds the reply RATE on top of
-- the existing concurrency guard. A legitimate late joiner is unaffected -- they are
-- answered by the first holder off cooldown, and RequestTimeline below only fires for
-- a client holding no plan at all.
local REPLY_COOLDOWN = 30
local lastReplyAt = 0

Private:RegisterCommEvent("TIMELINE_REQ", function(sender)
    if not Private:ViewerHasCapability("timeline.share") then return end
    if WildThingsSavedVars.timeline.payload == nil then return end
    if pendingReplyTimer then return end
    if GetTime() - lastReplyAt < REPLY_COOLDOWN then return end

    pendingReplyTimer = C_Timer.NewTimer(math.random() * 1.5, function()
        pendingReplyTimer = nil
        -- Up to 1.5s elapsed since the request arrived; re-check raid membership rather
        -- than broadcasting to a "RAID" distribution we may have left in the meantime.
        if not IsInRaid() then return end
        lastReplyAt = GetTime()
        Private:Broadcast("TIMELINE", "RAID", WildThingsSavedVars.timeline.payload)
    end)
end)

-- Private:RequestTimeline(): late-joiner catch-up trigger (SHARE-06). A no-op
-- unless the client currently holds no stored plan AND is in a raid -- once a
-- payload exists this never fires again, so a room full of reloaders/late
-- joiners cannot spam TIMELINE_REQ. Broadcasts over the same RAID distribution
-- the TIMELINE handler already trusts; the reply (below) relays the holder's
-- stored payload verbatim, so this never disturbs an already-present client's
-- recorded kills (SHARE-06's no-wipe guarantee rides on ApplyTimelinePayload's
-- sharedAt-diff, not on anything in this function).
function Private:RequestTimeline()
    if WildThingsSavedVars.timeline.payload ~= nil then return end
    if not IsInRaid() then return end

    Private:Broadcast("TIMELINE_REQ", "RAID")
end

-- Dedicated persistent frame for the late-joiner trigger (SHARE-06). Timeline.lua
-- owns its own frame (RosterArrange.lua combatResumeFrame / UI/WindowRaiser.lua
-- raiserFrame idiom) instead of extending Core.lua's mainFrame trampoline, so
-- this stays a surgical, single-file addition -- Core.lua is untouched by this
-- plan. WildThingsSavedVars is already populated by PLAYER_ENTERING_WORLD time
-- (well after wt:OnInitialize), so the payload read above is safe.
-- Also hosts the weekly-lockout expiry check (dashboard-kill-scope-and-reset), which
-- needs the same "SavedVariables are populated, the client is live" moment and would
-- otherwise be a second frame on the same event for no reason.
local timelineRequestFrame = CreateFrame("Frame")
timelineRequestFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
timelineRequestFrame:SetScript("OnEvent", function()
    -- Two separate safecalls rather than one wrapping both: an error in either must
    -- not swallow the other, and they are genuinely independent -- an unreadable
    -- reset clock has nothing to do with the late-joiner sync. Order is not
    -- load-bearing either: the expiry deliberately KEEPS tl.payload, so
    -- RequestTimeline's "only when no plan is stored" no-op is unaffected by it.
    Private.safecall(function() Private:ApplyTimelineLockoutReset() end)
    Private.safecall(function() Private:RequestTimeline() end)
end)
