-- WarBoard_Verifier.lua
-- Hybrid ledger verification / anti-cheat.
--
-- Design principle: gameplay stays OPTIMISTIC and INSTANT. We never make a player
-- wait for verification before rewards/progress show. Instead:
--
--   • Active campaign — incoming records get a cheap LIGHT validation. Obvious junk is
--     quarantined (kept, not deleted, with a reason). Plausible records are accepted
--     PROVISIONALLY (st = "P") and count for the live War Contribution / Leaderboard.
--   • Reconciliation/finalization — a deterministic DEEP audit replays the campaign
--     window in timestamp order, recomputes the legal reward for each record from the
--     objective definitions + warfront/repeat/daily state, marks legal records
--     ACCEPTED (st = "A") and quarantines impossible ones. Final Campaign Results are
--     built from accepted records only, so a fake record can't poison the result.
--
-- This is NOT bulletproof (no trusted server; someone editing the addon can still lie
-- to themselves). The point is: don't trust raw saved totals, rebuild from events,
-- reject obvious junk live, and run a real audit before a campaign result is locked.
--
-- Record status (LOCAL annotation, never trusted from the wire — recomputed on admit):
--   rec.st = "P" provisional | "A" accepted | "Q" quarantined-in-place
--          | "U" unverified — from a newer rules version we can't audit yet. Kept and
--            shown, relayed and synced, but scores NOTHING (like "Q") until we update and
--            can bless it ("A") or convict it ("Q"). This is the forward-compat path.
-- The status is deliberately NOT part of L.RecordHash, so it never affects sync.

local addonName, WB = ...

WB.Verifier = WB.Verifier or {}
local V = WB.Verifier
local L = WB.Ledger

-- ── Tunables ──────────────────────────────────────────────────────────────────
local MAX_GOLD_PER_REC = 100000   -- a single record can't move more than this (sanity)
local MAX_WC_PER_REC   = 100000
local MAX_KILL_BATCH   = 200      -- pvpkill batch can't credit more kills than this
local FUTURE_SKEW      = 3600     -- tolerate timestamps up to 1h ahead outright (machine clock drift)
local ABSURD_FUTURE    = 7 * 86400  -- beyond a week ahead is bogus, not drift → quarantine
local MIN_EPOCH        = 1000000000  -- 2001-09 — anything earlier is a bogus timestamp
local QUARANTINE_CAP   = 300      -- keep at most this many quarantine entries (FIFO)

local KNOWN_ATYPES = {
    objective = true, pvpkill = true,
    bounty_cost = true, bounty_reward = true,
    bounty_post = true, bounty_claim = true, bounty_clear = true,
    supply_run = true,
}
-- Only these atypes may carry NEGATIVE gold (an expenditure).
local EXPENDITURE_ATYPES = { bounty_cost = true }

local function vdbg(msg)
    if WB.Comm and WB.Comm.IsDebug and WB.Comm.IsDebug() then
        print("|cffcf6fffWBVerify|r " .. msg)
    end
end

-- ── Quarantine store (persisted, bounded, with reason strings) ─────────────────
local db
local function EnsureDB()
    WarBoardCharDB = WarBoardCharDB or {}
    WarBoardCharDB.quarantine     = WarBoardCharDB.quarantine     or {}  -- array of entries
    WarBoardCharDB.quarantineSeen = WarBoardCharDB.quarantineSeen or {}  -- [id] = true (dedup)
    db = WarBoardCharDB
end
local function Ready() if not db then EnsureDB() end return db ~= nil end

