--[[
    Cerebro - V2 Sync Handlers
    Handler functions migrated from Communication.lua (Phase 3 V1 kill)

    These handlers are called by RPC_DISPATCH in Sync/RPCDispatch.lua.
    They were originally V1 handlers delegated to by V2; now they live
    natively on CommunicationV2.

    @module Sync.V2Handlers
    @version 0.12.0
]]--

local AddonName = "Cerebro"
local Cerebro = LibStub("AceAddon-3.0"):GetAddon(AddonName)
local CommunicationV2 = Cerebro:GetModule("CommunicationV2")

-- Minimum version for sync acceptance (mirrors Communication.lua constant)
local MIN_SYNC_VERSION = "0.7.15"

-- Transient audit state (not persisted to DB)
-- Mirrors the file-locals in Communication.lua that back HandleSyncAuditRequest/Response/Report
local _auditResponses = nil    -- { [sender] = { checksum, fingerprints } }
local _auditLocalFP = nil      -- Officer's own fingerprints for comparison
local _auditInProgress = false -- Guard against overlapping audits

--[[=============================================
    EPGP_UPDATE Handler
===============================================]]

function CommunicationV2:HandleEPGPUpdate(data, sender)
    -- Accept Readiness EP from any guild member (M+ self-awards via AwardReadinessEP).
    -- All other EP/GP changes still require officer sender.
    if not Cerebro:IsOfficer(sender) then
        if not (data.currency == "EP" and data.reason and data.reason:find("Readiness")) then
            return
        end
        Cerebro:Debug("Accepted Readiness EP from raider: " .. sender)
    end

    -- Epoch check: reject stale EPGP updates from pre-reset epoch
    if data.epoch and data.epoch < (Cerebro.db.global.resetEpoch or 0) then
        Cerebro:Debug("EPGP_UPDATE rejected: stale epoch from " .. sender)
        return
    end

    -- Update local data
    -- P3: Delta-aware merge — only apply if incoming timestamp is newer
    -- than our local lastUpdate. Prevents stale overwrites during
    -- concurrent multi-officer awards.
    local playerData = Cerebro:GetPlayerData(data.player)
    local localTs = playerData.lastUpdate or 0
    local incomingTs = data.timestamp or 0

    if incomingTs >= localTs then
        -- Apply via write gate (ADR-001), preserving sender's timestamp for LWW
        local changes = {}
        if data.currency == "EP" then
            changes.ep = data.newBalance
        else
            changes.gp = data.newBalance
        end
        Cerebro:ApplyPlayerMutation(data.player, changes, { preserveTimestamp = true })
        -- Propagate the sender's lastUpdate to prevent roster hash divergence.
        -- Without this, the receiver keeps its old lastUpdate while the sender
        -- has a newer one from Player:AddEP/AddGP, causing permanent hash
        -- mismatch and post-boss-kill fetch storms (integrity=false).
        if data.timestamp then
            playerData.lastUpdate = data.timestamp
        end
    else
        Cerebro:Debug(string.format(
            "HandleEPGPUpdate: skipping stale %s for %s (local=%d incoming=%d)",
            data.currency, data.player, localTs, incomingTs))
    end

    -- History sync (v0.10.30+): write a local transaction so this award appears in the
    -- History tab. Use IngestReceipts, which preserves the sender's UUID, stamps
    -- verified=false/source=Auto-Import, and deduplicates by UUID automatically.
    if data.txId and Cerebro.Ledger then
        local receipt = {
            id           = data.txId,
            timestamp    = data.timestamp or time(),
            timestampUTC = date("!%Y-%m-%d %H:%M:%S", data.timestamp or time()),
            player       = data.player,
            amount       = data.amount,
            currency     = data.currency,
            reason       = data.reason,
            details      = data.details or {},
            admin        = Cerebro:GetShortPlayerName(sender) or sender,
            logicVersion = Cerebro.version,
        }
        local count = Cerebro.Ledger:IngestReceipts({ receipt })
        if count > 0 then
            Cerebro:Debug("HandleEPGPUpdate: ingested History entry " .. data.txId)
        end
    end

    -- Refresh PR
    local player = Cerebro.Player:New(data.player)
    player:UpdatePR()

    -- Show notification if it's us
    if data.player == Cerebro:GetPlayerFullName() then
        local config = Cerebro.db.global.config.notifications or {}
        if config.showEPGPChanges then
            local color = data.amount > 0 and "|cff00ff00" or "|cffff0000"
            local sign = data.amount > 0 and "+" or ""

            Cerebro:Print(string.format("%s%s %s%d|r (Reason: %s) | New balance: %d",
                color, data.currency or "EPGP", sign, data.amount, data.reason, data.newBalance))
        end
    end

    -- Refresh UI
    Cerebro:RefreshAllUI()

    -- Session-Aware Sync: Debounced SYNC_ACK
    -- After applying updates, schedule a 3s debounced ACK back to the sender.
    -- Rapid loot awards (4 items in 15s) reset the timer each time, resulting
    -- in ONE coalesced ACK with our current roster hash.
    if data.senderName then
        local v2 = self
        if v2._syncAckTimer then
            Cerebro:CancelTimer(v2._syncAckTimer)
        end
        v2._syncAckTarget = data.senderName
        v2._syncAckTimer = Cerebro:ScheduleTimer(function()
            v2._syncAckTimer = nil
            v2:InvalidateHashCache()
            local rosterHash = v2:ComputeRosterHash()
            local target = v2._syncAckTarget
            v2._syncAckTarget = nil
            if target then
                v2:SendRPC("SYNC_ACK", {
                    rosterHash = rosterHash,
                }, "WHISPER", target)
                Cerebro:Debug("Debounced SYNC_ACK sent to " .. (Cerebro:GetShortPlayerName(target) or target))
            end
        end, 3)
    end
