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

local Ledger = {}

-- The reset token is computed as now + GetSecondsUntilWeeklyReset(), which can
-- jitter by a couple of seconds between calls. Treat tokens within this window
-- as the same weekly reset so regain never double-applies.
local WEEKLY_TOKEN_TOLERANCE = 600

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

local WEEK_SECONDS = 7 * 24 * 60 * 60
-- Tuesday 18:00 UTC = Tuesday 10:00 AM PST (first occurrence after epoch).
local WEEKLY_ANCHOR = 496800

local function getWeeklyResetToken()
  if C_DateAndTime and C_DateAndTime.GetSecondsUntilWeeklyReset then
    local now = getNow()
    local seconds = C_DateAndTime.GetSecondsUntilWeeklyReset() or 0
    return now + math.max(seconds, 0)
  end

  -- Fallback: weekly buckets anchored to Tuesday 10 AM PST.
  local now = getNow()
  return now - ((now - WEEKLY_ANCHOR) % WEEK_SECONDS)
end

function Ledger:OnInitialize()
  DR.db.global.weightLedger = DR.db.global.weightLedger or {}

  local comms = DR:Require("Services.Comms")
  comms:Subscribe(constants.COMMS.MESSAGE.ROSTER_SYNC, function(envelope)
    self:OnRosterSyncReceived(envelope)
  end)
  comms:Subscribe(constants.COMMS.MESSAGE.ROSTER_REQUEST, function(envelope)
    self:OnRosterRequestReceived(envelope)
  end)
end

