-- WarBoard_Campaign.lua
-- The campaign + warfront clock.
--
-- A "campaign" is a fixed cycle ending on a Pacific PRIME-TIME evening (6 PM PST / 7 PM PDT —
-- not the 7 AM server reset, when almost nobody is online), so conclusions land while the war is
-- actually populated. Boundaries are anchored to fixed UTC instants — every client computes the
-- same start/end (and therefore the same winner) without relying on a region-specific reset.
--
-- The schedule changes at CUTOVER (see constants below): BEFORE it, strict back-to-back 72h
-- cycles; AT/AFTER it, fixed Monday- and Friday-evening endings — alternating 3-day (Fri→Mon)
-- and 4-day (Mon→Fri) campaigns. The index↔time mapping is piecewise across CUTOVER but stays
-- continuous and collision-free, so finalization/results/sync need no changes.
--
-- End flow: at 6 PM the campaign locks (no new war credit can land in it — fresh
-- activity belongs to the next campaign by its timestamp). It enters a short
-- reconciliation window during which late-synced activities that were COMPLETED
-- before the end (by their own timestamp, not when received) still count. When the
-- window closes the result is permanently locked and a winner declared.
--
-- Each day one "active warfront" zone is chosen at random (deterministically) and its
-- War Effort rewards are boosted +50%. Lifetime War Credit (Service Record rank) is
-- never reset — only the per-campaign contribution is.

local addonName, WB = ...

WB.Campaign = WB.Campaign or {}
local C = WB.Campaign

local DAY           = 86400
local CAMPAIGN_DAYS = 3
local CAMPAIGN_LEN  = CAMPAIGN_DAYS * DAY    -- exactly 72h

-- Fixed-UTC anchor sitting on a campaign boundary: Fri 2024-01-05 18:00 PST
-- (= 2024-01-06 02:00 UTC = epoch 1704506400). Every boundary is ANCHOR + k*72h, so
-- they recur every 3 days on a Pacific evening (6 PM PST / 7 PM PDT — still prime
-- time) and always land on the same weekday phase as the spec example
-- (Fri → Mon → Thu → Sun → …). Fixed UTC keeps all clients in agreement.
local ANCHOR        = 1704506400

-- ── Monday/Friday schedule transition ─────────────────────────────────────────────
-- At CUTOVER the schedule switches from strict 72h to fixed Mon/Fri evening endings, at one
-- global instant for everyone. CUTOVER MUST be a 72h boundary (ANCHOR + k*72h) so the handoff is
-- seamless — the last 72h campaign ends exactly as the first Mon/Fri one begins, no gap/overlap.
-- It must also land on a MONDAY evening (the schedule starts Mon→Fri), which for this anchor means
-- k ≡ 1 (mod 7): ANCHOR is a Friday boundary, +3 days lands on Monday. Still fixed-UTC, so no DST
-- math: the boundary sits at 02:00 UTC = 6 PM PST / 7 PM PDT. To reschedule, change CUTOVER only
-- (keep it a Monday-evening 72h boundary).
--
-- NOTE: unlike a Friday cutover, the first Mon/Fri campaign (Mon→Fri, 4 days) is NOT a 72h block,
-- so old-version clients diverge from it immediately — the result firewall in MergeResult handles
-- that, and the last pre-CUTOVER 72h campaign still syncs cleanly across versions.
local WEEK          = 7 * DAY
local CUTOVER       = 1782784800   -- Mon 2026-06-29 6 PM PST / 7 PM PDT  (Tue 2026-06-30 02:00 UTC)
                                   -- = end of the current 72h campaign, so the new schedule starts there
local K             = math.floor((CUTOVER - ANCHOR) / CAMPAIGN_LEN)   -- first Mon/Fri index (continuous)
C.EPOCH             = 2            -- campaign-SCHEDULE version (1 = legacy 72h, 2 = Mon/Fri).
                                   -- Separate from PROTO / RULESVER / RESULT_SCHEMA; bump on any
                                   -- future schedule change (and move CUTOVER forward).

C.WARFRONT_BONUS    = 0.5          -- +50% War Effort rewards in the active warfront
C.FINALIZE_WINDOW   = 30 * 60      -- reconciliation window after each end (30 min)