end

--[[=============================================
    EPGP_FORWARD Handler
    Fix 2c: Sync Leader receives forwarded awards from non-leader officers
    and merges them into local state for next heartbeat propagation.
===============================================]]

function CommunicationV2:HandleEPGPForward(data, sender)
    -- Only the Sync Leader should process forwards
    if not Cerebro.modules.SyncScheduler:IsSyncLeader() then
        Cerebro:Debug("Ignoring EPGP_FORWARD (not sync leader) from: " .. sender)
        return
    end

    -- Only accept from officers
    if not Cerebro:IsOfficer(sender) then
        Cerebro:Debug("Ignoring EPGP_FORWARD from non-officer: " .. sender)
        return
    end

    -- Epoch check: reject stale EPGP forwards from pre-reset epoch
    if data.epoch and data.epoch < (Cerebro.db.global.resetEpoch or 0) then
        Cerebro:Debug("EPGP_FORWARD rejected: stale epoch from " .. sender)
        return
    end

    -- Validate data structure
    if not data or not data.player or not data.amount or not data.currency then
        Cerebro:Debug("Invalid EPGP_FORWARD data from: " .. sender)
        return
    end

    local playerName = data.player
    local amount = data.amount
    local currency = data.currency
    local reason = data.reason or "Forwarded"

    -- Apply the change using Player model (same as HandleEPGPUpdate but with mutation)
    local player = Cerebro.Player:New(playerName)
    if currency == "EP" then
        player:AddEP(amount)
    elseif currency == "GP" then
        player:AddGP(amount)
    else
        Cerebro:Debug("Unknown currency in EPGP_FORWARD: " .. tostring(currency))
        return
    end

    -- Log to ledger for audit trail (deduplication via UUID)
    if data.txId then
        -- Check for duplicate transaction
        local existing = Cerebro.Ledger:FindTransaction(data.txId)
        if existing then
            Cerebro:Debug("EPGP_FORWARD duplicate transaction: " .. data.txId)
            return
        end
    end
    Cerebro.Ledger:AddTransaction(playerName, amount, currency, reason, data.details)

    -- Mark this player dirty so next heartbeat includes the update
    Cerebro:MarkPlayerDirty(playerName, { currency == "EP" and "ep" or "gp" })

    -- Recalculate PR
    Cerebro:RecalculateAllPR()
    Cerebro:RefreshAllUI()

    Cerebro:Debug(string.format("EPGP_FORWARD from %s: %s %+d %s (%s)",
        sender, playerName, amount, currency, reason))