function V.Quarantine(rec, reason)
    if not Ready() then return false end
    local id = (type(rec) == "table" and rec.id) or nil
    if id and db.quarantineSeen[id] then return false end   -- already quarantined this id
    if id then db.quarantineSeen[id] = true end
    db.quarantine[#db.quarantine + 1] = {
        id     = id,
        atype  = (type(rec) == "table" and rec.atype) or "?",
        name   = (type(rec) == "table" and rec.name)  or nil,
        t      = (type(rec) == "table" and tonumber(rec.t)) or nil,
        reason = tostring(reason or "?"),
        qt     = time(),
    }
    -- FIFO trim; drop the matching seen-id so the set tracks the array size.
    while #db.quarantine > QUARANTINE_CAP do
        local old = table.remove(db.quarantine, 1)
        if old and old.id then db.quarantineSeen[old.id] = nil end
    end
    vdbg(("quarantine [%s] %s: %s"):format(
        tostring((type(rec) == "table" and rec.atype) or "?"),
        tostring(id and id:sub(-8) or "?"), tostring(reason)))
    return true
end

function V.GetQuarantine() if not Ready() then return {} end return db.quarantine end
function V.IsQuarantined(id) return Ready() and id and db.quarantineSeen[id] and true or false end
function V.QuarantineCount() return Ready() and #db.quarantine or 0 end
function V.WipeQuarantine()
    if not Ready() then return end
    wipe(db.quarantine); wipe(db.quarantineSeen)
end

-- ── Normalize ─────────────────────────────────────────────────────────────────
-- Canonicalize loose/untrusted fields into a known shape. Derives the campaign id
-- from the timestamp (deterministic, region-independent) rather than trusting any
-- submitted campaign id. Clears any wire-supplied status (never trust it).
function V.NormalizeRecord(rec)
    if type(rec) ~= "table" then return nil end
    rec.atype     = (type(rec.atype) == "string" and rec.atype) or "objective"
    rec.faction   = (rec.faction == "Alliance") and "Alliance" or "Horde"
    rec.gold      = tonumber(rec.gold)      or 0
    rec.warCredit = tonumber(rec.warCredit) or 0
    rec.kills     = tonumber(rec.kills)     or 0
    rec.t         = tonumber(rec.t)         or time()
    rec.name      = (type(rec.name)  == "string" and rec.name)  or (rec.name  and tostring(rec.name))  or "?"
    rec.realm     = (type(rec.realm) == "string" and rec.realm) or (rec.realm and tostring(rec.realm)) or ""
    rec.st        = nil   -- status is local-only; recomputed below / on admit
    if WB.Campaign and WB.Campaign.IndexForTime then
        rec.cid = WB.Campaign.IndexForTime(rec.t)   -- derived, NOT hashed/trusted
    end
    return rec
end

-- ── Forward-compatibility gate ─────────────────────────────────────────────────
-- A record can't fully be judged when it references an objective/atype/rank this build
-- doesn't have. We ABSTAIN (don't convict) only if it claims a newer RULESVER than ours
-- — we might just be out of date. If it claims our version or older, we'd have the rule
-- if it were real, so it's fabricated → convict. (rec.rv is the originator's claim and
-- is NOT in RecordHash; both outcomes are safe whatever value they put there.)
local function RecordClaimsNewerRules(rec)
    return (tonumber(rec.rv) or 0) > (WB.RULESVER or 0)
end

-- Upper bound on a KNOWN objective's reward: the base × the maximum warfront bonus.
-- The live light check only rejects rewards ABOVE this (absurd values); the exact
-- first-vs-repeat-vs-warfront match is the deep audit's job (ObjectiveLegalRewards),
-- which avoids live false-positives at warfront/daily-reset boundaries.
local WARFRONT_MAX = 1.5
local function ObjectiveRewardWithinMax(rec)
    local obj = WB.WarEffort and WB.WarEffort.ById and WB.WarEffort.ById[rec.objId]
    if not obj then return true end   -- unknown objId is handled by the caller (abstain/convict)
    local maxG = math.floor((obj.gold or 0)      * WARFRONT_MAX + 0.5)
    local maxW = math.floor((obj.warCredit or 0) * WARFRONT_MAX + 0.5)
    if (rec.gold or 0) > maxG or (rec.warCredit or 0) > maxW then
        return false, "objective reward exceeds legal maximum"
    end
    return true
end