-- Warfront pool. Canonical names MUST match WarEffort_Objectives' ZONE_BY_TEXTURE
-- so the +50% lookup (zone-from-texture) lines up with the active warfront.
C.ZONES = {
    "Azshara", "Northern Barrens", "Southern Barrens", "Ashenvale",
    "Hillsbrad Foothills", "The Hinterlands", "Badlands",
}

-- Zone → title-card background art (reuses the War Effort objective textures).
C.ZONE_ART = {
    ["Azshara"]             = "Interface\\QuestionFrame\\Answer-WarBoard-Classic-Azshara.blp",
    ["Northern Barrens"]    = "Interface\\QuestionFrame\\Answer-Warboard-Classic-NorthernBarrens.blp",
    ["Southern Barrens"]    = "Interface\\QuestionFrame\\Answer-WarBoard-Classic-SouthernBarrens.blp",
    ["Ashenvale"]           = "Interface\\QuestionFrame\\Answer-WarBoard-Classic-Ashenvale.blp",
    ["Hillsbrad Foothills"] = "Interface\\QuestionFrame\\Answer-Warboard-Classic-HillsbradFoothills.blp",
    ["The Hinterlands"]     = "Interface\\QuestionFrame\\Answer-Warboard-Classic-Hinterlands.blp",
    ["Badlands"]            = "Interface\\QuestionFrame\\Answer-Warboard-Classic-Badlands.blp",
}

local db
local function EnsureDB()
    WarBoardCharDB = WarBoardCharDB or {}
    db = WarBoardCharDB   -- db.campaignIndex / db.lastResult seeded lazily
end

-- ── Clock ───────────────────────────────────────────────────────────────────────

-- [start, end) of campaign `idx` (defaults to current). Piecewise across CUTOVER: indices below K
-- are legacy 72h blocks; K and up are Mon/Fri. CUTOVER is a Monday, so phase 0 = Mon→Fri (4 days)
-- and phase 1 = Fri→Mon (3 days). Indices stay continuous and collision-free across the boundary.
function C.Window(idx)
    idx = idx or C.Index()
    if idx < K then
        local s = ANCHOR + idx * CAMPAIGN_LEN
        return s, s + CAMPAIGN_LEN
    end
    local localIdx = idx - K
    local wk       = math.floor(localIdx / 2)
    local phase    = localIdx % 2                       -- 0 = Mon→Fri (4d), 1 = Fri→Mon (3d)
    local s   = CUTOVER + wk * WEEK + (phase == 1 and 4 * DAY or 0)
    local len = (phase == 0) and (4 * DAY) or (3 * DAY)
    return s, s + len
end

-- Time-parameterized index — used by the audit/replay, where the campaign depends on a record's
-- OWN timestamp, not "now". Deterministic across clients; piecewise across CUTOVER (legacy 72h
-- before, Mon/Fri after) so a record always lands in the campaign its timestamp actually fell in.
function C.IndexForTime(t)
    t = tonumber(t) or time()
    if t < CUTOVER then
        return math.floor((t - ANCHOR) / CAMPAIGN_LEN)
    end
    local wk    = math.floor((t - CUTOVER) / WEEK)
    local off   = (t - CUTOVER) - wk * WEEK
    local phase = (off < 4 * DAY) and 0 or 1            -- first 4 days = Mon→Fri, rest = Fri→Mon
    return K + wk * 2 + phase
end

-- Campaign index (block containing `now`) and its [start, end) boundaries — all derived from
-- IndexForTime/Window, so they follow the schedule automatically on both sides of CUTOVER.
function C.Index()           return C.IndexForTime(time()) end
function C.StartTime()       local s = C.Window(C.Index()); return s end
function C.EndTime()         local _, e = C.Window(C.Index()); return e end
function C.SecondsUntilEnd() return math.max(0, C.EndTime() - time()) end
function C.DayIndexAt(t)   return math.floor(((tonumber(t) or time()) - ANCHOR) / DAY) end

-- The active warfront rotates daily, aligned to the same evening boundary (so every
-- 3rd rotation is also a campaign boundary).
function C.DayIndex() return math.floor((time() - ANCHOR) / DAY) end
function C.SecondsUntilWarfrontChange()
    return math.max(0, ANCHOR + (C.DayIndex() + 1) * DAY - time())
end

-- ── Finalization & results ───────────────────────────────────────────────────────