end

--[[=============================================
    BROADCAST_TX / BROADCAST_TX_BATCH Handlers (v0.10.30+)
    Receive full transaction objects from other officers.
    Used by real-time per-award broadcast and retroactive /cb synchistory.
===============================================]]

-- Receive a single transaction from another officer's Ledger:AddTransaction() call.
-- BROADCAST_TX is sent automatically by BroadcastTransaction() on every new award.
function CommunicationV2:HandleBroadcastTransaction(data, sender)
    if not Cerebro:IsOfficer(sender) then
        Cerebro:Debug("BROADCAST_TX rejected (non-officer): " .. tostring(sender))
        return
    end

    -- Quarantine guard: do not ingest transactions during factory reset window
    if Cerebro:IsQuarantined() then
        Cerebro:Debug("BROADCAST_TX rejected (quarantine) from " .. sender)
        return
    end

    -- Epoch guard: reject entries from a prior reset generation
    local localEpoch = Cerebro.db.global.resetEpoch or 0
    if data.epoch and data.epoch < localEpoch then
        Cerebro:Debug("BROADCAST_TX rejected (stale epoch) from " .. sender)
        return
    end

    if not data.entry or not data.entry.id then
        Cerebro:Debug("BROADCAST_TX: malformed payload from " .. sender)
        return
    end

    -- IngestReceipts handles UUID dedup, schema validation, ReplayHistory, and RefreshAllUI
    local count = Cerebro.Ledger:IngestReceipts({ data.entry })
    if count > 0 then
        Cerebro:Debug("BROADCAST_TX: merged transaction " .. data.entry.id .. " from " .. sender)
    end
end

-- Receive a batch of transactions from /cb synchistory force-push.
-- BROADCAST_TX_BATCH is sent by BroadcastAllTransactions() in chunks of 25.
function CommunicationV2:HandleBroadcastTransactionBatch(data, sender)
    if not Cerebro:IsOfficer(sender) then
        Cerebro:Debug("BROADCAST_TX_BATCH rejected (non-officer): " .. tostring(sender))
        return
    end

    -- Quarantine guard
    if Cerebro:IsQuarantined() then
        Cerebro:Debug("BROADCAST_TX_BATCH rejected (quarantine) from " .. sender)
        return
    end

    -- Epoch guard
    local localEpoch = Cerebro.db.global.resetEpoch or 0
    if data.epoch and data.epoch < localEpoch then
        Cerebro:Debug("BROADCAST_TX_BATCH rejected (stale epoch) from " .. sender)
        return
    end

    if not data.entries or type(data.entries) ~= "table" then
        Cerebro:Debug("BROADCAST_TX_BATCH: malformed payload from " .. sender)
        return
    end

    -- IngestReceipts deduplicates by UUID — safe to call even if some entries already exist
    local count = Cerebro.Ledger:IngestReceipts(data.entries)
    if count > 0 then
        local total = data.total or #data.entries
        Cerebro:Print(string.format("|cff00ff00[Cerebro]|r Received %d history entries from %s.",
            count, Cerebro:GetShortPlayerName(sender) or sender))
        Cerebro:Debug(string.format("BROADCAST_TX_BATCH: %d/%d new from %s", count, total, sender))
    end
end

--[[=============================================
    BROADCAST_AUDIT_LOG Handler
===============================================]]

function CommunicationV2:HandleBroadcastAuditLog(data, sender)
    if not Cerebro:IsOfficer() then return end
    if sender == Cerebro:GetPlayerFullName() then return end

    if data.entry then
        Cerebro.Ledger:MergeAuditLog(data.entry)
    end
end

--[[=============================================
    Bidding Handlers
===============================================]]