--- Leader broadcasts the tracked roster (guid, name, roll power) to the raid
--- so every raider's local ledger matches the authoritative one.
function Ledger:SendRosterSync()
  if not (IsInGroup() and UnitIsGroupLeader("player")) then
    return false, "not_leader"
  end

  local rows = {}
  for _, row in ipairs(self:GetTrackedWeights()) do
    rows[#rows + 1] = { guid = row.guid, display = row.display, weight = row.weight, bench = row.bench }
  end
  if #rows == 0 then
    return false, "empty_roster"
  end

  local sent = DR:Require("Services.Comms"):SendMessage(constants.COMMS.MESSAGE.ROSTER_SYNC, {
    rows = rows,
  }, { reliable = true })

  if sent then
    self.lastRosterSyncAt = getNow()
    DR:Require("Services.Audit"):Append("ROSTER_SYNC_SENT", { count = #rows })
    DR:Print(string.format("Roster sent to the raid (%d player(s))", #rows))
  end
  return sent, #rows
end

function Ledger:RequestRosterSync()
  if not IsInGroup() then
    return false, "not_in_group"
  end
  if UnitIsGroupLeader("player") then
    return false, "is_leader"
  end
  return DR:Require("Services.Comms"):SendMessage(constants.COMMS.MESSAGE.ROSTER_REQUEST, {}, { reliable = false })
end

function Ledger:OnRosterSyncReceived(envelope)
  -- Roster data is authoritative, so only the verified group leader may send it.
  if not DR:Require("Services.SessionManager"):IsEnvelopeFromLeader(envelope) then
    DR:Require("Services.Audit"):Append("AUTHORITY_REJECTED", {
      messageType = constants.COMMS.MESSAGE.ROSTER_SYNC,
      sender = envelope and (envelope.verifiedSenderName or tostring(envelope.sender)) or "unknown",
    })
    return
  end

  local rows = envelope.payload and envelope.payload.rows
  if type(rows) ~= "table" then
    return
  end

  local count = 0
  for _, row in ipairs(rows) do
    if type(row) == "table" and row.guid then
      self:Track(row.guid, row.display)
      self:SetWeight(row.guid, row.weight, "leader_roster_sync")
      self:SetBench(row.guid, row.bench == true)
      count = count + 1
    end
  end

  if count > 0 then
    DR:Require("Services.Audit"):Append("ROSTER_SYNC_APPLIED", { count = count })
    DR:Print(string.format("Roster synced from the raid leader (%d player(s))", count))
    DR:SendMessage("DR_ROSTER_SYNCED", { count = count })
  end
end

function Ledger:OnRosterRequestReceived(envelope)
  if not (IsInGroup() and UnitIsGroupLeader("player")) then
    return
  end
  -- Several raiders may request at once; one broadcast serves them all.
  if self.lastRosterSyncAt and (getNow() - self.lastRosterSyncAt) < 5 then
    return
  end
  self:SendRosterSync()
end

function Ledger:Ensure(guid)
  if not guid then
    return nil
  end

  local entry = DR.db.global.weightLedger[guid]
  if not entry then
    entry = {
      weight = tonumber(DR.db.profile.startingWeight) or 100,
      -- Stamp the current week so a fresh entry doesn't immediately get a
      -- weekly regain on its first read.
      lastWeeklyResetToken = getWeeklyResetToken(),
      updatedAt = getNow(),
    }
    DR.db.global.weightLedger[guid] = entry
  end

  return entry
end

function Ledger:ApplyWeeklyRegainIfNeeded(guid)
  local entry = self:Ensure(guid)
  if not entry then
    return nil
  end

  local token = getWeeklyResetToken()
  local previous = tonumber(entry.lastWeeklyResetToken)
  if previous and math.abs(token - previous) <= WEEKLY_TOKEN_TOLERANCE then
    return entry.weight
  end

  local rollEngine = DR:Require("Domain.RollEngine")
  entry.weight = rollEngine:ApplyWeeklyRegain(entry.weight)
  entry.lastWeeklyResetToken = token
  entry.updatedAt = getNow()

  local audit = DR:Require("Services.Audit")
  audit:Append("WEEKLY_REGAIN", {
    guid = guid,
    token = token,
    weight = entry.weight,
  })

  return entry.weight
end

function Ledger:GetWeight(guid)
  local entry = self:Ensure(guid)
  if not entry then
    return 100
  end

  self:ApplyWeeklyRegainIfNeeded(guid)
  return entry.weight
end

function Ledger:ApplyWinnerPenalty(guid)
  local entry = self:Ensure(guid)
  if not entry then
    return nil
  end

  local rollEngine = DR:Require("Domain.RollEngine")
  entry.weight = rollEngine:ApplyWinnerPenalty(entry.weight)
  entry.updatedAt = getNow()

  local audit = DR:Require("Services.Audit")
  audit:Append("WINNER_PENALTY", {
    guid = guid,
    weight = entry.weight,
  })

  return entry.weight
end

--- Overwrites a player's weight with the authoritative value broadcast by the
--- raid leader, so local roll-power displays stay in sync across the raid.
function Ledger:SetWeight(guid, weight, reason)
  local entry = self:Ensure(guid)
  if not entry then
    return nil
  end

  local numeric = tonumber(weight)
  if not numeric then
    return entry.weight
  end

  entry.weight = DR:Require("Domain.RollEngine"):ClampWeight(numeric)
  -- The synced value is authoritative as of now; make sure a pending weekly
  -- regain doesn't immediately mutate it.
  entry.lastWeeklyResetToken = getWeeklyResetToken()
  entry.updatedAt = getNow()

  DR:Require("Services.Audit"):Append("WEIGHT_SYNCED", {
    guid = guid,
    weight = entry.weight,
    reason = reason,
  })

  return entry.weight
end

function Ledger:ApplyBossRegain(guid, encounterID, amount, role)
  local entry = self:Ensure(guid)
  if not entry then
    return nil
  end

  local rollEngine = DR:Require("Domain.RollEngine")
  entry.weight = rollEngine:ApplyBossRegain(entry.weight, amount)
  entry.updatedAt = getNow()

  local audit = DR:Require("Services.Audit")
  audit:Append("BOSS_REGAIN", {
    guid = guid,
    encounterID = encounterID,
    amount = amount,
    role = role or "raider",
    weight = entry.weight,
  })

  return entry.weight
end

--- Tracking marks players the raid leader deliberately cares about; only
--- tracked entries show in the roster and get exported. Entries created as a
--- side effect (rolls from pug members, etc.) stay untracked.
function Ledger:Track(guid, display)
  local entry = self:Ensure(guid)
  if not entry then
    return nil
  end
  entry.tracked = true
  if display then
    DR:Require("Services.Identity"):Bind(guid, display)
  end
  return entry
end

function Ledger:Untrack(guid)
  local entry = guid and DR.db.global.weightLedger[guid]
  if entry then
    entry.tracked = false
    DR:Require("Services.Audit"):Append("ROSTER_UNTRACKED", { guid = guid })
  end
end

function Ledger:IsTracked(guid)
  local entry = guid and DR.db.global.weightLedger[guid]
  return entry ~= nil and entry.tracked == true
end

--- Bench: leader-flagged standby players earn the (higher) bench boss credit
--- while sitting out. Benching implies tracking.
function Ledger:SetBench(guid, benched)
  local entry = self:Track(guid)
  if not entry then
    return nil
  end
  entry.bench = benched and true or false
  DR:Require("Services.Audit"):Append("ROSTER_BENCH", {
    guid = guid,
    bench = entry.bench,
  })
  return entry.bench
end

function Ledger:IsBenched(guid)
  local entry = guid and DR.db.global.weightLedger[guid]
  return entry ~= nil and entry.bench == true
end

--- Adds every current group member (or just yourself when solo) to the
--- tracked roster. Returns how many players were added or refreshed.
function Ledger:TrackGroupMembers()
  local identity = DR:Require("Services.Identity")
  local count = 0

  local function trackUnit(unit)
    local guid, display = identity:NormalizeUnit(unit)
    if guid then
      self:Track(guid, display)
      count = count + 1
    end
  end

  if IsInRaid() then
    for i = 1, (GetNumGroupMembers() or 0) do
      local unit = "raid" .. i
      if UnitExists(unit) then
        trackUnit(unit)
      end
    end
  elseif IsInGroup() then
    trackUnit("player")
    for i = 1, 4 do
      local unit = "party" .. i
      if UnitExists(unit) then
        trackUnit(unit)
      end
    end
  else
    trackUnit("player")
  end

  DR:Require("Services.Audit"):Append("ROSTER_GROUP_ADDED", { count = count })
  return count
end

function Ledger:GetTrackedWeights()
  local out = {}
  for _, row in ipairs(self:GetKnownWeights()) do
    if self:IsTracked(row.guid) then
      out[#out + 1] = row
    end
  end
  return out
end

--- Plain-text roster export: one "guid;name;weight;bench" line per tracked
--- player. Safe to store outside the game and re-import after data loss.
function Ledger:ExportRoster()
  local rows = self:GetTrackedWeights()
  local lines = { "DRROSTER;v2;" .. #rows }
  for _, row in ipairs(rows) do
    lines[#lines + 1] = string.format(
      "%s;%s;%s;%d",
      row.guid,
      tostring(row.display),
      tostring(row.weight or 100),
      row.bench and 1 or 0
    )
  end
  return table.concat(lines, "\n")
end

function Ledger:ImportRoster(text)
  if type(text) ~= "string" or text == "" then
    return false, "empty"
  end

  local count = 0
  for line in text:gmatch("[^\r\n]+") do
    -- v2 lines carry a trailing bench flag; v1 lines (three fields) still import.
    local guid, display, weight, bench = line:match("^%s*([^;]+);([^;]+);([%d%.]+);([01])%s*$")
    if not guid then
      guid, display, weight = line:match("^%s*([^;]+);([^;]+);([%d%.]+)%s*$")
    end
    if guid and guid ~= "DRROSTER" and tonumber(weight) then
      self:Track(guid, display)
      self:SetWeight(guid, tonumber(weight), "roster_import")
      self:SetBench(guid, bench == "1")
      count = count + 1
    end
  end

  if count == 0 then
    return false, "no_valid_lines"
  end

  DR:Require("Services.Audit"):Append("ROSTER_IMPORTED", { count = count })
  return true, count
end

function Ledger:GetKnownWeights()
  local identity = DR:Require("Services.Identity")
  local out = {}
  for guid, entry in pairs(DR.db.global.weightLedger) do
    out[#out + 1] = {
      guid = guid,
      display = identity:GetDisplayByGuid(guid) or guid,
      weight = entry.weight,
      bench = entry.bench == true,
      updatedAt = entry.updatedAt,
    }
  end
  table.sort(out, function(a, b)
    return tostring(a.display) < tostring(b.display)
  end)
  return out
end

function Ledger:RemoveEntry(guid)
  if guid then
    DR.db.global.weightLedger[guid] = nil
  end
end

--- Boss credit: everyone in the raid gets the raider rate; tracked players
--- flagged Bench who are NOT in the raid get the (higher) bench rate.
--- Tracked players who are absent and not benched get nothing.
function Ledger:ApplyBossRegainForRaid(encounterID)
  local identity = DR:Require("Services.Identity")
  local raidAmount = tonumber(DR.db.profile.bossRegain) or 5
  local benchAmount = tonumber(DR.db.profile.benchBossRegain) or 12.5
  local inRaid = {}

  local function creditUnit(unit)
    local guid = select(1, identity:NormalizeUnit(unit))
    if guid and not inRaid[guid] then
      inRaid[guid] = true
      self:ApplyBossRegain(guid, encounterID, raidAmount, "raider")
    end
  end

  if IsInRaid and IsInRaid() then
    for i = 1, (GetNumGroupMembers() or 0) do
      local unit = "raid" .. i
      if UnitExists(unit) then
        creditUnit(unit)
      end
    end
  elseif IsInGroup and IsInGroup() then
    creditUnit("player")
    for i = 1, 4 do
      local unit = "party" .. i
      if UnitExists(unit) then
        creditUnit(unit)
      end
    end
  else
    creditUnit("player")
  end

  local benched = 0
  for guid, entry in pairs(DR.db.global.weightLedger) do
    if entry.tracked and entry.bench and not inRaid[guid] then
      self:ApplyBossRegain(guid, encounterID, benchAmount, "bench")
      benched = benched + 1
    end
  end

  if benched > 0 then
    local fmt = DR:Require("Domain.RollEngine")
    DR:Print(string.format("Boss credit: raid +%s, %d benched player(s) +%s",
      fmt:FormatWeight(raidAmount), benched, fmt:FormatWeight(benchAmount)))
  end
end

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