-- For FINALIZE_WINDOW after a campaign ends, the JUST-ENDED campaign is "Finalizing
-- Results": locked to new contribution (new activity belongs to the new campaign by
-- timestamp) but still gathering late-synced records whose timestamp predates the end.
function C.SecondsIntoCampaign()   return time() - C.StartTime() end
function C.IsFinalizing()          return C.SecondsIntoCampaign() < C.FINALIZE_WINDOW end
function C.SecondsUntilFinalized() return math.max(0, C.StartTime() + C.FINALIZE_WINDOW - time()) end
function C.State()                 return C.IsFinalizing() and "finalizing" or "active" end

-- The window the Leader Board / War Contribution should display right now: the
-- previous (still-settling) campaign while finalizing, else the current one (open).
function C.ViewWindow()
    if C.IsFinalizing() then
        return C.Window(C.Index() - 1)      -- previous campaign's TRUE window (lengths vary under
                                            -- Mon/Fri, so not start − CAMPAIGN_LEN)
    end
    return C.StartTime(), nil               -- current campaign, open-ended
end

-- Winner of campaign `idx`: faction with the most war credit in its window.
-- Returns winner ("Horde"/"Alliance"/"Tie"/nil), hordeWC, allianceWC.
function C.WinnerOf(idx)
    if not (WB.Ledger and WB.Ledger.FactionWarCreditBetween) then return nil, 0, 0 end
    local s, e = C.Window(idx)
    local h, a
    if WB.Verifier then
        -- Prefer deep-audited (accepted-only) totals; fall back to provisional if the
        -- audit hasn't populated this window yet (defensive — LockResults runs it first).
        h = WB.Ledger.FactionWarCreditBetween("Horde",    s, e, true)
        a = WB.Ledger.FactionWarCreditBetween("Alliance", s, e, true)
        if h == 0 and a == 0 then
            h = WB.Ledger.FactionWarCreditBetween("Horde",    s, e, false)
            a = WB.Ledger.FactionWarCreditBetween("Alliance", s, e, false)
        end
    else
        h = WB.Ledger.FactionWarCreditBetween("Horde",    s, e, false)
        a = WB.Ledger.FactionWarCreditBetween("Alliance", s, e, false)
    end
    -- Fold in kill WC (kept outside the ledger) for this campaign — the same figure the live bar
    -- showed, so the locked result matches what players watched climb. Baked into the stored result
    -- at LockResults, so pruning the tally later can't change it.
    local kt = WB.KillTally
    if kt and kt.FactionWC then
        h = h + kt.FactionWC("Horde", idx)
        a = a + kt.FactionWC("Alliance", idx)
    end
    if h == 0 and a == 0 then return nil, h, a end
    if h == a then return "Tie", h, a end
    return (h > a) and "Horde" or "Alliance", h, a
end

-- Persisted result of the last fully-locked campaign (for display). nil until one locks.
function C.LastResult() return db and db.lastResult or nil end

-- ── Warfront selection ───────────────────────────────────────────────────────────