function CommunicationV2:HandleBidSubmit(data, sender)
    -- Store bid in session (officers only need to track this)
    if not Cerebro:IsOfficer() then return end

    local session = Cerebro.modules.Session:GetCurrentSession()

    if not session then return end

    -- Initialize bids for this item
    if not session.bids[data.itemID] then
        session.bids[data.itemID] = {}
    end

    -- Find existing bid from this player
    local found = false

    for i, bid in ipairs(session.bids[data.itemID]) do
        if bid.player == sender then
            -- Update bid
            session.bids[data.itemID][i] = {
                player = sender,
                amount = data.amount,
                priority = data.priority,
                timestamp = data.timestamp,
                status = "pending",
            }
            found = true
            break
        end
    end

    if not found then
        -- New bid
        table.insert(session.bids[data.itemID], {
            player = sender,
            amount = data.amount,
            priority = data.priority,
            timestamp = data.timestamp,
            status = "pending",
        })
    end

    Cerebro:Debug(string.format("%s bid on item %d (priority: %d)",
        sender, data.itemID, data.priority))
end

function CommunicationV2:HandleBidLock(sender)
    if not Cerebro:IsOfficer(sender) then return end

    local session = Cerebro.modules.Session:GetCurrentSession()

    if session then
        session.locked = true
        Cerebro:Print("|cffff8800Bidding is now locked!|r")
    end
end

function CommunicationV2:HandleBidUnlock(sender)
    if not Cerebro:IsOfficer(sender) then return end

    local session = Cerebro.modules.Session:GetCurrentSession()

    if session then
        session.locked = false
        Cerebro:Print("|cff00ff00Bidding has been unlocked!|r")
    end
end

--[[=============================================
    Item Handlers
===============================================]]

function CommunicationV2:HandleItemDrop(data, sender) -- luacheck: ignore sender
    -- Item dropped with bids
    Cerebro:Print("|cff00ff00Loot dropped:|r " .. data.itemLink)

    -- Check if we have a wishlist item
    if Cerebro.modules.Wishlist:IsOnWishlist(data.itemLink) then
        local shouldBid, amount, priority = Cerebro.modules.Wishlist:ShouldAutoBid(data.itemID)

        if shouldBid then
            -- Auto-bid
            Cerebro:ScheduleTimer(function()
                Cerebro.modules.Bidding:PlaceBid(data.itemLink, amount, priority)
            end, 0.5)
        end
    end
end

function CommunicationV2:HandleItemAward(data, sender)
    if not Cerebro:IsOfficer(sender) then return end

    Cerebro:Print(string.format("%s won %s for %d GP",
        Cerebro:ColorPlayerName(data.winner),
        data.itemLink,
        data.amount
    ))

    if data.winner == Cerebro:GetPlayerFullName() then
        Cerebro:PlaySound(SOUNDKIT.AUCTION_WINDOW_OPEN)
    end
end

--[[=============================================
    Session Handlers
===============================================]]

function CommunicationV2:HandleSessionEnd(data, sender)
    if not Cerebro:IsOfficer(sender) then return end

    -- Clear local session if it matches the ended one (or if no specific ID provided)
    local localSession = Cerebro.db.global.currentSession
    if localSession then
        local endedID = data and data.sessionID
        if not endedID or localSession.sessionID == endedID then
            Cerebro.db.global.currentSession = nil
            Cerebro:Debug("SESSION_END from " .. sender .. ": cleared local session " .. tostring(localSession.sessionID))
        else
            Cerebro:Debug("SESSION_END from " .. sender .. ": ID mismatch (local=" .. tostring(localSession.sessionID) .. ", ended=" .. tostring(endedID) .. ")")
        end
    end

    Cerebro:Print("|cffff8800Raid session ended.|r Thanks for raiding!")

    -- Refresh UI so button reflects the updated state
    Cerebro:RefreshAllUI()
end

--[[=============================================
    Boss Kill Handler
===============================================]]

