-- WarBoard_Campaign.lua
-- The campaign + warfront clock.
--
-- A "campaign" is a fixed 3-day (72h) cycle. Campaigns run back-to-back and end at
-- 6 PM Pacific PRIME TIME (not the 7 AM server reset, when almost nobody is online),
-- so conclusions land while the war is actually populated. Boundaries are anchored to
-- a fixed UTC instant — every client computes the same start/end (and therefore the
-- same winner) without relying on a region-specific reset.
--
-- 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

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 ───────────────────────────────────────────────────────────────────────

-- Campaign index (block containing `now`) and its [start, end) boundaries.
function C.Index()           return math.floor((time() - ANCHOR) / CAMPAIGN_LEN) end
function C.StartTime()       return ANCHOR + C.Index() * CAMPAIGN_LEN end
function C.EndTime()         return C.StartTime() + CAMPAIGN_LEN end
function C.SecondsUntilEnd() return math.max(0, C.EndTime() - time()) end

-- [start, end) of campaign `idx` (defaults to current).
function C.Window(idx)
    idx = idx or C.Index()
    local s = ANCHOR + idx * CAMPAIGN_LEN
    return s, s + CAMPAIGN_LEN
end

-- Time-parameterized variants — used by the audit/replay, where the campaign and
-- warfront depend on a record's OWN timestamp, not "now". Deterministic across clients.
function C.IndexForTime(t) return math.floor(((tonumber(t) or time()) - ANCHOR) / CAMPAIGN_LEN) 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
        local s = C.StartTime()
        return s - CAMPAIGN_LEN, s          -- previous campaign [prevStart, prevEnd)
    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
    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

-- ── 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

local function HistoryHas(idx)
    if not db or not db.campaignHistory then return false end
    for _, e in ipairs(db.campaignHistory) do
        if e.index == idx then return true end
    end
    return false
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 e = C.StartTime() - (#db.campaignHistory * CAMPAIGN_LEN)
    local s = e - CAMPAIGN_LEN
    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

-- Permanently lock a finished campaign's result and declare the winner (once). The
-- result is appended to the persisted history (for the Campaign Results window and the
-- card hover); the chat/feed alert only fires for a recent conclusion, so logging in
-- days later doesn't replay a stale announcement.
function C.LockResults(idx)
    if not db then EnsureDB() end
    if HistoryHas(idx) then return end   -- already locked
    -- Deep audit the campaign window before locking: replay records in timestamp order,
    -- accept legal rewards, quarantine impossible ones. The result is then computed from
    -- audited totals, so a fake/illegal record can't poison the finalized Campaign Result.
    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 entry = { index = idx, winner = winner, horde = h, alliance = a, startT = s, endT = e, at = time() }

    db.campaignHistory = db.campaignHistory or {}
    db.campaignHistory[#db.campaignHistory + 1] = entry
    while #db.campaignHistory > MAX_HISTORY do table.remove(db.campaignHistory, 1) end
    db.lastResult = entry

    local fresh = (time() - e) <= 3600   -- only announce a conclusion within the hour
    if fresh then
        -- Coloured winner line in the player's OWN chat — only fires when they're online
        -- for a recent conclusion (a much-later login won't replay a stale result). Red
        -- for a Horde victory, blue for an Alliance victory.
        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:" .. idx, "Campaign Results Locked", body)
        end
    end
    RefreshViews()
    if WB.UI and WB.UI.RefreshCampaignResults then WB.UI.RefreshCampaignResults() 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.
    if not firstRun and not C.IsFinalizing()
       and (not db.lastResult or db.lastResult.index ~= ended) then
        C.LockResults(ended)
    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