-- Deterministic per-day pick (Knuth multiplicative hash → stable across clients).
local function RawPick(day)
    local h = ((day + 1) * 2654435761) % 1000003
    return (h % #C.ZONES) + 1
end

function C.WarfrontIndex(day)
    day = day or C.DayIndex()
    local idx = RawPick(day)
    if idx == RawPick(day - 1) then          -- never the same zone two days running
        idx = (idx % #C.ZONES) + 1
    end
    return idx
end

function C.ActiveWarfront(day)        return C.ZONES[C.WarfrontIndex(day)] end
function C.ActiveWarfrontTexture(day) return C.ZONE_ART[C.ActiveWarfront(day)] end

-- Is `zone` today's active warfront? (its War Effort rewards get +50%.)
function C.IsActiveWarfront(zone)
    return zone ~= nil and C.ActiveWarfront() == zone
end
function C.RewardMultiplier(zone)
    return C.IsActiveWarfront(zone) and (1 + C.WARFRONT_BONUS) or 1
end

-- Warfront / multiplier AT a given timestamp (deterministic; for the reconciliation
-- audit, which recomputes each historical record's legal warfront bonus).
function C.ActiveWarfrontAt(t) return C.ActiveWarfront(C.DayIndexAt(t)) end
function C.RewardMultiplierAt(zone, t)
    return (zone ~= nil and C.ActiveWarfrontAt(t) == zone) and (1 + C.WARFRONT_BONUS) or 1
end

-- ── Weekly flip zones (Val ⇄ Naigtal) ────────────────────────────────────────────
-- Two current-expansion player-kill fronts that swap every WEEKLY reset (Tuesday): only one is
-- "up" at a time, so only its objective is offered. Anchored to a fixed UTC instant on a Tuesday
-- reset so every client agrees on which zone is up (same approach as the campaign clock).
local FLIP_ANCHOR = 1782226800            -- Tue 2026-06-23 15:00 UTC (US reset) — Val is up this week
local FLIP_ORDER  = { [0] = "Val", [1] = "Naigtal" }   -- parity 0 = the anchor week (Val)

-- Which flip zone ("Val" / "Naigtal") is up right now — straight from the weekly schedule.
function C.ActiveFlipZone()
    local week = math.floor((time() - FLIP_ANCHOR) / WEEK)
    return FLIP_ORDER[week % 2]
end

-- ── Rollover & finalization flow ─────────────────────────────────────────────────

local wasFinalizing  -- module-local; refresh windowed views on the finalize→active edge

local function RefreshViews()
    if not WB.UI then return end
    if WB.UI.RefreshLeaderboard then WB.UI.RefreshLeaderboard() end
    if WB.UI.RefreshWarEffort   then WB.UI.RefreshWarEffort()   end
    if WB.UI.RefreshCampaign    then WB.UI.RefreshCampaign()    end
end

local MAX_HISTORY = 60   -- how many past campaign results to retain

-- ── Finalized campaign results: locked, shared truth ────────────────────────────
-- A finalized result is the LOCKED outcome of a campaign. When a campaign closes and the
-- reconciliation window passes, each client computes its result from its AUDITED ledger and
-- stamps it with how complete that tally is (recordCount of accepted records), then gossips it.
-- Peers keep the STRONGEST result per campaign (more accepted records = more complete = wins),
-- so a late/stale client adopts the community's complete result instead of declaring a wrong
-- winner from its own partial ledger. Past campaigns DISPLAY from this stored result, never a
-- fresh recalculation. Bump RESULT_SCHEMA if the stored/synced shape ever changes.
C.RESULT_SCHEMA = 1

-- This client's stable identity — the result's `source` and the deterministic conflict tiebreaker.
function C.SourceName()
    if WB.Ledger and WB.Ledger.MyIdentity then
        local n, r = WB.Ledger.MyIdentity()
        if n then return n .. "-" .. (r or "") end
    end
    return (UnitName and UnitName("player")) or "?"
end

-- The stored finalized result for campaign `idx` (the source of truth for display), or nil.
-- IMPORTANT: callers should prefer this over WinnerOf() for any PAST campaign — once a result
-- is finalized we never recompute the winner from the (possibly stale/partial) local ledger.
function C.HistoryEntry(idx)
    if not db or not db.campaignHistory then return nil end
    for _, e in ipairs(db.campaignHistory) do
        if e.index == idx then return e end
    end
    return nil
end
C.ResultFor = C.HistoryEntry

local function HistoryHas(idx) return C.HistoryEntry(idx) ~= nil end

-- Map of finalized campaign index → its accepted-record completeness. Sent in the catch-up
-- request so peers reply with anything we're MISSING *or only hold more weakly* — this is what
-- lets a client that already locked a thin just-ended result still pull the community's
-- complete one (it never would if we only asked for indices we lacked entirely).
function C.FinalizedDigest()
    local out = {}
    if db and db.campaignHistory then
        for _, e in ipairs(db.campaignHistory) do
            if e.index ~= nil and e.finalized then out[e.index] = tonumber(e.recordCount) or 0 end
        end
    end
    return out
end

-- Past campaign results, newest first (for the Campaign Results window).
function C.History()
    if not db or not db.campaignHistory then return {} end
    local out = {}
    for i = #db.campaignHistory, 1, -1 do out[#out + 1] = db.campaignHistory[i] end
    return out
end

-- Testing aid: append a fake result so the Campaign Results popup can be previewed.
function C.AddTestResult()
    if not db then EnsureDB() end
    db.campaignHistory = db.campaignHistory or {}
    local idx = C.Index() - (#db.campaignHistory + 1)
    local s, e = C.Window(idx)
    local h = 1575 + (#db.campaignHistory * 125)
    local a = 620 + (#db.campaignHistory * 90)
    local winner = (h == a) and "Tie" or ((h > a) and "Horde" or "Alliance")
    local entry = { index = idx, winner = winner, horde = h, alliance = a, startT = s, endT = e, at = time() }
    db.campaignHistory[#db.campaignHistory + 1] = entry
    while #db.campaignHistory > MAX_HISTORY do table.remove(db.campaignHistory, 1) end
    db.lastResult = entry
    if WB.UI and WB.UI.RefreshCampaignResults then WB.UI.RefreshCampaignResults() end
    if WB.UI and WB.UI.RefreshCampaign then WB.UI.RefreshCampaign() end
    return entry
end

-- Conflict policy: does finalized result `cand` beat `cur` (the one we currently hold)?
-- Primary signal is COMPLETENESS — more accepted records means a more complete tally, so it
-- wins; this is what makes a late/stale client's thin result lose to the community's full one.
-- Equal completeness falls back to a deterministic tiebreaker (lowest source name) so every
-- client converges on the SAME entry. A weaker result never overrides a stronger one.
local function ResultBeats(cand, cur)
    if not cur then return true end
    local cc, uc = tonumber(cand.recordCount) or 0, tonumber(cur.recordCount) or 0
    if cc ~= uc then return cc > uc end
    local cs, us = tostring(cand.source or ""), tostring(cur.source or "")
    if cs ~= us then return cs < us end   -- deterministic — all clients pick the same one
    return false                          -- identical strength → keep what we have (no churn)
end

-- Debug-only surface for a conflicting (divergent but not stronger) peer result.
local function NoteResultConflict(cand, cur)
    if WB.Comm and WB.Comm.IsDebug and WB.Comm.IsDebug() then
        print(("%s |cff888888campaign %s: kept %s/%d (%s); rejected divergent %s/%d (%s)|r"):format(
            WB.TAG, tostring(cand.index),
            tostring(cur.winner), tonumber(cur.recordCount) or 0, tostring(cur.source),
            tostring(cand.winner), tonumber(cand.recordCount) or 0, tostring(cand.source)))
    end
end

-- The one-time chat / feed announcement for a freshly-concluded campaign.
local function AnnounceResult(entry)
    local winner, h, a = entry.winner, entry.horde or 0, entry.alliance or 0
    local line
    if winner == "Horde" then
        line = ("|cffff3030Horde wins the campaign!|r  (Horde %d WC vs Alliance %d WC)"):format(h, a)
    elseif winner == "Alliance" then
        line = ("|cff3399ffAlliance wins the campaign!|r  (Horde %d WC vs Alliance %d WC)"):format(h, a)
    elseif winner == "Tie" then
        line = ("The campaign ends in a stalemate \226\128\148 %d War Credit each."):format(h)
    else
        line = "The campaign ended with no War Credit recorded."
    end
    print(WB.TAG .. " " .. line)
    if WB.Alerts and WB.Alerts.AddSystem then
        local body
        if winner == "Horde" or winner == "Alliance" then
            body = ("%s wins the campaign! (Horde %d WC vs Alliance %d WC)"):format(winner, h, a)
        elseif winner == "Tie" then
            body = ("The campaign ends in a stalemate — %d War Credit each."):format(h)
        else
            body = "The campaign ended with no War Credit recorded."
        end
        WB.Alerts.AddSystem("campaign-result:" .. tostring(entry.index), "Campaign Results Locked", body)
    end
    -- Live on-screen banner for players currently logged in when the result locks.
    if WB.UI and WB.UI.ShowVictorPopup then
        WB.UI.ShowVictorPopup(entry)
    end
end

-- Adopt a finalized result (locally computed OR received from a peer), applying the conflict
-- policy. Returns true if it changed what we store (the caller then relays/broadcasts so the
-- strongest tally propagates). Announces once per campaign — only the FIRST time we learn a
-- RECENT conclusion (within the hour) — so a late-login catch-up of old results stays quiet.
function C.MergeResult(entry)
    if not db then EnsureDB() end
    if type(entry) ~= "table" or entry.index == nil then return false end
    if (tonumber(entry.schema) or 1) > C.RESULT_SCHEMA then return false end   -- newer shape we can't read

    -- Cross-schedule firewall. Only adopt a result whose window matches what WE compute for that
    -- index. A legacy 72h client's post-CUTOVER result carries 72h start/end that won't equal our
    -- Mon/Fri Window(index), so it's dropped; pre-CUTOVER campaigns (identical 72h windows in both
    -- schedules) still match and merge. Records sync freely regardless — this gate is only for the
    -- locked per-campaign RESULT.
    do
        local s, e = C.Window(entry.index)
        local es, ee = tonumber(entry.startT), tonumber(entry.endT)
        if entry.index >= K then
            -- New (Mon/Fri) era: demand an exact window match (rejects legacy windows + malformed).
            if not (es and ee) or es ~= s or ee ~= e then return false end
            if entry.epoch ~= nil and tonumber(entry.epoch) ~= C.EPOCH then return false end
        elseif es and ee and (es ~= s or ee ~= e) then
            return false                       -- legacy era: if it carries a window, it must match
        end
        if ee and ee > time() + 120 then return false end   -- reject a campaign that hasn't ended
    end

    db.campaignHistory = db.campaignHistory or {}
    local pos, cur
    for i, e in ipairs(db.campaignHistory) do
        if e.index == entry.index then pos, cur = i, e; break end
    end
    if not ResultBeats(entry, cur) then
        if cur and entry.ledgerHash and cur.ledgerHash and entry.ledgerHash ~= cur.ledgerHash then
            NoteResultConflict(entry, cur)   -- divergent but weaker/equal — keep ours
        end
        return false
    end
    local s, e = C.Window(entry.index)
    local stored = {
        index       = entry.index,
        winner      = entry.winner,
        horde       = tonumber(entry.horde) or 0,
        alliance    = tonumber(entry.alliance) or 0,
        startT      = tonumber(entry.startT) or s,
        endT        = tonumber(entry.endT)   or e,
        finalized   = true,
        finalizedAt = tonumber(entry.finalizedAt) or time(),
        recordCount = tonumber(entry.recordCount) or 0,
        ledgerHash  = entry.ledgerHash,
        source      = entry.source,
        schema      = tonumber(entry.schema) or C.RESULT_SCHEMA,
        epoch       = tonumber(entry.epoch) or ((entry.index >= K) and C.EPOCH or 1),
        at          = tonumber(entry.at) or time(),
    }
    local isNewIndex = (pos == nil)
    if pos then
        db.campaignHistory[pos] = stored        -- a stronger result REPLACES the weaker one in place
    else
        db.campaignHistory[#db.campaignHistory + 1] = stored
        table.sort(db.campaignHistory, function(x, y) return (x.index or 0) < (y.index or 0) end)
        while #db.campaignHistory > MAX_HISTORY do table.remove(db.campaignHistory, 1) end
    end
    db.lastResult = db.campaignHistory[#db.campaignHistory]   -- newest = highest index

    if isNewIndex and stored.endT and (time() - stored.endT) <= 3600 then
        AnnounceResult(stored)   -- first time we learn a recent conclusion (local lock or peer)
    end
    RefreshViews()
    if WB.UI and WB.UI.RefreshCampaignResults then WB.UI.RefreshCampaignResults() end
    return true
end

-- Lock a finished campaign: deep-audit its window, compute OUR result + completeness digest,
-- merge it under the conflict policy (a peer's stronger result still wins), then broadcast the
-- canonical result so the strongest tally reaches everyone. Computed at most once per session
-- per campaign; the stored result — not a recalculation — is the source of truth thereafter.
local lockedLocal = {}
function C.LockResults(idx)
    if not db then EnsureDB() end
    if lockedLocal[idx] then return end
    lockedLocal[idx] = true
    if WB.Verifier and WB.Verifier.RebuildAcceptedState then
        WB.Verifier.RebuildAcceptedState(idx)
    end
    local winner, h, a = C.WinnerOf(idx)
    local s, e = C.Window(idx)
    local count, hash = 0, nil
    if WB.Ledger and WB.Ledger.CampaignDigest then count, hash = WB.Ledger.CampaignDigest(s, e) end
    local entry = {
        index = idx, winner = winner, horde = h, alliance = a, startT = s, endT = e,
        finalized = true, finalizedAt = time(), recordCount = count, ledgerHash = hash,
        source = C.SourceName(), schema = C.RESULT_SCHEMA, epoch = C.EPOCH, at = time(),
    }
    C.MergeResult(entry)
    if WB.Comm and WB.Comm.BroadcastCampaignResult then
        WB.Comm.BroadcastCampaignResult(C.HistoryEntry(idx))   -- ours, or a stronger one we already held
    end
end

-- Detect a campaign rollover since the last check / login. Views are time-window
-- derived (ViewWindow), so they reset implicitly. On a fresh rollover we announce the
-- finalize → lock → winner flow; if we logged in well after one ended, we just lock it.
function C.CheckRollover()
    if not db then EnsureDB() end
    local cur      = C.Index()
    local ended    = cur - 1                  -- the campaign that most recently closed
    local firstRun = (db.campaignIndex == nil)

    if firstRun then
        db.campaignIndex = cur                -- seed; no history to finalize
    elseif cur ~= db.campaignIndex then
        db.campaignIndex = cur
        if WB.Bounties and WB.Bounties.PruneExpired then WB.Bounties.PruneExpired() end
        RefreshViews()
        if WB.UI then
            if WB.UI.RefreshActiveBounties then WB.UI.RefreshActiveBounties() end
            if WB.UI.RefreshBounties       then WB.UI.RefreshBounties()       end
        end
        if C.IsFinalizing() and WB.Alerts and WB.Alerts.AddSystem then
            -- Just ended: announce finalization; the lock/winner fires below once the
            -- window closes (late timestamped records keep arriving until then).
            WB.Alerts.AddSystem("campaign-end:" .. ended, "Campaign Ended — Finalizing Results",
                "The campaign has ended and a new one has begun. Tallying late-reported activity before the result locks…")
        end
    end

    -- Lock + declare the winner once the finalize window has closed. This also covers
    -- logging out mid-finalization (a lost C_Timer) and rollovers missed while offline.
    -- Eligibility is purely by activity timestamp, so late syncs already counted.
    -- Compute + broadcast OUR tally for the just-closed campaign once (LockResults merges it
    -- under the conflict policy and is internally idempotent). Even a stale client contributes,
    -- but its thin result loses to the community's complete one, and it adopts the stronger via
    -- the campaign-result sync — it never displays a winner recalculated from its own ledger.
    -- Both heavy jobs below (the lock's deep audit, the trim's full-ledger rebuild) wait out combat —
    -- each is O(ledger) and synchronous, and this ticker doesn't care WHEN it runs. The next 30s tick
    -- picks them up; eligibility is timestamp-based, so a delayed lock computes the identical result.
    local fighting = UnitAffectingCombat("player")

    if not firstRun and not fighting and not C.IsFinalizing() and not lockedLocal[ended] then
        C.LockResults(ended)
    end

    -- Housekeeping: drop records from campaigns that ended >2h ago (their results are long locked, so
    -- this can't change any outcome) and prune the matching kill tallies. Throttled to ~30m since it's
    -- slow-changing; runs shortly after login (lastTrimAt nil) and periodically thereafter.
    if WB.Ledger and WB.Ledger.TrimOldCampaigns and not fighting then
        local now = time()
        if not db.lastTrimAt or (now - db.lastTrimAt) > 1800 then
            db.lastTrimAt = now
            WB.Ledger.TrimOldCampaigns()
            if WB.KillTally and WB.KillTally.Prune then WB.KillTally.Prune() end
        end
    end

    -- Keep the windowed views fresh while finalizing and on the finalize→active edge.
    local fin = C.IsFinalizing()
    if fin or (wasFinalizing and not fin) then RefreshViews() end
    wasFinalizing = fin

    if WB.UI and WB.UI.RefreshCampaign then WB.UI.RefreshCampaign() end
end

-- ── Init ─────────────────────────────────────────────────────────────────────────
do
    local f = CreateFrame("Frame")
    f:RegisterEvent("ADDON_LOADED")
    f:RegisterEvent("PLAYER_LOGIN")
    f:SetScript("OnEvent", function(self, event, name)
        if event == "ADDON_LOADED" then
            if name ~= addonName then return end
            EnsureDB()
            self:UnregisterEvent("ADDON_LOADED")
        elseif event == "PLAYER_LOGIN" then
            EnsureDB()
            C.CheckRollover()
            if C_Timer then C_Timer.NewTicker(30, function() C.CheckRollover() end) end
        end
    end)
end