function CommunicationV2:HandleBossKill(data, sender)
    if not Cerebro:IsOfficer(sender) then return end

    Cerebro:Print(string.format("|cff00ff00Boss killed:|r %s", data.encounterName))
end

--[[=============================================
    Decay Handler
===============================================]]

function CommunicationV2:HandleDecay(data, sender)
    if not Cerebro:IsOfficer(sender) then return end

    Cerebro:Print(string.format("|cffff8800EPGP Decay applied:|r %d%% decay to all positive balances",
        data.decayPercent))
end

--[[=============================================
    Reset Handler
===============================================]]

function CommunicationV2:HandleReset(data, sender)
    -- RBAC: Validate SENDER is authorized to initiate reset
    -- Everyone processes the reset, but only if sender is authorized
    local senderIsGM = Cerebro:IsGuildMaster(sender)
    local senderIsOfficer = Cerebro:IsOfficer(sender)
    local allowOfficerReset = Cerebro.db.global.config.admin and
        Cerebro.db.global.config.admin.allowOfficerFactoryReset

    if not senderIsGM and not (senderIsOfficer and allowOfficerReset) then
        Cerebro:Debug("Reset from " .. sender .. " rejected (sender unauthorized)")
        if Cerebro.Ledger then
            Cerebro.Ledger:AddAuditLog("RESET_DENIED", "Unauthorized sender: " .. sender)
        end
        return
    end

    -- Epoch check
    local incomingEpoch = data.epoch or 0
    local localEpoch = Cerebro.db.global.resetEpoch or 0
    if incomingEpoch < localEpoch then
        Cerebro:Debug("Reset from " .. sender .. " ignored (stale epoch)")
        return
    end

    -- Create backup before wipe (for ALL users, not just officers)
    local backup = {
        timestamp = time(),
        version = Cerebro.version,
        players = Cerebro:CopyTable(Cerebro.db.global.players),
        transactions = Cerebro:CopyTable(Cerebro.db.global.transactions),
        audit = Cerebro:CopyTable(Cerebro.db.global.audit),
        epgpConfig = Cerebro:CopyTable(Cerebro.db.global.config.epgp),
        resetEpoch = localEpoch,
    }

    -- Abort if backup failed
    if not backup.players or not backup.transactions then
        Cerebro:Debug("HandleReset aborted: backup creation failed")
        if Cerebro.Ledger then
            Cerebro.Ledger:AddAuditLog("RESET_ABORTED", "Backup failed, no wipe performed")
        end
        return
    end

    Cerebro.db.global.backup = backup
    if Cerebro.Ledger then
        Cerebro.Ledger:AddAuditLog("BACKUP_CREATED", "Before remote reset from " .. sender)
    end

    -- Wipe data tables (audit is PRESERVED with epoch markers for accountability)
    Cerebro.db.global.players = {}
    Cerebro.db.global.transactions = {}
    Cerebro.db.global.awards = {}
    -- NOTE: Audit is intentionally NOT wiped - preserved for accountability
    -- Old entries will show [Epoch N] markers in UI
    Cerebro.db.global.currentSession = nil
    Cerebro.db.global.resetEpoch = incomingEpoch
    Cerebro.db.global.resetQuarantine = { at = time() }

    -- Receipt Cursor Tracking: Reset cursor state on factory reset
    Cerebro:ResetReceiptCursorState("factory reset")

    -- Log the reset to audit (this entry will have the new epoch)
    if Cerebro.Ledger then
        Cerebro.Ledger:AddAuditLog("FACTORY_RESET", "Remote reset from " .. sender .. " (epoch " .. incomingEpoch .. ")")
    end

    Cerebro:Print("|cffff0000EPGP data reset by " .. sender .. "|r")

    -- Refresh UI
    Cerebro:RefreshAllUI()
end

--[[=============================================
    Version Check Handler
===============================================]]

