local DR = select(2, ...)
local constants = DR.Modules["Core.Constants"]

local SessionManager = {
  sessions = {},
  sessionOrder = {},
  latestSessionId = nil,
  sessionCounter = 0,
}
LibStub("AceEvent-3.0"):Embed(SessionManager)

local function nowTs()
  if GetServerTime then
    return GetServerTime()
  end
  return time()
end

local function makeSessionId(nonce)
  local guid = UnitGUID("player") or "noguid"
  -- The nonce disambiguates sessions opened within the same second (e.g.
  -- several items auto-added from one loot window).
  return string.format("%s-%d-%d", guid, nowTs(), nonce or 0)
end

local function toNumber(value, fallback)
  local n = tonumber(value)
  if n == nil then
    return fallback
  end
  return n
end

local function copySubmissionsMap(submissions)
  local map = {}
  for _, submit in ipairs(submissions or {}) do
    if submit.guid then
      map[submit.guid] = {
        category = submit.category,
        baseRoll = submit.baseRoll,
        weight = submit.weight,
        submittedAt = submit.submittedAt,
        revision = submit.revision,
      }
    end
  end
  return map
end

local function getRevision(session)
  if not session then
    return 0
  end
  return toNumber(session.revision, 0)
end

-- Sessions only move forward through this order; used to reject out-of-order
-- state updates that carry the same revision (transitions don't bump it).
local STATE_RANK = {
  DRAFT = 1,
  OPEN = 2,
  COLLECTING = 3,
  RESOLVING = 4,
  ROLLOFF = 5,
  AWARDED = 6,
  REWARDED = 7,
  ARCHIVED = 8,
}

local function stateRank(state)
  return STATE_RANK[state] or 0
end

local function getGroupLeaderGuid()
  if not IsInGroup() then
    return nil
  end
  if UnitIsGroupLeader("player") then
    return UnitGUID("player")
  end
  if IsInRaid() then
    for i = 1, (GetNumGroupMembers() or 0) do
      local unit = "raid" .. i
      if UnitExists(unit) and UnitIsGroupLeader(unit) then
        return UnitGUID(unit)
      end
    end
  else
    for i = 1, 4 do
      local unit = "party" .. i
      if UnitExists(unit) and UnitIsGroupLeader(unit) then
        return UnitGUID(unit)
      end
    end
  end
  return nil
end

--- Authority messages (session init, results, rewards, snapshots) are only
--- accepted from the actual group leader, verified against the transport
--- sender — a hostile client in the raid cannot inject session state.
local function isFromGroupLeader(envelope)
  local senderGuid = envelope and envelope.verifiedSenderGuid
  if not senderGuid then
    return false
  end
  local leaderGuid = getGroupLeaderGuid()
  return leaderGuid ~= nil and senderGuid == leaderGuid
end

local function rejectNonAuthority(envelope, messageType)
  DR:Require("Services.Audit"):Append("AUTHORITY_REJECTED", {
    messageType = messageType,
    sender = envelope and (envelope.verifiedSenderName or tostring(envelope.sender)) or "unknown",
  })
end

local function markSync(session, source, reason, revision)
  if not session then
    return
  end

  session.syncInfo = {
    source = source or "unknown",
    reason = reason or "sync",
    revision = toNumber(revision, getRevision(session)),
    at = nowTs(),
  }
end

function SessionManager:OnInitialize()
  self.sessions = {}
  self.sessionOrder = {}
  self.latestSessionId = nil
  self.sessionCounter = 0

  local comms = DR:Require("Services.Comms")
  comms:Subscribe(constants.COMMS.MESSAGE.INIT_SESSION, function(envelope)
    self:OnInitSessionReceived(envelope)
  end)
  comms:Subscribe(constants.COMMS.MESSAGE.REQUEST_SNAPSHOT, function(envelope)
    self:OnRequestSnapshotReceived(envelope)
  end)
  comms:Subscribe(constants.COMMS.MESSAGE.SUBMIT_ROLL, function(envelope)
    self:OnSubmitRollReceived(envelope)
  end)
  comms:Subscribe(constants.COMMS.MESSAGE.CANCEL_ROLL, function(envelope)
    self:OnCancelRollReceived(envelope)
  end)
  comms:Subscribe(constants.COMMS.MESSAGE.RESOLVE_RESULT, function(envelope)
    self:OnResolveResultReceived(envelope)
  end)
  comms:Subscribe(constants.COMMS.MESSAGE.ROLLOFF_START, function(envelope)
    self:OnRollOffStartReceived(envelope)
  end)
  comms:Subscribe(constants.COMMS.MESSAGE.ROLLOFF_RESULT, function(envelope)
    self:OnRollOffResultReceived(envelope)
  end)
  comms:Subscribe(constants.COMMS.MESSAGE.STATE_SNAPSHOT, function(envelope)
    self:OnStateSnapshotReceived(envelope)
  end)
  comms:Subscribe(constants.COMMS.MESSAGE.REWARD, function(envelope)
    self:OnRewardReceived(envelope)
  end)

  -- Marking a deferred trade as traded (auto-detected or manual) confirms
  -- the reward, which is when the winner's roll-power penalty applies.
  self:RegisterMessage("DR_DEFERRED_TRADE_UPDATED", function(_, entry)
    self:OnDeferredTradeUpdated(entry)
  end)
end

function SessionManager:OnEnable()
  DR:RegisterEvent("ENCOUNTER_END", function(_, encounterID, encounterName, difficultyID, groupSize, success)
    if success == 1 then
      -- Boss regain only counts in raids designated via the activation
      -- prompt (or test mode) - random raids/dungeons don't restore power.
      if not DR:Require("Modules.RaidActivation"):IsActive() then
        return
      end
      DR:Require("Services.Ledger"):ApplyBossRegainForRaid(encounterID)
      DR:Require("Services.Audit"):Append("BOSS_KILL_REGAIN_TICK", {
        encounterID = encounterID,
        encounterName = encounterName,
        difficultyID = difficultyID,
        groupSize = groupSize,
      })
    end
  end)
end

--- Public wrapper so other modules can verify a message came from the leader.
function SessionManager:IsEnvelopeFromLeader(envelope)
  return isFromGroupLeader(envelope)
end

function SessionManager:IsRaidLeaderAuthority()
  -- Test mode lets a solo player act as their own raid leader so the whole
  -- flow can be exercised without a group.
  if DR.Runtime.testMode and not IsInGroup() then
    return true
  end
  return UnitIsGroupLeader("player") == true
end

function SessionManager:GetSession(sessionId)
  return self.sessions[sessionId]
end

function SessionManager:GetLatestSession()
  if not self.latestSessionId then
    return nil
  end
  return self.sessions[self.latestSessionId]
end

function SessionManager:TrackSessionOrder(sessionId)
  for _, id in ipairs(self.sessionOrder) do
    if id == sessionId then
      return
    end
  end
  self.sessionOrder[#self.sessionOrder + 1] = sessionId
end

function SessionManager:GetOrderedSessions()
  local out = {}
  for _, id in ipairs(self.sessionOrder) do
    local session = self.sessions[id]
    if session then
      out[#out + 1] = session
    end
  end
  return out
end

--- Sessions worth showing in the UI: anything not yet archived. Awarded
--- sessions stay listed (with their winner) until the leader archives them.
function SessionManager:GetActiveSessions()
  local out = {}
  for _, session in ipairs(self:GetOrderedSessions()) do
    if session.state ~= constants.SESSION.STATE.ARCHIVED then
      out[#out + 1] = session
    end
  end
  return out
end

function SessionManager:ReconcileSnapshot(snapshot, syncMeta)
  if type(snapshot) ~= "table" or not snapshot.sessionId then
    return false, "invalid_snapshot"
  end

  local existing = self.sessions[snapshot.sessionId]
  local incomingRevision = toNumber(snapshot.revision, 0)
  local currentRevision = getRevision(existing)
  if existing and incomingRevision < currentRevision then
    return false, "stale_snapshot"
  end
  -- Equal revisions: never regress the state machine (snapshots for
  -- back-to-back transitions share a revision).
  if existing and incomingRevision == currentRevision and stateRank(snapshot.state) < stateRank(existing.state) then
    return false, "stale_state"
  end

  local session = existing or {
    id = snapshot.sessionId,
    submissionsByGuid = {},
    rollOff = { round = 0, tiedGuids = {} },
  }

  session.id = snapshot.sessionId
  session.itemNonce = snapshot.itemNonce
  session.itemLink = snapshot.itemLink
  session.createdBy = snapshot.createdBy
  session.revision = incomingRevision
  session.state = snapshot.state or session.state or constants.SESSION.STATE.DRAFT
  session.timeoutAt = snapshot.timeoutAt
  session.submissionsByGuid = copySubmissionsMap(snapshot.submissions)
  session.rollOff = snapshot.rollOff or session.rollOff or { round = 0, tiedGuids = {} }
  session.winner = snapshot.winner
  session.rewarded = snapshot.rewarded
  markSync(session, syncMeta and syncMeta.source, syncMeta and syncMeta.reason or "state_snapshot", incomingRevision)

  self.sessions[snapshot.sessionId] = session
  self.latestSessionId = snapshot.sessionId
  self:TrackSessionOrder(snapshot.sessionId)

  return true, session
end

function SessionManager:RequestSnapshot(sessionId)
  if not sessionId then
    return false
  end

  local comms = DR:Require("Services.Comms")
  return comms:SendMessage(constants.COMMS.MESSAGE.REQUEST_SNAPSHOT, {
    sessionId = sessionId,
  }, {
    sessionId = sessionId,
    reliable = false,
  })
end

function SessionManager:GetSessionSnapshot(sessionId)
  local session = self.sessions[sessionId]
  if not session then
    return nil
  end

  local snapshot = {
    sessionId = session.id,
    revision = session.revision,
    state = session.state,
    itemLink = session.itemLink,
    itemNonce = session.itemNonce,
    timeoutAt = session.timeoutAt,
    createdBy = session.createdBy,
    submissions = {},
    rollOff = session.rollOff,
    winner = session.winner,
    rewarded = session.rewarded,
  }

  for guid, submit in pairs(session.submissionsByGuid) do
    snapshot.submissions[#snapshot.submissions + 1] = {
      guid = guid,
      category = submit.category,
      baseRoll = submit.baseRoll,
      weight = submit.weight,
      submittedAt = submit.submittedAt,
      revision = submit.revision,
    }
  end

  table.sort(snapshot.submissions, function(a, b)
    return a.guid < b.guid
  end)

  return snapshot
end

function SessionManager:EmitUiEvent(eventName, payload)
  DR:SendMessage(eventName, payload)
end

function SessionManager:OpenSession(itemLink, timeoutSeconds)
  if not self:IsRaidLeaderAuthority() then
    return false, "not_raid_leader"
  end

  local canStart, outdated = DR:Require("Modules.VersionCheck"):CanStartSession()
  if not canStart then
    return false, "version_gate", outdated
  end

  self.sessionCounter = self.sessionCounter + 1
  local itemNonce = self.sessionCounter
  local sessionId = makeSessionId(itemNonce)

  local session = {
    id = sessionId,
    itemNonce = itemNonce,
    itemLink = itemLink,
    createdBy = UnitGUID("player"),
    createdAt = nowTs(),
    revision = 1,
    state = constants.SESSION.STATE.DRAFT,
    submissionsByGuid = {},
    timeoutAt = nowTs() + (timeoutSeconds or DR.db.profile.sessionTimeoutSeconds),
    rollOff = {
      round = 0,
      tiedGuids = {},
    },
    winner = nil,
  }

  self.sessions[sessionId] = session
  self.latestSessionId = sessionId
  self:TrackSessionOrder(sessionId)
  markSync(session, UnitGUID("player"), "session_open", session.revision)

  local state = DR:Require("Services.SessionState")
  state:Transition(sessionId, constants.SESSION.STATE.OPEN, "session_created")
  session.state = state:GetState(sessionId)
  state:Transition(sessionId, constants.SESSION.STATE.COLLECTING, "collecting_started")
  session.state = state:GetState(sessionId)

  local comms = DR:Require("Services.Comms")
  comms:SendMessage(constants.COMMS.MESSAGE.INIT_SESSION, {
    sessionId = sessionId,
    itemNonce = itemNonce,
    itemLink = itemLink,
    timeoutAt = session.timeoutAt,
    revision = session.revision,
  }, {
    sessionId = sessionId,
    itemNonce = itemNonce,
    revision = session.revision,
    reliable = true,
  })

  DR:Require("Services.Audit"):Append("SESSION_OPENED", {
    sessionId = sessionId,
    itemNonce = itemNonce,
    itemLink = itemLink,
    timeoutAt = session.timeoutAt,
  })

  self:EmitUiEvent("DR_SESSION_OPENED", self:GetSessionSnapshot(sessionId))
  self:ScheduleAutoResolve(sessionId, (timeoutSeconds or DR.db.profile.sessionTimeoutSeconds))

  return true, sessionId
end

function SessionManager:ScheduleAutoResolve(sessionId, delaySeconds)
  DR:ScheduleTimer(function()
    local session = self.sessions[sessionId]
    if not session or session.state ~= constants.SESSION.STATE.COLLECTING then
      return
    end
    if not self:IsRaidLeaderAuthority() then
      return
    end

    local ok, reason = self:ResolveSession(sessionId)
    if not ok and reason == "no_submissions" then
      self:ArchiveSession(sessionId, "timeout_no_submissions")
      DR:Print("Session timed out with no rolls; archived.")
    end
  end, math.max(delaySeconds, 1) + 1)
end

function SessionManager:GenerateRoll(category)
  local minRoll, maxRoll = DR:Require("Domain.RollEngine"):GetBaseRange(category)
  if not minRoll then
    return nil
  end
  return math.random(minRoll, maxRoll)
end

function SessionManager:BuildSubmission(guid, category, baseRoll)
  local ledger = DR:Require("Services.Ledger")
  local weight = ledger:GetWeight(guid)
  return {
    guid = guid,
    category = category,
    baseRoll = baseRoll,
    weight = weight,
    submittedAt = nowTs(),
  }
end

function SessionManager:ValidateSubmission(session, submission)
  if not session then
    return false, "unknown_session"
  end

  if session.state ~= constants.SESSION.STATE.COLLECTING then
    return false, "not_collecting"
  end

  if nowTs() > session.timeoutAt then
    return false, "deadline_passed"
  end

  if session.submissionsByGuid[submission.guid] then
    local existing = session.submissionsByGuid[submission.guid]
    if existing.category ~= submission.category then
      return false, "category_locked"
    end
    return false, "duplicate_submit"
  end

  return true
end

function SessionManager:SubmitLocalRoll(sessionId, category)
  local session = self.sessions[sessionId]
  if not session then
    return false, "unknown_session"
  end

  category = tonumber(category)
  local isPass = category == constants.ROLL.CATEGORY.PASS
  if not isPass then
    local minRoll = DR:Require("Domain.RollEngine"):GetBaseRange(category)
    if not minRoll then
      return false, "invalid_category"
    end
  end

  local guid = UnitGUID("player")
  if not guid then
    return false, "missing_guid"
  end

  -- The session authority (raid leader) generates all rolls. Raiders only
  -- transmit their chosen category, so a modified client cannot forge a roll.
  if self:IsRaidLeaderAuthority() then
    local baseRoll = isPass and 0 or self:GenerateRoll(category)
    local submit = self:BuildSubmission(guid, category, baseRoll)
    local ok, reason = self:ValidateSubmission(session, submit)
    if not ok then
      return false, reason
    end
    self:AcceptSubmission(session, submit, "local")
    -- The competing score is what we report (need rolls are weighted;
    -- transmog/offspec are flat).
    local effective = isPass and 0
      or DR:Require("Domain.RollEngine"):ComputeScoreFor(category, baseRoll, submit.weight or 100)
    return true, effective, baseRoll, submit.weight
  end

  local pending = self:BuildSubmission(guid, category, 0)
  local ok, reason = self:ValidateSubmission(session, pending)
  if not ok then
    return false, reason
  end

  local payload = {
    sessionId = sessionId,
    itemNonce = session.itemNonce,
    category = category,
  }

  local sent = DR:Require("Services.Comms"):SendMessage(constants.COMMS.MESSAGE.SUBMIT_ROLL, payload, {
    sessionId = sessionId,
    itemNonce = session.itemNonce,
    revision = session.revision,
    reliable = false,
  })

  if sent then
    -- Local echo so repeated clicks are rejected before the leader's snapshot
    -- comes back; the authoritative snapshot overwrites this entry.
    session.submissionsByGuid[guid] = {
      category = category,
      baseRoll = nil,
      weight = pending.weight,
      submittedAt = pending.submittedAt,
      pending = true,
    }
    DR:Require("Services.Audit"):Append("ROLL_SUBMITTED", {
      sessionId = sessionId,
      category = category,
      guid = guid,
    })
    return true, isPass and "pass_sent" or "sent"
  end

  return false, "send_failed"
end

function SessionManager:AcceptSubmission(session, submission, source)
  session.submissionsByGuid[submission.guid] = {
    category = submission.category,
    baseRoll = submission.baseRoll,
    weight = submission.weight,
    submittedAt = submission.submittedAt,
    revision = session.revision,
  }

  session.revision = session.revision + 1
  markSync(session, source == "remote" and "remote_submission" or UnitGUID("player"), "submission_accepted", session.revision)

  DR:Require("Services.Audit"):Append("ROLL_ACCEPTED", {
    sessionId = session.id,
    guid = submission.guid,
    category = submission.category,
    baseRoll = submission.baseRoll,
    weight = submission.weight,
    source = source,
    revision = session.revision,
  })

  self:EmitUiEvent("DR_SUBMISSION_ACCEPTED", {
    sessionId = session.id,
    guid = submission.guid,
    category = submission.category,
    baseRoll = submission.baseRoll,
  })

  return true
end

--- True while rolls can still be added or retracted for a session.
function SessionManager:CanRetract(session)
  return session ~= nil
    and session.state == constants.SESSION.STATE.COLLECTING
    and nowTs() <= (session.timeoutAt or 0)
end

function SessionManager:RemoveSubmission(session, guid, source)
  if not session.submissionsByGuid[guid] then
    return false, "no_submission"
  end

  session.submissionsByGuid[guid] = nil
  session.revision = session.revision + 1
  markSync(session, source == "remote" and "remote_cancel" or UnitGUID("player"), "roll_cancelled", session.revision)

  DR:Require("Services.Audit"):Append("ROLL_CANCELLED", {
    sessionId = session.id,
    guid = guid,
    source = source,
    revision = session.revision,
  })

  self:EmitUiEvent("DR_SUBMISSION_CANCELLED", {
    sessionId = session.id,
    guid = guid,
  })

  return true
end

--- A raider retracting their own roll to pick again (fixes misclicks).
function SessionManager:CancelLocalRoll(sessionId)
  local session = self.sessions[sessionId]
  if not session then
    return false, "unknown_session"
  end
  if not self:CanRetract(session) then
    return false, "too_late"
  end

  local guid = UnitGUID("player")
  if not guid or not session.submissionsByGuid[guid] then
    return false, "no_roll"
  end

  if self:IsRaidLeaderAuthority() then
    return self:RemoveSubmission(session, guid, "local")
  end

  local sent = DR:Require("Services.Comms"):SendMessage(constants.COMMS.MESSAGE.CANCEL_ROLL, {
    sessionId = sessionId,
  }, {
    sessionId = sessionId,
    itemNonce = session.itemNonce,
    reliable = false,
  })

  if sent then
    session.submissionsByGuid[guid] = nil
    DR:Require("Services.Audit"):Append("ROLL_CANCEL_SENT", { sessionId = sessionId, guid = guid })
    self:EmitUiEvent("DR_SUBMISSION_CANCELLED", { sessionId = sessionId, guid = guid })
    return true
  end
  return false, "send_failed"
end

function SessionManager:OnCancelRollReceived(envelope)
  if not self:IsRaidLeaderAuthority() then
    return
  end

  local guid = envelope.verifiedSenderGuid
  if not guid then
    return
  end

  local payload = envelope.payload or {}
  local session = self.sessions[payload.sessionId]
  if not session or not self:CanRetract(session) then
    return
  end
  if session.submissionsByGuid[guid] then
    self:RemoveSubmission(session, guid, "remote")
  end
end

--- Leader rolls on behalf of a player without the addon.
function SessionManager:SubmitProxyRoll(sessionId, guid, category)
  if not self:IsRaidLeaderAuthority() then
    return false, "not_raid_leader"
  end
  local session = self.sessions[sessionId]
  if not session then
    return false, "unknown_session"
  end
  if not guid then
    return false, "missing_guid"
  end

  category = tonumber(category)
  local isPass = category == constants.ROLL.CATEGORY.PASS
  if not isPass then
    local minRoll = DR:Require("Domain.RollEngine"):GetBaseRange(category)
    if not minRoll then
      return false, "invalid_category"
    end
  end

  local baseRoll = isPass and 0 or self:GenerateRoll(category)
  local submit = self:BuildSubmission(guid, category, baseRoll)
  local ok, reason = self:ValidateSubmission(session, submit)
  if not ok then
    return false, reason
  end
  self:AcceptSubmission(session, submit, "proxy")

  local effective = isPass and 0
    or DR:Require("Domain.RollEngine"):ComputeScoreFor(category, baseRoll, submit.weight or 100)
  return true, effective, baseRoll, submit.weight
end

--- Leader retracts a proxy (or any) roll while collecting.
function SessionManager:CancelRollFor(sessionId, guid)
  if not self:IsRaidLeaderAuthority() then
    return false, "not_raid_leader"
  end
  local session = self.sessions[sessionId]
  if not session then
    return false, "unknown_session"
  end
  if not self:CanRetract(session) then
    return false, "too_late"
  end
  return self:RemoveSubmission(session, guid, "proxy")
end

function SessionManager:BuildWinnerFromSubmission(session, guid)
  local submit = session.submissionsByGuid and session.submissionsByGuid[guid]
  if not submit or submit.category == constants.ROLL.CATEGORY.PASS or not submit.baseRoll then
    return nil
  end
  local engine = DR:Require("Domain.RollEngine")
  return {
    guid = guid,
    category = submit.category,
    baseRoll = submit.baseRoll,
    weight = submit.weight,
    effectiveScore = engine:ComputeScoreFor(submit.category, submit.baseRoll, submit.weight),
  }
end

--- Replaces a session's winner (manual re-award or weight recomputation) and
--- rebroadcasts the result so all clients converge.
function SessionManager:ApplyWinnerChange(session, winner, reason)
  local deferred = DR:Require("Services.DeferredTrade")
  local tradeId = session.winner and session.winner.deferredTradeId
  if tradeId then
    deferred:SetWinner(tradeId, winner.guid)
  end
  winner.deferredTradeId = tradeId

  session.winner = winner
  session.revision = session.revision + 1
  markSync(session, UnitGUID("player"), reason, session.revision)

  local payload = {
    sessionId = session.id,
    itemNonce = session.itemNonce,
    winner = session.winner,
    deferredTradeId = tradeId,
    revision = session.revision,
    reason = reason,
  }

  DR:Require("Services.Comms"):SendMessage(constants.COMMS.MESSAGE.RESOLVE_RESULT, payload, {
    sessionId = session.id,
    itemNonce = session.itemNonce,
    revision = session.revision,
    reliable = true,
  })

  DR:Require("Services.Audit"):Append("WINNER_CHANGED", payload)
  self:EmitUiEvent("DR_SESSION_RESOLVED", payload)
end

--- Manual winner change from the leader's rolls list, e.g. when the original
--- winner no longer wants the item. Only before the reward is confirmed.
function SessionManager:SetWinner(sessionId, guid, source)
  if not self:IsRaidLeaderAuthority() then
    return false, "not_raid_leader"
  end
  local session = self.sessions[sessionId]
  if not session then
    return false, "unknown_session"
  end
  if session.state ~= constants.SESSION.STATE.AWARDED then
    return false, "not_awarded"
  end
  if session.winner and session.winner.guid == guid then
    return false, "already_winner"
  end

  local winner = self:BuildWinnerFromSubmission(session, guid)
  if not winner then
    return false, "invalid_submission"
  end

  self:ApplyWinnerChange(session, winner, source or "manual_winner_change")
  return true, winner
end

--- After a reward penalty, a player's captured weight in every other open
--- session is stale. Update it, and re-resolve awarded-but-unrewarded
--- sessions so nobody wins two items at the cost of one penalty.
function SessionManager:PropagateWeightUpdate(changedGuid, newWeight, exceptSessionId)
  local engine = DR:Require("Domain.RollEngine")
  local S = constants.SESSION.STATE

  for _, session in ipairs(self:GetOrderedSessions()) do
    local submit = session.id ~= exceptSessionId
      and session.submissionsByGuid
      and session.submissionsByGuid[changedGuid]

    if submit and submit.category ~= constants.ROLL.CATEGORY.PASS then
      if session.state == S.COLLECTING or session.state == S.ROLLOFF then
        submit.weight = newWeight
        session.revision = session.revision + 1
        DR:Require("Services.Audit"):Append("SUBMISSION_WEIGHT_UPDATED", {
          sessionId = session.id,
          guid = changedGuid,
          weight = newWeight,
        })
        self:EmitUiEvent("DR_SUBMISSION_ACCEPTED", { sessionId = session.id, guid = changedGuid })
      elseif session.state == S.AWARDED then
        submit.weight = newWeight

        local inputs = {}
        for g, s in pairs(session.submissionsByGuid) do
          inputs[#inputs + 1] = { guid = g, category = s.category, baseRoll = s.baseRoll, weight = s.weight }
        end
        local result = engine:ResolveWinner(inputs)
        if result and result.winner then
          if session.winner and result.winner.guid == session.winner.guid then
            session.winner.weight = result.winner.weight
            session.winner.effectiveScore = result.winner.effectiveScore
            session.revision = session.revision + 1
          else
            self:ApplyWinnerChange(session, {
              guid = result.winner.guid,
              category = result.winner.category,
              baseRoll = result.winner.baseRoll,
              weight = result.winner.weight,
              effectiveScore = result.winner.effectiveScore,
            }, "weight_propagation")
            local display = DR:Require("Services.Identity"):GetDisplayByGuid(result.winner.guid) or result.winner.guid
            DR:Print(string.format("Winner of %s changed to %s (roll power update)", session.itemLink or session.id, display))
          end
        end
      end
    end
  end
end

function SessionManager:OnSubmitRollReceived(envelope)
  if not self:IsRaidLeaderAuthority() then
    return
  end

  local payload = envelope.payload or {}
  local sessionId = payload.sessionId
  local session = self.sessions[sessionId]
  if not session then
    return
  end

  -- Identity comes from the transport-verified sender, never from the payload.
  local guid = envelope.verifiedSenderGuid
  if not guid then
    DR:Require("Services.Audit"):Append("ROLL_REJECTED_UNVERIFIED", {
      sessionId = sessionId,
      sender = envelope.verifiedSenderName or tostring(envelope.sender),
    })
    return
  end

  local category = tonumber(payload.category)
  local isPass = category == constants.ROLL.CATEGORY.PASS
  if not isPass then
    local minRoll = DR:Require("Domain.RollEngine"):GetBaseRange(category)
    if not minRoll then
      return
    end
  end

  -- The leader generates the roll; any roll value in the payload is ignored.
  local baseRoll = isPass and 0 or self:GenerateRoll(category)
  local submit = self:BuildSubmission(guid, category, baseRoll)
  local ok = self:ValidateSubmission(session, submit)
  if not ok then
    return
  end

  self:AcceptSubmission(session, submit, "remote")
end

function SessionManager:ResolveSession(sessionId)
  if not self:IsRaidLeaderAuthority() then
    return false, "not_raid_leader"
  end

  local session = self.sessions[sessionId]
  if not session then
    return false, "unknown_session"
  end

  local state = DR:Require("Services.SessionState")
  local transitioned = state:Transition(sessionId, constants.SESSION.STATE.RESOLVING, "resolve_requested")
  if not transitioned then
    return false, "state_transition_failed"
  end
  session.state = state:GetState(sessionId)

  local rollEngine = DR:Require("Domain.RollEngine")
  local inputs = {}
  for guid, submit in pairs(session.submissionsByGuid) do
    inputs[#inputs + 1] = {
      guid = guid,
      category = submit.category,
      baseRoll = submit.baseRoll,
      weight = submit.weight,
    }
  end

  local result = rollEngine:ResolveWinner(inputs)
  if not result then
    return false, "no_submissions"
  end

  if result.tie then
    return self:StartRollOff(session, result)
  end

  return self:FinalizeWinner(session, result)
end

function SessionManager:StartRollOff(session, result)
  local state = DR:Require("Services.SessionState")
  state:Transition(session.id, constants.SESSION.STATE.ROLLOFF, "tie_detected")
  session.state = state:GetState(session.id)

  session.rollOff.round = (session.rollOff.round or 0) + 1
  session.rollOff.tiedGuids = result.tieGuids
  session.rollOff.category = result.winner.category
  session.revision = session.revision + 1
  markSync(session, UnitGUID("player"), "rolloff_started", session.revision)

  local payload = {
    sessionId = session.id,
    round = session.rollOff.round,
    category = session.rollOff.category,
    tiedGuids = session.rollOff.tiedGuids,
    revision = session.revision,
  }

  DR:Require("Services.Comms"):SendMessage(constants.COMMS.MESSAGE.ROLLOFF_START, payload, {
    sessionId = session.id,
    itemNonce = session.itemNonce,
    revision = session.revision,
    reliable = true,
  })

  DR:Require("Services.Audit"):Append("ROLLOFF_STARTED", payload)
  self:EmitUiEvent("DR_ROLLOFF_STARTED", payload)

  return true, "rolloff_started"
end

function SessionManager:RunRollOffRound(sessionId)
  if not self:IsRaidLeaderAuthority() then
    return false, "not_raid_leader"
  end

  local session = self.sessions[sessionId]
  if not session or session.state ~= constants.SESSION.STATE.ROLLOFF then
    return false, "not_in_rolloff"
  end

  local entries = {}
  for _, guid in ipairs(session.rollOff.tiedGuids or {}) do
    local submit = session.submissionsByGuid[guid]
    if submit then
      entries[#entries + 1] = {
        guid = guid,
        category = submit.category,
        baseRoll = submit.baseRoll,
        weight = submit.weight,
      }
    end
  end

  local rollEngine = DR:Require("Domain.RollEngine")
  local result = rollEngine:RunRollOffRound(entries, session.rollOff.category, session.rollOff.round + 1)
  if not result then
    return false, "rolloff_failed"
  end

  session.revision = session.revision + 1

  local payload = {
    sessionId = sessionId,
    round = session.rollOff.round + 1,
    tie = result.tie,
    tieGuids = result.tieGuids,
    winner = result.winner,
    revision = session.revision,
  }

  DR:Require("Services.Comms"):SendMessage(constants.COMMS.MESSAGE.ROLLOFF_RESULT, payload, {
    sessionId = session.id,
    itemNonce = session.itemNonce,
    revision = session.revision,
    reliable = true,
  })

  DR:Require("Services.Audit"):Append("ROLLOFF_RESULT", payload)

  if result.tie then
    session.rollOff.round = payload.round
    session.rollOff.tiedGuids = result.tieGuids
    markSync(session, UnitGUID("player"), "rolloff_result_tie", session.revision)
    if result.winner then
      local prev = session.submissionsByGuid[result.winner.guid]
      if prev then
        prev.baseRoll = result.winner.baseRoll
      end
    end
    self:EmitUiEvent("DR_ROLLOFF_RESULT", payload)
    return true, "rolloff_continue"
  end

  return self:FinalizeWinner(session, result)
end

function SessionManager:FinalizeWinner(session, result)
  local winner = result.winner
  if not winner then
    return false, "missing_winner"
  end

  local state = DR:Require("Services.SessionState")
  state:Transition(session.id, constants.SESSION.STATE.AWARDED, "winner_finalized")
  session.state = state:GetState(session.id)

  -- The roll-power penalty is NOT applied here: it applies when the reward
  -- is confirmed (item traded to the winner, or the leader presses Reward).
  local tradeId = nil
  if session.itemLink and session.itemLink ~= "" then
    tradeId = select(1, DR:Require("Services.DeferredTrade"):Add(session.itemLink, session.id, winner.guid))
  end

  session.winner = {
    guid = winner.guid,
    category = winner.category,
    baseRoll = winner.baseRoll,
    weight = winner.weight,
    effectiveScore = winner.effectiveScore,
    deferredTradeId = tradeId,
  }
  session.rewarded = false
  session.revision = session.revision + 1
  markSync(session, UnitGUID("player"), "winner_finalized", session.revision)

  local payload = {
    sessionId = session.id,
    itemNonce = session.itemNonce,
    winner = session.winner,
    deferredTradeId = tradeId,
    revision = session.revision,
  }

  DR:Require("Services.Comms"):SendMessage(constants.COMMS.MESSAGE.RESOLVE_RESULT, payload, {
    sessionId = session.id,
    itemNonce = session.itemNonce,
    revision = session.revision,
    reliable = true,
  })

  DR:Require("Services.Audit"):Append("SESSION_RESOLVED", payload)

  if DR.db.profile.fallbackChatEnabled and SendChatMessage and IsInGroup() then
    local locked = C_ChatInfo and C_ChatInfo.InChatMessagingLockdown and C_ChatInfo.InChatMessagingLockdown()
    if not locked then
      local display = DR:Require("Services.Identity"):GetDisplayByGuid(winner.guid) or winner.guid
      local channel = IsInRaid() and "RAID" or "PARTY"
      pcall(SendChatMessage, string.format("[DetentionRolls] Winner: %s (%d)", display, winner.effectiveScore or 0), channel)
    end
  end

  self:EmitUiEvent("DR_SESSION_RESOLVED", payload)
  return true, winner.guid
end

--- Confirms the reward: applies the winner's roll-power penalty and tells the
--- whole raid the new authoritative weight. Called by the leader's Reward
--- button or automatically when the item is traded to the winner.
function SessionManager:RewardWinner(sessionId, source)
  if not self:IsRaidLeaderAuthority() then
    return false, "not_raid_leader"
  end

  local session = self.sessions[sessionId]
  if not session then
    return false, "unknown_session"
  end
  if session.state ~= constants.SESSION.STATE.AWARDED then
    return false, "not_awarded"
  end
  if not session.winner then
    return false, "missing_winner"
  end

  local state = DR:Require("Services.SessionState")
  local ok = state:Transition(sessionId, constants.SESSION.STATE.REWARDED, source or "reward_confirmed")
  if not ok then
    return false, "state_transition_failed"
  end
  session.state = state:GetState(sessionId)

  -- Only need-roll wins cost roll power; transmog/offspec wins are free.
  local ledger = DR:Require("Services.Ledger")
  local isNeedWin = DR:Require("Domain.RollEngine"):IsWeightedCategory(session.winner.category)
  local newWeight
  if isNeedWin then
    newWeight = ledger:ApplyWinnerPenalty(session.winner.guid)
    -- Refresh this player's captured weight in every other open session so
    -- concurrent items compete against the post-penalty power.
    self:PropagateWeightUpdate(session.winner.guid, newWeight, sessionId)
  else
    newWeight = ledger:GetWeight(session.winner.guid)
  end
  session.winner.postPenaltyWeight = newWeight
  session.rewarded = true
  session.revision = session.revision + 1
  markSync(session, UnitGUID("player"), "reward_confirmed", session.revision)

  local payload = {
    sessionId = sessionId,
    itemNonce = session.itemNonce,
    winnerGuid = session.winner.guid,
    newWeight = newWeight,
    penaltyApplied = isNeedWin,
    source = source or "manual",
    revision = session.revision,
  }

  DR:Require("Services.Comms"):SendMessage(constants.COMMS.MESSAGE.REWARD, payload, {
    sessionId = sessionId,
    itemNonce = session.itemNonce,
    revision = session.revision,
    reliable = true,
  })

  DR:Require("Services.Audit"):Append("REWARD_CONFIRMED", payload)
  self:EmitUiEvent("DR_SESSION_REWARDED", payload)

  return true, newWeight
end

function SessionManager:OnDeferredTradeUpdated(entry)
  if type(entry) ~= "table" or entry.status ~= constants.DEFERRED_TRADE.STATUS.TRADED then
    return
  end
  if not entry.sessionId or not self:IsRaidLeaderAuthority() then
    return
  end

  local session = self.sessions[entry.sessionId]
  if not session or session.state ~= constants.SESSION.STATE.AWARDED then
    return
  end
  if not session.winner or session.winner.guid ~= entry.winnerGuid then
    return
  end

  self:RewardWinner(entry.sessionId, "trade_complete")
end

function SessionManager:OnRewardReceived(envelope)
  if not isFromGroupLeader(envelope) then
    rejectNonAuthority(envelope, constants.COMMS.MESSAGE.REWARD)
    return
  end

  local payload = envelope.payload or {}
  local sessionId = payload.sessionId
  if not sessionId or not payload.winnerGuid then
    return
  end

  local session = self.sessions[sessionId]
  if session then
    local incomingRevision = toNumber(payload.revision, 0)
    if incomingRevision >= getRevision(session) then
      session.state = constants.SESSION.STATE.REWARDED
      session.rewarded = true
      session.revision = incomingRevision
      if session.winner then
        session.winner.postPenaltyWeight = payload.newWeight
      end
      markSync(session, envelope.verifiedSenderGuid or envelope.sender, "reward", session.revision)
    end
  end

  -- Mirror the leader's authoritative weight locally so roll-power displays
  -- stay in sync across the raid.
  DR:Require("Services.Ledger"):SetWeight(payload.winnerGuid, payload.newWeight, "leader_reward_sync")

  self:EmitUiEvent("DR_SESSION_REWARDED", payload)
end

function SessionManager:ArchiveSession(sessionId, reason)
  if not self:IsRaidLeaderAuthority() then
    return false, "not_raid_leader"
  end

  local session = self.sessions[sessionId]
  if not session then
    return false, "unknown_session"
  end

  local state = DR:Require("Services.SessionState")
  local ok = state:Transition(session.id, constants.SESSION.STATE.ARCHIVED, reason or "manual_archive")
  if not ok then
    return false, "state_transition_failed"
  end

  session.state = state:GetState(session.id)
  session.revision = session.revision + 1
  markSync(session, UnitGUID("player"), "session_archived", session.revision)

  DR:Require("Services.Audit"):Append("SESSION_ARCHIVED", {
    sessionId = sessionId,
    reason = reason,
    revision = session.revision,
  })

  self:EmitUiEvent("DR_SESSION_ARCHIVED", {
    sessionId = sessionId,
    revision = session.revision,
  })

  return true
end

function SessionManager:OnInitSessionReceived(envelope)
  if not isFromGroupLeader(envelope) then
    rejectNonAuthority(envelope, constants.COMMS.MESSAGE.INIT_SESSION)
    return
  end

  local payload = envelope.payload or {}
  local sessionId = payload.sessionId
  if not sessionId then
    return
  end

  local existing = self.sessions[sessionId]
  local incomingRevision = toNumber(payload.revision, 0)
  if existing and incomingRevision < getRevision(existing) then
    return
  end

  local session = self.sessions[sessionId]
  if not session then
    session = {
      id = sessionId,
      itemNonce = payload.itemNonce,
      itemLink = payload.itemLink,
      createdBy = envelope.verifiedSenderGuid or envelope.sender,
      createdAt = nowTs(),
      revision = payload.revision or 1,
      state = constants.SESSION.STATE.COLLECTING,
      submissionsByGuid = {},
      timeoutAt = payload.timeoutAt,
      rollOff = { round = 0, tiedGuids = {} },
      winner = nil,
    }
    self.sessions[sessionId] = session
  else
    session.itemLink = payload.itemLink or session.itemLink
    session.timeoutAt = payload.timeoutAt or session.timeoutAt
    session.revision = math.max(session.revision or 0, payload.revision or 0)
    -- INIT_SESSION means the leader is collecting rolls: if a transition
    -- snapshot created this session in a pre-collecting state, advance it
    -- so the roll buttons appear.
    if stateRank(session.state) < stateRank(constants.SESSION.STATE.COLLECTING) then
      session.state = constants.SESSION.STATE.COLLECTING
    end
  end

  self.latestSessionId = sessionId
  self:TrackSessionOrder(sessionId)
  markSync(session, envelope.sender, "init_session", session.revision)
  self:EmitUiEvent("DR_SESSION_OPENED", self:GetSessionSnapshot(sessionId))
end

function SessionManager:OnResolveResultReceived(envelope)
  if not isFromGroupLeader(envelope) then
    rejectNonAuthority(envelope, constants.COMMS.MESSAGE.RESOLVE_RESULT)
    return
  end

  local payload = envelope.payload or {}
  local sessionId = payload.sessionId
  if not sessionId then
    return
  end

  local session = self.sessions[sessionId]
  local incomingRevision = toNumber(payload.revision, 0)
  if session and incomingRevision < getRevision(session) then
    return
  end

  if not session then
    local ok = self:RequestSnapshot(sessionId)
    if not ok then
      return
    end
    session = {
      id = sessionId,
      itemNonce = payload.itemNonce,
      submissionsByGuid = {},
      rollOff = { round = 0, tiedGuids = {} },
    }
    self.sessions[sessionId] = session
    self:TrackSessionOrder(sessionId)
  end

  session.winner = payload.winner
  session.revision = incomingRevision
  session.state = constants.SESSION.STATE.AWARDED
  markSync(session, envelope.sender, "resolve_result", session.revision)
  self.latestSessionId = sessionId

  self:EmitUiEvent("DR_SESSION_RESOLVED", payload)
end

function SessionManager:OnRollOffStartReceived(envelope)
  if not isFromGroupLeader(envelope) then
    rejectNonAuthority(envelope, constants.COMMS.MESSAGE.ROLLOFF_START)
    return
  end

  local payload = envelope.payload or {}
  local sessionId = payload.sessionId
  if not sessionId then
    return
  end

  local session = self.sessions[sessionId]
  local incomingRevision = toNumber(payload.revision, 0)
  if session and incomingRevision < getRevision(session) then
    return
  end

  if not session then
    local ok = self:RequestSnapshot(sessionId)
    if not ok then
      return
    end
    session = {
      id = sessionId,
      submissionsByGuid = {},
      rollOff = { round = 0, tiedGuids = {} },
    }
    self.sessions[sessionId] = session
    self:TrackSessionOrder(sessionId)
  end

  session.state = constants.SESSION.STATE.ROLLOFF
  session.rollOff = session.rollOff or { round = 0, tiedGuids = {} }
  session.rollOff.round = payload.round or session.rollOff.round
  session.rollOff.tiedGuids = payload.tiedGuids or session.rollOff.tiedGuids
  session.rollOff.category = payload.category or session.rollOff.category
  session.revision = incomingRevision
  markSync(session, envelope.sender, "rolloff_start", session.revision)
  self.latestSessionId = sessionId

  self:EmitUiEvent("DR_ROLLOFF_STARTED", payload)
end

function SessionManager:OnRollOffResultReceived(envelope)
  if not isFromGroupLeader(envelope) then
    rejectNonAuthority(envelope, constants.COMMS.MESSAGE.ROLLOFF_RESULT)
    return
  end

  local payload = envelope.payload or {}
  local sessionId = payload.sessionId
  if not sessionId then
    return
  end

  local session = self.sessions[sessionId]
  local incomingRevision = toNumber(payload.revision, 0)
  if session and incomingRevision < getRevision(session) then
    return
  end

  if not session then
    local ok = self:RequestSnapshot(sessionId)
    if not ok then
      return
    end
    session = {
      id = sessionId,
      submissionsByGuid = {},
      rollOff = { round = 0, tiedGuids = {} },
    }
    self.sessions[sessionId] = session
    self:TrackSessionOrder(sessionId)
  end

  session.rollOff = session.rollOff or { round = 0, tiedGuids = {} }
  session.rollOff.round = payload.round or session.rollOff.round
  session.rollOff.tiedGuids = payload.tieGuids or session.rollOff.tiedGuids
  session.revision = incomingRevision
  markSync(session, envelope.sender, "rolloff_result", session.revision)

  if payload.winner and payload.tie == false then
    session.pendingWinner = payload.winner
  end

  self.latestSessionId = sessionId
  self:EmitUiEvent("DR_ROLLOFF_RESULT", payload)
end

function SessionManager:OnStateSnapshotReceived(envelope)
  if not isFromGroupLeader(envelope) then
    rejectNonAuthority(envelope, constants.COMMS.MESSAGE.STATE_SNAPSHOT)
    return
  end

  local payload = envelope.payload or {}
  if payload.snapshot then
    local ok, session = self:ReconcileSnapshot(payload.snapshot, {
      source = envelope.sender,
      reason = payload.reason,
    })
    if not ok then
      return
    end
    self:EmitUiEvent("DR_STATE_SNAPSHOT", {
      itemKey = session.id,
      toState = session.state,
      snapshot = payload.snapshot,
    })
    return
  end

  local session = self.sessions[payload.itemKey]
  if session then
    if payload.toState and stateRank(payload.toState) >= stateRank(session.state) then
      session.state = payload.toState
    end
    markSync(session, envelope.sender, payload.reason or "state_snapshot_delta", payload.revision)
    self:EmitUiEvent("DR_STATE_SNAPSHOT", payload)
    return
  end

  if payload.itemKey then
    self:RequestSnapshot(payload.itemKey)
  end

  self:EmitUiEvent("DR_STATE_SNAPSHOT", payload)
end

function SessionManager:OnRequestSnapshotReceived(envelope)
  if not self:IsRaidLeaderAuthority() then
    return
  end

  local payload = envelope.payload or {}
  local sessionId = payload.sessionId
  if not sessionId then
    return
  end

  local snapshot = self:GetSessionSnapshot(sessionId)
  if not snapshot then
    return
  end

  DR:Require("Services.Comms"):SendMessage(constants.COMMS.MESSAGE.STATE_SNAPSHOT, {
    itemKey = sessionId,
    fromState = snapshot.state,
    toState = snapshot.state,
    reason = "snapshot_request",
    snapshot = snapshot,
  }, {
    sessionId = sessionId,
    revision = snapshot.revision,
    reliable = true,
  })

  self:EmitUiEvent("DR_STATE_SNAPSHOT", payload)
end

DR:RegisterModule("Services.SessionManager", SessionManager)