-- ── Light validation (cheap, runs live) ────────────────────────────────────────
-- Returns true; or (false, reason) for a PROVABLE violation (quarantine); or
-- (false, reason, true) to ABSTAIN — a record from newer rules we can't judge, stored
-- as a non-scoring "U" (see Admit). The version-INDEPENDENT structural checks all run
-- first, so even an abstained record has passed caps / faction / timestamp / integer
-- sanity. Order matters: generic checks → unknown-atype gate → atype-specific checks.
function V.LightValidateRecord(rec, context)
    if type(rec) ~= "table" then return false, "not a table" end
    if type(rec.id) ~= "string" or rec.id == "" then return false, "missing id" end

    -- Structural sanity (applies to EVERY atype, even one we don't recognize).
    local t = tonumber(rec.t)
    if not t or t < MIN_EPOCH then return false, "bad timestamp" end
    -- A timestamp ahead of OUR clock is almost always machine clock drift between players,
    -- not a cheat — so don't HARD-quarantine it. Quarantine is sticky (quarantineSeen), so a
    -- few minutes of skew would permanently lose a peer's legitimate records. Tolerate normal
    -- drift outright; past that, ABSTAIN (store a non-scoring "U" that self-heals once our
    -- clock passes the stamp); only a wildly-future timestamp is treated as bogus.
    if t > time() + ABSURD_FUTURE then return false, "absurd future timestamp" end
    if t > time() + FUTURE_SKEW   then return false, "future timestamp (clock skew)", true end

    if rec.faction ~= "Horde" and rec.faction ~= "Alliance" then return false, "bad faction" end
    if type(rec.name) ~= "string" or #rec.name < 1 or #rec.name > 48 then return false, "bad name" end
    if rec.realm and #tostring(rec.realm) > 48 then return false, "bad realm" end

    local gold, wc, kills = tonumber(rec.gold), tonumber(rec.warCredit), tonumber(rec.kills)
    if not gold or not wc or not kills then return false, "non-numeric reward" end
    if gold ~= math.floor(gold) or wc ~= math.floor(wc) then return false, "non-integer reward" end
    if math.abs(gold) > MAX_GOLD_PER_REC then return false, "gold out of range" end
    if math.abs(wc)   > MAX_WC_PER_REC   then return false, "war credit out of range" end
    if wc   < 0 then return false, "negative war credit" end

    -- Unknown activity type: abstain if it claims newer rules, else it's fabricated.
    if not KNOWN_ATYPES[rec.atype] then
        if RecordClaimsNewerRules(rec) then return false, "unknown atype (newer rules)", true end
        return false, "unknown atype: " .. tostring(rec.atype)
    end

    -- Negative gold is legal only for an expenditure (now that the atype is known).
    if gold < 0 and not EXPENDITURE_ATYPES[rec.atype] then return false, "negative gold (non-expenditure)" end

    if rec.atype == "objective" then
        if not rec.objId or rec.objId == "" then return false, "objective without objId" end
        if not (WB.WarEffort and WB.WarEffort.ById and WB.WarEffort.ById[rec.objId]) then
            -- Unknown objective: abstain if newer rules, else fabricated.
            if RecordClaimsNewerRules(rec) then return false, "unknown objId (newer rules)", true end
            return false, "unknown objId: " .. tostring(rec.objId)
        end
        -- Known objective: reject an absurd reward live (exact match is the deep audit).
        local within, reason = ObjectiveRewardWithinMax(rec)
        if not within then return false, reason end
    elseif rec.atype == "pvpkill" then
        if gold ~= 0 then return false, "pvpkill carries gold" end
        if kills <= 0 then return false, "pvpkill kills <= 0" end
        if kills > MAX_KILL_BATCH then return false, "pvpkill batch too large" end
        if wc ~= kills then return false, "pvpkill warCredit != kills" end
    elseif rec.atype == "supply_run" then
        -- One record per delivered shipment. The reward is the fixed base (WB.SUPPLY_REWARD) or,
        -- when the run's zone was the active warfront AT rec.t, the boosted amount — both fully
        -- deterministic cross-client (zone comes from the static RUNS table by objId, never the
        -- wire; the warfront-at-t pick is the same hash every client computes). We accept EITHER
        -- amount: an older client legitimately banks the base even during a warfront (underpaying
        -- is harmless — only overpaying is a cheat). Anything else is bogus.
        local rw = WB.SUPPLY_REWARD or { gold = 300, warCredit = 150 }
        local bg, bw = rw.gold or 0, rw.warCredit or 0
        local mult = 1
        local zone = WB.SupplyRun and WB.SupplyRun.ZoneForId and WB.SupplyRun.ZoneForId(rec.objId)
        if zone and WB.Campaign and WB.Campaign.RewardMultiplierAt then
            mult = WB.Campaign.RewardMultiplierAt(zone, rec.t) or 1
        end
        local xg, xw = math.floor(bg * mult + 0.5), math.floor(bw * mult + 0.5)
        if not ((gold == bg and wc == bw) or (gold == xg and wc == xw)) then
            return false, "supply_run reward not legal"
        end
        if kills ~= 0 then return false, "supply_run carries kills" end
    elseif rec.atype == "bounty_claim" or rec.atype == "bounty_clear" then
        if not rec.targetGuid or rec.targetGuid == "" then return false, "bounty claim/clear without targetGuid" end
    elseif rec.atype == "bounty_post" then
        if not rec.bountyId or rec.bountyId == "" then return false, "bounty_post without bountyId" end
    end
    return true
end

-- Live double-claim correction. A bounty post can be claimed ONCE, but the live claim-race can let
-- two hunters both claim before either's clear propagates. Replaying claims in timestamp order,
-- keep the FIRST claim of each bounty and quarantine any later claim naming a bounty already taken
-- — so the double credit is corrected the moment both claim records have synced (not only at the
-- deep-audit reconciliation). Same earliest-wins rule the deep audit uses, so the two never disagree.
function V.DedupeClaims()
    if not L then return end
    local claims = {}
    for _, rec in ipairs(L.All()) do
        if rec.atype == "bounty_claim" and rec.st ~= "Q"
           and type(rec.clearedBountyIds) == "table" and #rec.clearedBountyIds > 0 then
            claims[#claims + 1] = rec
        end
    end
    if #claims < 2 then return end
    table.sort(claims, function(x, y)
        local tx, ty = x.t or 0, y.t or 0
        if tx ~= ty then return tx < ty end
        return (x.id or "") < (y.id or "")
    end)
    local consumed, changed = {}, false
    for _, rec in ipairs(claims) do
        local dup = false
        for _, bid in ipairs(rec.clearedBountyIds) do
            if consumed[bid] then dup = true; break end
        end
        if dup then
            rec.st = "Q"; V.Quarantine(rec, "double claim (same bounty)"); changed = true
        else
            for _, bid in ipairs(rec.clearedBountyIds) do consumed[bid] = true end
        end
    end
    if changed then
        if L and L.BumpScore then L.BumpScore() end   -- statuses changed → invalidate the derived-total caches
        if WB.WarEffort and WB.WarEffort.RefreshUI then WB.WarEffort.RefreshUI() end
    end
end

-- Admit an INCOMING record: normalize → dedup → light-validate → store. Returns
-- "new" | "dup" | "quarantined" | "drop". Only "new" should relay.
--   • passes            → stored provisional ("P")
--   • abstain (newer)   → stored UNVERIFIED ("U"): kept + relayed + shown, but scores
--                         nothing until we have the rules to bless it (forward compat)
--   • provable violation → quarantined (not stored, not relayed)
-- Storing "U" (and returning "new") is what keeps a mixed-version network converged: the
-- record stays in the ledger/hash and propagates through out-of-date relays unharmed.
function V.Admit(rec, context)
    if not L then return "drop" end
    rec = V.NormalizeRecord(rec)
    if not rec then return "drop" end
    if L.Has(rec.id) or V.IsQuarantined(rec.id) then return "dup" end
    local ok, reason, soft = V.LightValidateRecord(rec, context or "comm")
    if not ok and not soft then
        V.Quarantine(rec, reason)
        return "quarantined"
    end
    rec.st = ok and "P" or "U"   -- force; never inherit a wire-supplied status
    if L.Add(rec) then
        if rec.atype == "bounty_claim" then V.DedupeClaims() end   -- correct a double-claim as soon as both arrive
        return "new"
    end
    return "dup"
end

-- ── Deep validation (reconciliation) ───────────────────────────────────────────
local function StableSort(recs)
    table.sort(recs, function(a, b)
        local ta, tb = a.t or 0, b.t or 0
        if ta ~= tb then return ta < tb end
        return (a.id or "") < (b.id or "")
    end)
end

-- The legal reward set for an objective record. Returns first-completion and repeat
-- values (both pre-multiplied by the warfront bonus at rec.t). We accept EITHER,
-- because the live "first vs repeat" decision depends on the server's region-specific
-- daily reset, which can't be reproduced deterministically cross-client — but the
-- magnitude (base, repeat 10%, warfront +50%) IS deterministic, which is what we audit.
local function ObjectiveLegalRewards(rec)
    local obj = WB.WarEffort and WB.WarEffort.ById and WB.WarEffort.ById[rec.objId]
    if not obj then return nil, "unknown objId" end
    if obj.faction and obj.faction:lower() ~= tostring(rec.faction):lower() then
        return nil, "objective faction mismatch"
    end
    local REPEAT_FACTOR = (WB.WarEffort and WB.WarEffort.REPEAT_FACTOR) or 0.10
    local mult = 1
    if WB.Campaign and WB.Campaign.RewardMultiplierAt then
        local zone = WB.WarEffort.ZoneFromTexture and WB.WarEffort.ZoneFromTexture(obj.texture)
        mult = WB.Campaign.RewardMultiplierAt(zone, rec.t) or 1
    end
    local function f(base, factor) return math.floor((base or 0) * factor * mult + 0.5) end
    return {
        first  = { gold = f(obj.gold, 1),             wc = f(obj.warCredit, 1) },
        repeated = { gold = f(obj.gold, REPEAT_FACTOR), wc = f(obj.warCredit, REPEAT_FACTOR) },
    }
end

-- Deep-validate a single record (no state needed for the sanity-only types). Bounty
-- records get light checks here; the durable bounty-chain audit lands with the
-- bounty-event refactor (Phase 2). Returns true, or false + reason.
-- Returns true; or (false, reason) for a provable violation; or (false, reason, true)
-- to abstain (unknown objId/atype from newer rules — leave as "U", don't convict).
local function DeepValidateRecord(rec)
    if rec.atype == "objective" then
        if not (WB.WarEffort and WB.WarEffort.ById and WB.WarEffort.ById[rec.objId]) then
            if RecordClaimsNewerRules(rec) then return false, "unknown objId (newer rules)", true end
            return false, "unknown objId"
        end
        local legal, err = ObjectiveLegalRewards(rec)
        if not legal then return false, err end   -- faction mismatch (provable)
        local g, w = rec.gold or 0, rec.warCredit or 0
        if (g == legal.first.gold and w == legal.first.wc)
        or (g == legal.repeated.gold and w == legal.repeated.wc) then
            return true
        end
        return false, ("reward not legal (got %d/%d; legal %d/%d or %d/%d)")
            :format(g, w, legal.first.gold, legal.first.wc, legal.repeated.gold, legal.repeated.wc)
    end
    -- pvpkill + bounty_* (and unknown atype, which abstains): re-run the light checks.
    return V.LightValidateRecord(rec)
end

-- Replay one campaign window, set each record's status, and return a summary.
-- accountFor(idx) accepts a campaign index, a {from,to}/{[1],[2]} window, or nil (= current).
local function WindowOf(account)
    if type(account) == "number" and WB.Campaign and WB.Campaign.Window then
        return WB.Campaign.Window(account)
    elseif type(account) == "table" then
        return account.from or account[1] or 0, account.to or account[2]
    end
    local s = (WB.Campaign and WB.Campaign.StartTime and WB.Campaign.StartTime()) or 0
    return s, nil
end

-- ── Bounty-chain deep validation ───────────────────────────────────────────────
-- A bounty_post must be poster-vs-target opposite faction, carry no currency, and its
-- cost/reward must equal the target's rank tier (WB.Bounties.TIERS — the same table the
-- Post panel charges from).
local function BountyPostValid(rec)
    if not rec.bountyId or rec.bountyId == "" then return false, "post without bountyId" end
    if not rec.targetFaction or rec.faction == rec.targetFaction then return false, "poster/target not opposite faction" end
    if (rec.gold or 0) ~= 0 or (rec.warCredit or 0) ~= 0 then return false, "post carries currency" end
    -- Unknown bounty rank (a tier added by a newer version): abstain if it claims newer
    -- rules, else it's fabricated. Posting on an unknown rank is blocked locally, so a
    -- genuine unknown-rank post can only have come from a client that had the tier.
    local known = WB.Bounties and WB.Bounties.IsKnownRank and WB.Bounties.IsKnownRank(rec.rank)
    if known == false then
        if RecordClaimsNewerRules(rec) then return false, "unknown bounty rank (newer rules)", true end
        return false, "unknown bounty rank: " .. tostring(rec.rank)
    end
    local tier = WB.Bounties and WB.Bounties.RankTier and WB.Bounties.RankTier(rec.rank)
    if tier then
        if (rec.cost or 0)            ~= tier.cost then return false, "cost != rank tier" end
        if (rec.rewardGold or 0)      ~= tier.gold then return false, "rewardGold != rank tier" end
        if (rec.rewardWarCredit or 0) ~= tier.wc   then return false, "rewardWC != rank tier" end
    end
    return true
end

-- A bounty_cost must be a negative gold expenditure (no WC) matching a valid post's cost
-- by the same poster — or at least a legal rank-tier cost (the post may be outside window).
local function BountyCostValid(rec, posts)
    if (rec.gold or 0) >= 0 then return false, "bounty_cost not negative" end
    if (rec.warCredit or 0) ~= 0 then return false, "bounty_cost carries war credit" end
    local want = -(rec.gold or 0)
    for _, p in pairs(posts) do
        if p.guid and p.guid == rec.guid and (p.cost or 0) == want then return true end
    end
    -- Match any KNOWN rank tier's cost (the post may be outside the audit window).
    if WB.Bounties and WB.Bounties.TIERS then
        for _, tier in pairs(WB.Bounties.TIERS) do
            if tier.cost == want then return true end
        end
    end
    -- No known tier/post matches. If it claims newer rules, the cost may belong to a rank
    -- this build doesn't have yet → abstain; otherwise it's fabricated.
    if RecordClaimsNewerRules(rec) then return false, "bounty_cost (newer rules)", true end
    return false, "cost matches no valid post"
end

-- A bounty_claim's payout must equal the sum of the cleared posts' rewards, and the
-- claimer must not be one of those posters. Only enforced when all cleared posts are
-- present in the audit window (otherwise we lack the data to compare — accept).
local function BountyClaimValid(rec, posts)
    local ids = rec.clearedBountyIds
    if type(ids) ~= "table" or #ids == 0 then return true end   -- target-wide clear: sanity-accept
    local sumG, sumW, allPresent = 0, 0, true
    for _, bid in ipairs(ids) do
        local p = posts[bid]
        if p then
            if p.guid and rec.guid and p.guid == rec.guid then return false, "claimer is a poster" end
            sumG = sumG + (p.rewardGold or 0); sumW = sumW + (p.rewardWarCredit or 0)
        else
            allPresent = false
        end
    end
    -- Underpaying is harmless (e.g. an older client granting its default reward for a
    -- rank it can't price); only paying MORE than the consumed posts allow is a cheat.
    -- This keeps cross-version claims (old client pays the rank fallback, new client pays
    -- the real higher tier) from quarantining each other.
    if allPresent and ((rec.gold or 0) > sumG or (rec.warCredit or 0) > sumW) then
        return false, ("claim reward %d/%d exceeds consumed posts %d/%d"):format(rec.gold or 0, rec.warCredit or 0, sumG, sumW)
    end
    return true
end

function V.RebuildAcceptedState(account)
    if not L then return { accepted = 0, quarantined = 0 } end
    if not Ready() then EnsureDB() end
    local from, to = WindowOf(account)
    local recs = {}
    for _, rec in ipairs(L.All()) do
        local t = rec.t or 0
        if t >= (from or 0) and (not to or t < to) then recs[#recs + 1] = rec end
    end
    StableSort(recs)

    -- Pre-pass: index the VALID bounty_post records by bountyId, so bounty_cost / claim
    -- records can be audited against real posts.
    local validPosts = {}
    for _, rec in ipairs(recs) do
        if rec.atype == "bounty_post" and rec.bountyId and BountyPostValid(rec) then
            validPosts[rec.bountyId] = rec
        end
    end

    -- A given bounty post can be claimed exactly ONCE. Records are replayed in timestamp order
    -- (StableSort above), so the FIRST claim of a bounty consumes it; any later claim naming the
    -- same bountyId is a double-claim — two hunters credited for one kill when the live claim-race
    -- let both through — and is rejected here so only one ever scores. Deterministic (same order on
    -- every client), so the audited result stays consistent network-wide.
    local consumedBounty = {}

    local accepted, unknown, quarantined = 0, 0, 0
    for _, rec in ipairs(recs) do
        local a, ok, reason, soft = rec.atype, nil, nil, nil
        if     a == "bounty_post"  then ok, reason, soft = BountyPostValid(rec)
        elseif a == "bounty_cost"  then ok, reason, soft = BountyCostValid(rec, validPosts)
        elseif a == "bounty_claim" or a == "bounty_clear" then
            ok, reason = BountyClaimValid(rec, validPosts)
            if ok and type(rec.clearedBountyIds) == "table" and #rec.clearedBountyIds > 0 then
                for _, bid in ipairs(rec.clearedBountyIds) do
                    if consumedBounty[bid] then ok, reason = false, "bounty already claimed (double claim)"; break end
                end
                if ok then
                    for _, bid in ipairs(rec.clearedBountyIds) do consumedBounty[bid] = true end
                end
            end
        else                            ok, reason, soft = DeepValidateRecord(rec)
        end
        if ok then
            rec.st = "A"                              -- audited legal → scores (incl. final result)
            accepted = accepted + 1
        elseif soft then
            rec.st = "U"                              -- newer rules we can't judge → kept, non-scoring
            unknown = unknown + 1
        else
            rec.st = "Q"                              -- provably illegal → excluded everywhere
            V.Quarantine(rec, "deep: " .. tostring(reason))
            quarantined = quarantined + 1
        end
    end
    if L and L.BumpScore then L.BumpScore() end   -- statuses re-judged → invalidate the derived-total caches
    vdbg(("RebuildAcceptedState [%s..%s]: %d accepted, %d unknown, %d quarantined")
        :format(tostring(from), tostring(to), accepted, unknown, quarantined))
    return { accepted = accepted, unknown = unknown, quarantined = quarantined, from = from, to = to, total = #recs }
end

-- Thin wrapper used by /wb verify and the reconciliation hook (runs the audit + returns
-- a summary). Light vs deep is chosen by the caller / campaign state.
function V.DeepValidateCampaign(account)
    return V.RebuildAcceptedState(account)
end

-- Re-run light validation across the whole live ledger (active-play sanity sweep).
-- Quarantines anything provably bad; abstains (keeps "U") on records from newer rules we
-- can't judge; leaves the rest provisional.
function V.LightValidateAll()
    if not L then return { ok = 0, unknown = 0, bad = 0 } end
    local ok, unknown, bad = 0, 0, 0
    for _, rec in ipairs(L.All()) do
        local good, reason, soft = V.LightValidateRecord(rec, "sweep")
        if good then
            if rec.st ~= "A" then rec.st = rec.st or "P" end
            ok = ok + 1
        elseif soft then
            rec.st = "U"          -- newer rules we can't judge → keep, non-scoring (never convict)
            unknown = unknown + 1
        else
            rec.st = "Q"
            V.Quarantine(rec, "sweep: " .. tostring(reason))
            bad = bad + 1
        end
    end
    if L and L.BumpScore then L.BumpScore() end   -- statuses re-judged → invalidate the derived-total caches
    return { ok = ok, unknown = unknown, bad = bad }
end

-- ── Re-validate abstained records (forward-compat self-heal) ───────────────────
-- Re-examine "U" (unverified) records after our rules may have caught up — e.g. the
-- player UPDATED and now has the objective / atype / rank the records referenced. A now-
-- recognized & sane record is promoted to provisional ("P") so the next deep audit can
-- bless it ("A") and it starts scoring; a now-provably-fake one is quarantined; anything
-- STILL from newer rules than ours stays "U". This is the safe realization of "verify on
-- the new version" — it happens LOCALLY on update, never by trusting a peer's verdict.
function V.RevalidateUnknowns()
    if not L then return { promoted = 0, convicted = 0 } end
    if not Ready() then EnsureDB() end
    local promoted, convicted = 0, 0
    for _, rec in ipairs(L.All()) do
        if rec.st == "U" then
            local ok, reason, soft
            if rec.atype == "bounty_post" then
                ok, reason, soft = BountyPostValid(rec)
            else
                ok, reason, soft = V.LightValidateRecord(rec, "revalidate")
            end
            if ok then
                rec.st = "P"; promoted = promoted + 1            -- recognized now; deep audit will bless it
            elseif not soft then
                rec.st = "Q"; V.Quarantine(rec, "revalidate: " .. tostring(reason)); convicted = convicted + 1
            end
            -- still soft (newer than us even now) → leave as "U"
        end
    end
    if (promoted + convicted) > 0 then
        if L and L.BumpScore then L.BumpScore() end   -- statuses changed → invalidate the derived-total caches
        vdbg(("RevalidateUnknowns: %d U->P, %d U->Q"):format(promoted, convicted))
        if WB.WarEffort and WB.WarEffort.RefreshUI then WB.WarEffort.RefreshUI() end
    end
    return { promoted = promoted, convicted = convicted }
end

-- ── Hashes ─────────────────────────────────────────────────────────────────────
-- Provisional/raw hash = the whole synced ledger (what HELLO already compares).
function V.GetProvisionalHash()
    return (L and L.Hash and L.Hash()) or "0"
end
-- Accepted/audited hash = only records the deep audit blessed (st == "A").
function V.GetAcceptedHash()
    if not (L and L.All and L.RecordHash and L.HashString) then return "0" end
    local hs = {}
    for _, rec in ipairs(L.All()) do
        if rec.st == "A" then hs[#hs + 1] = L.RecordHash(rec) end
    end
    table.sort(hs)
    return L.HashString(table.concat(hs, "|"))
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 EnsureDB(); self:UnregisterEvent("ADDON_LOADED") end
        elseif event == "PLAYER_LOGIN" then
            -- After an update, re-judge records we abstained on while out of date so they
            -- start scoring (or get quarantined). Delayed so the rest of the addon is up — and both
            -- are full-ledger scans, so they additionally wait out combat (logging in and getting
            -- jumped shouldn't stack an O(ledger) audit on top of the fight; a delay changes nothing).
            if C_Timer then
                local function whenCalm(fn)
                    if UnitAffectingCombat("player") then C_Timer.After(10, function() whenCalm(fn) end); return end
                    fn()
                end
                C_Timer.After(20, function() whenCalm(V.RevalidateUnknowns) end)
                C_Timer.After(22, function() whenCalm(V.DedupeClaims) end)   -- clean up any double-claims already in the ledger
            end
        end
    end)
end