function CommunicationV2:HandleVersionCheck(data, sender)
    -- If this is a REQUEST, respond with our version
    if data.request then
        local responseData = {
            response = true,
            version = Cerebro.version,
        }
        self:SendRPC("VERSION_CHECK", responseData, "WHISPER", sender)
        return
    end

    -- If this is a RESPONSE, store it in the version cache on Communication (it owns ReportVersions)
    if data.response then
        if Cerebro.modules.Communication then
            local comm = Cerebro.modules.Communication
            if not comm.versionCache then
                comm.versionCache = {}
            end
            comm.versionCache[sender] = data.version
        end
    end
end

--[[=============================================
    Announce Self Handler
===============================================]]

function CommunicationV2:HandleAnnounceSelf(data, sender)
    -- Only officers care about this
    if not Cerebro:IsOfficer() then return end

    -- Version enforcement: ignore announcements from outdated clients
    local senderVersion = data.version or "0.0.0"
    if Cerebro:CompareVersions(senderVersion, MIN_SYNC_VERSION) < 0 then
        Cerebro:Debug("Ignoring announcement from outdated client: " .. sender .. " (v" .. senderVersion .. ")")
        return
    end

    -- Fix 5 Layer 1: Officer-side hard gate — reject ANN_SELF from non-visible ranks
    local visibleRanks = (Cerebro.db.global.config.sync or {}).visibleRanks
    if visibleRanks then
        -- Try payload-provided rank first, then live lookup
        local senderRankIndex = data.guildRankIndex
        if senderRankIndex == nil then
            senderRankIndex = Cerebro:GetPlayerGuildRankIndex(Cerebro:GetFullPlayerName(sender))
        end
        if senderRankIndex ~= nil and not visibleRanks[senderRankIndex] then
            Cerebro:Debug("ANN_SELF rejected: " .. sender .. " rank " .. senderRankIndex .. " not visible")
            return  -- Do NOT create/update player record
        end
    end

    -- Add to database if not exists (implicitly created by GetPlayerData)
    -- This ensures they show up in the "Pending" list
    local player = Cerebro:GetFullPlayerName(sender)

    -- Check if they are already in DB
    if not Cerebro.db.global.players[player] then
        Cerebro:GetPlayerData(player) -- Creates entry as Pending

        -- All announced players start as Pending - officer self-corrects on THEIR client
        -- Do NOT try to detect officer status here - IsOfficer(sender) has format issues
        Cerebro:Print(string.format("New candidate detected: %s (v%s)", Cerebro:ColorPlayerName(player), data.version or "?"))

        -- Refresh UI if open
        Cerebro:RefreshAllUI()
    else
        -- Auto-Recovery: Player exists but re-announced as Pending
        -- This happens when a raider deletes their SavedVariables — their local DB is blank
        -- so they re-announce, but we already have them as Core. Push a full sync so they
        -- regain their standings automatically without officer manual intervention.
        local existingData = Cerebro.db.global.players[player]
        if existingData.status == "Core" then
            Cerebro:Debug("Auto-recovery: " .. player .. " re-announced (Core in DB), pushing sync")
            local senderShort = Cerebro:GetShortPlayerName(sender)
            if Cerebro:IsGuildMemberOnline(senderShort) then
                Cerebro:RecalculateAllPR()
                local snapshot = self:BuildSnapshot(true, true)
                self:SendV2Message("SNAP", snapshot, "WHISPER", senderShort)
                Cerebro:Print(string.format("|cff00ff00[Auto-Recovery]|r Pushed standings to %s (detected data loss)",
                    Cerebro:ColorPlayerName(player)))
            end
        end
    end
end

--[[=============================================
    Receipt Sync Handler
===============================================]]

function CommunicationV2:HandleSyncReceipts(data, sender)
    if not Cerebro:IsOfficer() then return end

    -- Epoch check (defense-in-depth): reject stale receipts from pre-reset epoch
    local payloadEpoch = data.epoch or 0
    local localEpoch = Cerebro.db.global.resetEpoch or 0
    if payloadEpoch < localEpoch then
        Cerebro:Debug("Receipts from " .. sender .. " rejected: stale epoch " .. payloadEpoch .. " < " .. localEpoch)
        if Cerebro.Ledger then
            Cerebro.Ledger:AddAuditLog("RECEIPTS_REJECTED_STALE",
                string.format("From %s, epoch %d < %d", sender, payloadEpoch, localEpoch))
        end
        return
    end

    Cerebro:Debug("Received receipts from " .. sender)

    if data.receipts then
        local added = Cerebro.Ledger:IngestReceipts(data.receipts)
        if added > 0 then
            Cerebro:Print(string.format("Imported %d new transactions from %s.", added, Cerebro:ColorPlayerName(sender)))

            -- Raid Sync v2: Defer broadcast — mark peers as needing updated data
            -- MaybeRaidSync heartbeat will pick this up on next 30s tick
            -- This avoids an extra full broadcast mid-raid for receipt ingestion
            local s = Cerebro.db.global.syncState or {}
            s.peerStatus = s.peerStatus or {}
            -- Mark all current raid members as STALE since data changed
            if IsInRaid() then
                for i = 1, GetNumGroupMembers() do
                    local rName = GetRaidRosterInfo(i)
                    if rName then
                        local rFullName = Cerebro:GetFullPlayerName(rName)
                        if rFullName and s.peerStatus[rFullName] then
                            s.peerStatus[rFullName].freshness = "STALE"
                        end
                    end
                end
                Cerebro:Debug("Receipt ingestion: marked raid peers as STALE (deferred sync)")
            else
                -- Not in raid: sync immediately (guild fallback)
                Cerebro:ScheduleTimer(function()
                    self:SyncToRaid()
                end, 2)
            end
        end
    end

    -- Receipt Cursor Tracking: Send SYNC_RCPT_ACK back to sender via WHISPER
    -- FIX: Check if sender is online before whispering (prevents spam to offline players)
    if data.batchId and data.cursor then
        local senderShort = Cerebro:GetShortPlayerName(sender)
        local senderOnline = Cerebro:IsGuildMemberOnline(sender)

        if not senderOnline then
            Cerebro:Debug("Skipping SYNC_RCPT_ACK - sender " .. sender .. " is offline")
        else
            local ackPayload = {
                batchId = data.batchId,
                cursor = data.cursor,
            }
            self:SendRPC("SYNC_ACK", ackPayload, "WHISPER", senderShort)
            Cerebro:Debug("Sent SYNC_RCPT_ACK for batch " .. data.batchId .. " to " .. sender)
        end
    end
end

--[[=============================================
    Receipt Request Handler
===============================================]]

function CommunicationV2:HandleReceiptRequest(data, sender)
    -- Only respond if we are NOT an officer (officers don't need to send receipts back)
    if Cerebro:IsOfficer() then return end

    -- Only respond if sender is an officer
    if not Cerebro:IsOfficer(sender) then
        Cerebro:Debug("Ignoring receipt request from non-officer: " .. sender)
        return
    end

    -- Version check on the request
    local senderVersion = data.version or "0.0.0"
    if Cerebro:CompareVersions(senderVersion, MIN_SYNC_VERSION) < 0 then
        Cerebro:Debug("Ignoring receipt request from outdated officer: " .. sender)
        return
    end

    -- Upload our receipts to the requesting officer
    -- V2 receipt sync not yet implemented; no-op for now
    Cerebro:Debug("Receipt sync requested from " .. sender .. " — V2 receipt sync not yet implemented")
    Cerebro:Debug("Sent receipts to officer " .. sender)
end

--[[=============================================
    Discovery Request Handler
===============================================]]

function CommunicationV2:HandleDiscoveryRequest(sender)
    -- If we are an officer, ignore this (we are the ones asking, or already known)
    if Cerebro:IsOfficer() then return end

    -- If we are a normal raider, we might need to announce?
    -- Only if we are Pending? Or maybe Core too just in case?
    -- Plan says "If local player is Pending"

    local me = Cerebro:GetPlayerFullName()
    local myData = Cerebro:GetPlayerData(me)

    if myData.status == "Pending" then
        -- We are pending! Announce ourselves so the officer sees us.
        self:AnnounceSelf()
        Cerebro:Print("Officer " .. sender .. " requested a roll call. Announced presence.")
    end
end

--[[=============================================
    Player Update Handler
===============================================]]

function CommunicationV2:HandlePlayerUpdate(data, sender)
    -- Allow updates from officers
    if not Cerebro:IsOfficer(sender) then return end

    local target = data.player
    local update = data.update

    if target and update then
        local playerData = Cerebro:GetPlayerData(target)

        -- Merge updates
        for k, v in pairs(update) do
            playerData[k] = v
        end

        -- FIX: Ensure lastUpdate is set for LWW sync
        -- If incoming update includes lastUpdate, it was already applied above
        -- If not, set it now so this update wins future merges
        if not update.lastUpdate then
            playerData.lastUpdate = time()
        end

        Cerebro:Debug("Received player update for " .. target .. " from " .. sender)

        -- Refresh UI if open (e.g. My Progress needs to show new status)
        Cerebro:RefreshAllUI()

        -- If it's me, print a message
        if target == Cerebro:GetPlayerFullName() then
            if update.status then
                Cerebro:Print("Your status has been updated to: |cff00ff00" .. update.status .. "|r")
            end
        end
    end
end

--[[=============================================
    Sync Audit Handlers (Diagnostic)
    Officer-initiated per-player fingerprint comparison
    across all online guild members.
    Strictly read-only — no mutations, no ledger writes.
===============================================]]

--- Build per-player fingerprints for audit comparison.
--- Returns: { ["Name-Realm"] = { ep=N, gp=N, st="Status", lu=N, att=N }, ... }
--- Only includes players with status == "Core" (matches Standings filter).
--- Read-only: no mutations.
function CommunicationV2:BuildPlayerFingerprints()
    local players = Cerebro.db.global.players or {}
    local fingerprints = {}

    for name, pData in pairs(players) do
        -- Only include Core players (same filter as Standings display)
        if pData.status == "Core" then
            fingerprints[name] = {
                ep  = pData.ep or 0,
                gp  = pData.gp or 0,
                st  = pData.status or "?",
                lu  = pData.lastUpdate or 0,
                att = pData.attendance or 0,
            }
        end
    end

    return fingerprints
end

--- Handle incoming sync audit REQUEST (anyone can respond).
function CommunicationV2:HandleSyncAuditRequest(data, sender)
    -- Validate: sender must be an officer (only officers may initiate audits)
    if not Cerebro:IsOfficer(sender) then
        Cerebro:Debug("Ignoring sync audit request from non-officer: " .. sender)
        return
    end

    -- Don't respond to our own request
    local me = Cerebro:GetPlayerFullName()
    if sender == me then return end

    -- Build our local fingerprints and reply via WHISPER
    local fingerprints = self:BuildPlayerFingerprints()
    local checksum = self:ComputeRosterHash()

    local respData = {
        requester    = data.requester,
        checksum     = checksum,
        fingerprints = fingerprints,
        isOfficer    = Cerebro:IsOfficerSafe(),
        version      = Cerebro.version,
    }

    -- Whisper back to requester (sender is guaranteed online — they just messaged us)
    local senderShort = Cerebro:GetShortPlayerName(sender)
    if senderShort then
        self:SendRPC("SYNC_AUDIT_RESP", respData, "WHISPER", senderShort)
        Cerebro:Debug("Sent sync audit response to " .. senderShort)
    end
end

--- Handle incoming sync audit RESPONSE (officer collects).
function CommunicationV2:HandleSyncAuditResponse(data, sender)
    -- Only collect if we have an audit in progress
    if not _auditInProgress or not _auditResponses then
        return
    end

    -- Store response
    _auditResponses[sender] = {
        checksum     = data.checksum,
        fingerprints = data.fingerprints or {},
        isOfficer    = data.isOfficer,
        version      = data.version,
    }

    Cerebro:Debug("Received sync audit response from " .. sender)
end

-- luacheck: ignore MIN_SYNC_VERSION
