local ADDON_NAME, ns = ...
local WF = ns.WF
local Util = WF.Util

local Tracker = WF.Tracker
Tracker.current = Tracker.current or nil
Tracker._dirty = false
Tracker._sortedCache = nil

local MAX_SOURCES_PER_ITEM = 12

local function _WF_AddSourceCount(entry, src, qty)
  if type(entry) ~= "table" or not src then return end
  src = tostring(src or "")
  if src == "" then return end
  qty = tonumber(qty) or 1
  if qty < 1 then qty = 1 end
  entry.sources = entry.sources or {}
  if entry.sources[src] ~= nil then
    entry.sources[src] = (tonumber(entry.sources[src]) or 0) + qty
    entry.lastSource = src
    return
  end
  local n = 0
  for _ in pairs(entry.sources) do n = n + 1 end
  if n >= MAX_SOURCES_PER_ITEM then
    entry.sources.Other = (tonumber(entry.sources.Other) or 0) + qty
    entry.lastSource = "Other"
    return
  end
  entry.sources[src] = qty
  entry.lastSource = src
end

-- NOTE:
-- Multi-loot patterns MUST be tested before single-loot patterns, otherwise the "%s" in LOOT_ITEM_SELF
-- will happily eat " x4" and you'll always get qty=1.
local LOOT_PATTERNS = {
  Util:PatternFromFormat(LOOT_ITEM_SELF_MULTIPLE),
  Util:PatternFromFormat(LOOT_ITEM_PUSHED_SELF_MULTIPLE),
  Util:PatternFromFormat(LOOT_ITEM_SELF),
  Util:PatternFromFormat(LOOT_ITEM_PUSHED_SELF),
}

local function ExtractItemLink(s)
  if type(s) ~= "string" then
    return nil
  end

  -- Prefer the fully-colored hyperlink (|cAARRGGBB...|r) when present.
  -- This preserves instance-specific rarity/quality for upgraded/bonus-ID items
  -- (e.g. Remix gear) even when GetItemInfo(itemID) reports the base template quality.
  local colored = s:match("(|c%x%x%x%x%x%x%x%x|Hitem:.-|h%[.-%]|h|r)")
  if colored then
    return colored
  end

  -- Fallback: uncolored item hyperlink.
  local link = s:match("(|Hitem:.-|h%[.-%]|h)")
  if link then
    return link
  end

  if s:find("|Hitem:") then
    return s
  end
  return nil
end

local function ParseLootMessage(msg)
  if type(msg) ~= "string" then
    return nil, 0
  end

  for _, pat in ipairs(LOOT_PATTERNS) do
    if pat then
      local a, b = msg:match(pat)
      if a then
        local link = ExtractItemLink(a) or ExtractItemLink(b)
        local qty = 1

        -- If we matched the MULTIPLE pattern, one of the captures should be digits.
        qty = tonumber(a) or tonumber(b) or 1

        -- Safety: if we matched the SINGLE pattern but the %s capture contained a trailing "xN",
        -- peel it off (this can happen in some locales/client strings).
        if qty == 1 and type(a) == "string" then
          local tail = a:match("[xX]%s*(%d+)%s*$") or a:match("%s[xX](%d+)%s*$")
          if tail then
            qty = tonumber(tail) or 1
          end
        end

        if link then
          qty = tonumber(qty) or 1
          if qty < 1 then qty = 1 end
          return link, qty
        end
      end
    end
  end

  return nil, 0
end

local function ParseMoneyMessage(msg)
  if type(msg) ~= "string" then
    return 0
  end
  local g = Util:AmountFromFormat(msg, GOLD_AMOUNT)
  local s = Util:AmountFromFormat(msg, SILVER_AMOUNT)
  local c = Util:AmountFromFormat(msg, COPPER_AMOUNT)
  return (g * 10000) + (s * 100) + c
end

local function Now()
  return GetTime()
end

local function MaybeResumePausedRunFromLoot()
  if not (WF and WF.DB and WF.DB.GetSetting) then
    return
  end
  if not WF.DB:GetSetting("autoResumeOnLootWhilePaused") then
    return
  end

  local Main = WF.UI and WF.UI.Main
  if not Main then
    return
  end

  if Main.IsPaused and Main:IsPaused() and Main.Resume then
    Main:Resume()
  end
end

-- -------------------------------------------------------
-- /reload-safe active session persistence
--
-- WoW writes SavedVariables on PLAYER_LOGOUT (including /reload). We keep the
-- active session in db.runtime.activeSession so we can resume seamlessly.
--
-- NOTE: GetTime() resets after /reload, so we persist an "elapsed" snapshot and
-- reconstruct sess.startTime on resume.
-- -------------------------------------------------------
local function GetRuntimeDB()
  if not (WF and WF.DB and WF.DB.Get) then return nil end
  local db = WF.DB:Get()
  if type(db) ~= "table" then return nil end
  db.runtime = db.runtime or {}
  return db.runtime
end

function Tracker:_PersistActiveSession()
  local rt = GetRuntimeDB()
  if not rt then return end
  local sess = self.current
  if not (sess and sess.active) then
    rt.activeSession = nil
    return
  end

  -- Persist pause-adjusted elapsed snapshot (GetTime() restarts after /reload).
  -- Previous pause totals must survive reload, and a currently-paused run must
  -- remain frozen instead of silently counting AFK time while the UI is down.
  local pausedTotal = tonumber(sess._wfPausedTotal) or 0
  if pausedTotal < 0 then pausedTotal = 0 end

  local elapsed = Now() - (sess.startTime or Now()) - pausedTotal
  if elapsed < 0 then elapsed = 0 end

  if sess._wfPaused then
    local frozen = tonumber(sess._wfPausedElapsed)
    if frozen and frozen >= 0 then
      elapsed = frozen
    end
  end

  sess._wfPersistElapsed = elapsed
  sess._wfPersistSavedAt = time()
  sess._wfPersistV = 1

  -- Store the session table itself (pure data) so it stays up-to-date in memory.
  rt.activeSession = sess
end

function Tracker:_ClearPersistedActiveSession()
  local rt = GetRuntimeDB()
  if not rt then return end
  rt.activeSession = nil
end

function Tracker:ResumeFromReload()
  local rt = GetRuntimeDB()
  if not rt then return false end

  local sess = rt.activeSession
  if type(sess) ~= "table" or not sess.active then
    rt.activeSession = nil
    return false
  end

  local savedAt = tonumber(sess._wfPersistSavedAt)
  if not savedAt then
    rt.activeSession = nil
    return false
  end

  -- Only auto-resume if this was a recent /reload.
  -- Prevents surprise "resumes" hours later if the client crashed.
  local age = time() - savedAt
  if age < 0 then age = 0 end
  if age > 300 then
    rt.activeSession = nil
    return false
  end

  local elapsed = tonumber(sess._wfPersistElapsed) or 0
  if elapsed < 0 then elapsed = 0 end

  local pausedTotal = tonumber(sess._wfPausedTotal) or 0
  if pausedTotal < 0 then pausedTotal = 0 end

  -- Reconstruct startTime for GetTime() domain.
  -- Keep previously-accounted paused time in the raw runtime basis so future
  -- pause/resume math stays correct after /reload.
  sess.startTime = Now() - elapsed - pausedTotal

  -- Safety: ensure expected tables exist.
  sess.items = sess.items or {}
  sess.pending = sess.pending or {}

  self.current = sess
  self._sortedCache = nil
  self:MarkDirty()

  if DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then
    DEFAULT_CHAT_FRAME:AddMessage("|cff66c2ffWhatFarm:|r Resumed active run after /reload.")
  end
  return true
end

-- -------------------------------------------------------
-- Capture Filters
-- -------------------------------------------------------
local CATEGORY_KEYS = {
  "lootRecordMaterials",
  "lootRecordGear",
  "lootRecordRecipes",
  "lootRecordPets",
  "lootRecordToys",
  "lootRecordMounts",
  "lootRecordConsumables",
  "lootRecordMisc",
}

local NON_MATERIAL_CATEGORY_KEYS = {
  "lootRecordGear",
  "lootRecordRecipes",
  "lootRecordPets",
  "lootRecordToys",
  "lootRecordMounts",
  "lootRecordConsumables",
  "lootRecordMisc",
}

local function NormalizeCategorySelection(categories)
  categories = categories or {}
  for i = 1, #CATEGORY_KEYS do
    local k = CATEGORY_KEYS[i]
    categories[k] = categories[k] and true or false
  end
  return categories
end

local function HasCategorySelection(categories)
  if type(categories) ~= "table" then return false end
  for i = 1, #CATEGORY_KEYS do
    if categories[CATEGORY_KEYS[i]] then
      return true
    end
  end
  return false
end

local function DeriveLegacyCategorySelection(includeMaterials, onlyMaterials, onlyGear)
  local categories = NormalizeCategorySelection({})

  if onlyMaterials and onlyGear then
    categories.lootRecordMaterials = true
    categories.lootRecordGear = true
    return categories
  end

  if onlyMaterials then
    categories.lootRecordMaterials = true
    return categories
  end

  if onlyGear then
    categories.lootRecordGear = true
    return categories
  end

  if includeMaterials == false then
    for i = 1, #NON_MATERIAL_CATEGORY_KEYS do
      categories[NON_MATERIAL_CATEGORY_KEYS[i]] = true
    end
  end

  return categories
end

local function GetLootCaptureSettings()
  local minQty = 1
  local minQuality = 0
  local ignoreSoulbound = true
  local minGold = 0
  local categories = NormalizeCategorySelection({})

  if WF.DB and WF.DB.GetSetting then
    minQty = tonumber(WF.DB:GetSetting("lootMinQty")) or 1
    local mq = WF.DB:GetSetting("lootMinQuality")
    if type(mq) == "string" then
      local MAP = {
        ["Poor+"] = 0, ["Common+"] = 1, ["Uncommon+"] = 2, ["Rare+"] = 3, ["Epic+"] = 4, ["Legendary+"] = 5,
        ["Any"] = 0, ["Poor"] = 0, ["Common"] = 1, ["Uncommon"] = 2, ["Rare"] = 3, ["Epic"] = 4, ["Legendary"] = 5,
      }
      mq = MAP[mq] or tonumber(mq) or 0
    end
    minQuality = tonumber(mq) or 0

    local sb = WF.DB:GetSetting("lootIgnoreSoulbound")
    if sb ~= nil then
      ignoreSoulbound = sb and true or false
    end

    minGold = tonumber(WF.DB:GetSetting("lootMinGold")) or 0

    local hasCategoryData = false
    for i = 1, #CATEGORY_KEYS do
      local k = CATEGORY_KEYS[i]
      local v = WF.DB:GetSetting(k)
      if v ~= nil then
        categories[k] = v and true or false
        hasCategoryData = true
      end
    end

    if not hasCategoryData then
      local includeMaterials = true
      local mats = WF.DB:GetSetting("lootIncludeMaterials")
      if mats ~= nil then
        includeMaterials = mats and true or false
      end

      local onlyMaterials = WF.DB:GetSetting("lootOnlyMaterials") and true or false
      local onlyGear = WF.DB:GetSetting("lootOnlyGear") and true or false
      categories = DeriveLegacyCategorySelection(includeMaterials, onlyMaterials, onlyGear)
    end
  end

  if minQty < 1 then minQty = 1 end
  if minQuality < 0 then minQuality = 0 end
  if minGold < 0 then minGold = 0 end

  return minQty, minQuality, ignoreSoulbound, minGold, NormalizeCategorySelection(categories)
end

local function SafeCall(fn, ...)
  if type(fn) ~= "function" then return nil end
  local ok, a = pcall(fn, ...)
  if ok then return a end
  return nil
end

local function IsToyItem(itemID)
  if not itemID then return false end
  if C_ToyBox and C_ToyBox.GetToyInfo then
    local toyName = SafeCall(C_ToyBox.GetToyInfo, itemID)
    if toyName then return true end
  end
  if PlayerHasToy then
    return SafeCall(PlayerHasToy, itemID) and true or false
  end
  return false
end

local function IsMountItem(itemID)
  if not itemID then return false end
  if C_MountJournal and C_MountJournal.GetMountFromItem then
    local mountID = SafeCall(C_MountJournal.GetMountFromItem, itemID)
    if mountID and mountID ~= 0 then return true end
  end
  return false
end

local function ClassifyRecordCategory(rec)
  if not rec then return "lootRecordMisc" end
  if rec.isToy then return "lootRecordToys" end
  if rec.isMount then return "lootRecordMounts" end
  if rec.isPet then return "lootRecordPets" end
  if rec.isRecipe then return "lootRecordRecipes" end
  if rec.isReagent then return "lootRecordMaterials" end
  if rec.isGear then return "lootRecordGear" end
  if rec.isConsumable then return "lootRecordConsumables" end
  return "lootRecordMisc"
end

local function MatchesCategorySelection(rec, categories)
  if not HasCategorySelection(categories) then
    return true
  end
  local key = rec and rec.recordCategory or "lootRecordMisc"
  return categories[key] and true or false
end

local function CacheItemMeta(rec)
  if rec._metaReady then return end

  -- Prefer instance link (may include bonus IDs / upgrade level / Remix variant) so quality is accurate.
  local infoKey = rec.link or rec.itemID
  local name, link, quality, ilvl, reqLevel, class, subclass, maxStack, equipSlot, icon,
        sellPrice, classID, subclassID, bindType, expacID, setID, isCraftingReagent = GetItemInfo(infoKey)

  if not name then
    -- Request data for both the base template and the specific instance link if needed.
    if C_Item and C_Item.RequestLoadItemDataByID then
      if rec.itemID then
        C_Item.RequestLoadItemDataByID(rec.itemID)
      end
      if type(rec.link) == "string" and C_Item.RequestLoadItemDataByID then
        local iid = GetItemInfoInstant(rec.link)
        if iid then
          C_Item.RequestLoadItemDataByID(iid)
        end
      end
    end
    return
  end

  -- Some clients/builds don't return classID/subclassID/etc from GetItemInfo reliably.
  -- Use GetItemInfoInstant as a fast fallback so material/gear classification stays correct.
  if (not classID) or (not subclassID) or (not equipSlot) or (equipSlot == "") then
    local _, _, _, iequip, iicon, iclassID, isubclassID = GetItemInfoInstant(rec.itemID)
    if not icon and iicon then icon = iicon end
    if (not equipSlot or equipSlot == "") and iequip then equipSlot = iequip end
    if not classID and iclassID then classID = iclassID end
    if not subclassID and isubclassID then subclassID = isubclassID end
  end

  rec.name       = name
  rec.link       = rec.link or link
  rec.icon       = rec.icon or icon
  rec.quality    = quality or 0
  rec.classID    = classID
  rec.subclassID = subclassID
  rec.equipSlot  = equipSlot or ""
  rec.isGear     = (rec.equipSlot ~= "" and rec.equipSlot ~= "INVTYPE_BAG" and (rec.classID == 2 or rec.classID == 4))
  rec.bindType   = bindType or 0          -- 1 = BoP
  rec.vendor     = sellPrice or 0
  rec.isReagent  = (isCraftingReagent == true) -- modern reagent flag

  -- Classic-ish reagent class fallback
  if rec.isReagent ~= true and (rec.classID == 3 or rec.classID == 5 or rec.classID == 7 or rec.classID == 8) then
    rec.isReagent = true
  end

  rec.isRecipe = (rec.classID == 9)
  rec.isPet = (rec.classID == 17)
  rec.isConsumable = (rec.classID == 0)
  rec.isToy = IsToyItem(rec.itemID)
  rec.isMount = IsMountItem(rec.itemID)
  rec.recordCategory = ClassifyRecordCategory(rec)

  rec._metaReady = true
end

local function PassesStaticFilters(rec, minQuality, ignoreSoulbound, categories)
  if not rec._metaReady then
    return nil -- unknown yet
  end
  if ignoreSoulbound and (rec.bindType == 1) then
    return false
  end
  if (tonumber(rec.quality) or 0) < (tonumber(minQuality) or 0) then
    return false
  end
  if not MatchesCategorySelection(rec, categories) then
    return false
  end
  return true
end



local function _SessIgnoreTable(sess, key)
  if type(sess) ~= "table" then return nil end
  sess._wfIgnore = sess._wfIgnore or {}
  local t = sess._wfIgnore[key]
  if type(t) ~= "table" then
    t = {}
    sess._wfIgnore[key] = t
  end
  return t
end

local function _IsExcluded(sess, itemID)
  local ig = type(sess) == "table" and sess._wfIgnore
  local ex = ig and ig.excludeValue
  return type(ex) == "table" and ex[itemID] or false
end

local function _SetExcluded(sess, itemID, v)
  local t = _SessIgnoreTable(sess, "excludeValue")
  if not t then return end
  if v then t[itemID] = true else t[itemID] = nil end
end

local function ValueGateBlocks(rec, minGold)
  minGold = tonumber(minGold) or 0
  if minGold <= 0 then
    return false
  end

  -- Apply the minimum value gate to EVERYTHING that is eligible for recording.
  -- (Previously, materials/reagents were exempt in mixed capture mode, which made
  -- the "Min total value to record" setting feel like it wasn't working.)
  return (tonumber(rec and rec.value) or 0) < minGold
end

-- -------------------------------------------------------
-- Per-instance-reset breakdowns
-- Stored as child rows on the saved History session, not as separate sessions.
-- -------------------------------------------------------
local function ResetBreakdownEnabled()
  -- Reset stats are lightweight and are always recorded for active sessions.
  -- Display remains controlled by the History/Top Loot setting.
  return true
end

local function ResetNumber(v)
  v = tonumber(v) or 0
  if v < 0 then v = 0 end
  return v
end

local function CopyResetItemDelta(itemID, entry, countDelta, valueDelta)
  if type(entry) ~= "table" then return nil end
  countDelta = ResetNumber(countDelta)
  valueDelta = ResetNumber(valueDelta)
  if countDelta <= 0 and valueDelta <= 0 then return nil end

  local out = {
    itemID = tonumber(entry.itemID or itemID) or itemID,
    link = entry.link,
    name = entry.name,
    icon = entry.icon,
    quality = entry.quality,
    count = countDelta,
    value = valueDelta,
    unitValue = entry.unitValue,
    lastSource = entry.lastSource,
  }

  if countDelta > 0 and valueDelta > 0 then
    out.unitValue = valueDelta / countDelta
  end

  return out
end

local function BuildResetBaseline(sess, elapsed)
  local base = {
    elapsed = ResetNumber(elapsed),
    serverTime = time(),
    kills = ResetNumber(sess and sess.kills),
    moneyGained = ResetNumber(sess and sess.moneyGained),
    totalValue = ResetNumber(sess and sess.totalValue),
    items = {},
  }

  local items = sess and sess.items
  if type(items) == "table" then
    for itemID, entry in pairs(items) do
      if type(entry) == "table" then
        base.items[itemID] = {
          count = ResetNumber(entry.count),
          value = ResetNumber(entry.value),
        }
      end
    end
  end

  return base
end

local function CountResetItems(items)
  local types, total = 0, 0
  if type(items) ~= "table" then return 0, 0 end
  for _, entry in pairs(items) do
    if type(entry) == "table" then
      types = types + 1
      total = total + math.max(0, tonumber(entry.count) or 0)
    end
  end
  return types, total
end

function Tracker:_EnsureResetBreakdownBaseline(elapsedOverride)
  local sess = self.current
  if type(sess) ~= "table" then return nil end

  local elapsed = tonumber(elapsedOverride)
  if elapsed == nil then
    elapsed = (self.GetElapsed and self:GetElapsed()) or 0
  end

  if type(sess._wfResetBreakdownBase) ~= "table" then
    -- Important: the first reset segment must compare against the START of the
    -- session, not against the state at the moment the Reset button is first
    -- pressed. Otherwise Reset 1 looks empty and its loot falls into the
    -- session total only. Use an empty baseline when no reset has been saved yet.
    local hasPriorReset = (type(sess.resets) == "table" and #sess.resets > 0)
    if hasPriorReset then
      sess._wfResetBreakdownBase = BuildResetBaseline(sess, 0)
    else
      sess._wfResetBreakdownBase = {
        elapsed = 0,
        moneyGained = 0,
        totalValue = 0,
        kills = 0,
        items = {},
      }
    end
    sess._wfResetBreakdownBase.serverTime = tonumber(sess.startServerTime) or time()
  end

  return sess._wfResetBreakdownBase, ResetNumber(elapsed)
end

function Tracker:RecordInstanceResetSegment(reason, elapsedOverride, serverTimeOverride)
  if not ResetBreakdownEnabled() then
    return false
  end

  local sess = self.current
  if type(sess) ~= "table" then
    return false
  end

  if self.RepriceSession then
    self:RepriceSession(sess, true)
  end

  local base, elapsedNow = self:_EnsureResetBreakdownBaseline(elapsedOverride)
  if type(base) ~= "table" then
    return false
  end

  local elapsedDelta = math.floor((elapsedNow - ResetNumber(base.elapsed)) + 0.0001)
  if elapsedDelta < 0 then elapsedDelta = 0 end

  local moneyDelta = ResetNumber(sess.moneyGained) - ResetNumber(base.moneyGained)
  if moneyDelta < 0 then moneyDelta = 0 end

  local totalDelta = ResetNumber(sess.totalValue) - ResetNumber(base.totalValue)
  if totalDelta < 0 then totalDelta = 0 end

  local killsDelta = ResetNumber(sess.kills) - ResetNumber(base.kills)
  if killsDelta < 0 then killsDelta = 0 end

  local resetItems = {}
  local itemValue = 0
  local items = sess.items
  if type(items) == "table" then
    for itemID, entry in pairs(items) do
      if type(entry) == "table" then
        local b = (type(base.items) == "table" and base.items[itemID]) or nil
        local countDelta = ResetNumber(entry.count) - ResetNumber(b and b.count)
        local valueDelta = ResetNumber(entry.value) - ResetNumber(b and b.value)
        if countDelta < 0 then countDelta = 0 end
        if valueDelta < 0 then valueDelta = 0 end

        local copy = CopyResetItemDelta(itemID, entry, countDelta, valueDelta)
        if copy then
          resetItems[itemID] = copy
          itemValue = itemValue + ResetNumber(copy.value)
        end
      end
    end
  end

  local itemTypes, itemCount = CountResetItems(resetItems)

  -- If there is no item delta but total changed, preserve the session-level value delta.
  -- This catches raw copper and any future non-item value sources.
  if itemValue <= 0 and totalDelta > moneyDelta then
    itemValue = totalDelta - moneyDelta
  end
  if itemValue < 0 then itemValue = 0 end

  local hasData = elapsedDelta > 0 or totalDelta > 0 or moneyDelta > 0 or killsDelta > 0 or itemTypes > 0
  if hasData then
    sess.resets = sess.resets or {}
    local resetNumber = #sess.resets + 1
    local endedAt = tonumber(serverTimeOverride) or time()
    local startedAt = tonumber(base.serverTime) or (endedAt - elapsedDelta)
    if startedAt > endedAt then startedAt = endedAt end

    sess.resets[resetNumber] = {
      resetNumber = resetNumber,
      reason = tostring(reason or "instance_reset"),
      startedAt = startedAt,
      endedAt = endedAt,
      elapsed = elapsedDelta,
      kills = killsDelta,
      moneyGained = moneyDelta,
      itemValue = itemValue,
      totalValue = totalDelta,
      itemTypes = itemTypes,
      itemCount = itemCount,
      items = resetItems,
    }
  end

  sess._wfResetBreakdownBase = BuildResetBaseline(sess, elapsedNow)
  sess._wfResetBreakdownBase.serverTime = tonumber(serverTimeOverride) or time()

  self:MarkDirty()
  self:_PersistActiveSession()
  return hasData and true or false
end

local function CopyResetBreakdownForHistory(sess)
  local src = sess and sess.resets
  if type(src) ~= "table" or #src == 0 then
    return nil
  end

  local out = {}
  for i = 1, #src do
    local r = src[i]
    if type(r) == "table" then
      local rc = {
        resetNumber = tonumber(r.resetNumber) or i,
        reason = r.reason,
        startedAt = r.startedAt,
        endedAt = r.endedAt,
        elapsed = r.elapsed,
        kills = r.kills,
        moneyGained = r.moneyGained,
        itemValue = r.itemValue,
        totalValue = r.totalValue,
        itemTypes = r.itemTypes,
        itemCount = r.itemCount,
        items = {},
      }

      if type(r.items) == "table" then
        for itemID, e in pairs(r.items) do
          if type(e) == "table" then
            rc.items[itemID] = {
              itemID = e.itemID,
              link = e.link,
              name = e.name,
              icon = e.icon,
              quality = e.quality,
              count = e.count,
              value = e.value,
              unitValue = e.unitValue,
              lastSource = e.lastSource,
            }
          end
        end
      end

      out[#out + 1] = rc
    end
  end

  return (#out > 0) and out or nil
end

function Tracker:MarkDirty()
  self._dirty = true
end

function Tracker:ConsumeDirty()
  if self._dirty then
    self._dirty = false
    return true
  end
  return false
end

-- Promote pending items once we have enough info + they pass capture filters.
function Tracker:_ProcessPending(sess)
  if not sess or not sess.pending then
    return false
  end

  local minQty, minQuality, ignoreSoulbound, minGold, categories = GetLootCaptureSettings()
  local changed = false

  for itemID, rec in pairs(sess.pending) do
    if rec and rec.itemID then
      CacheItemMeta(rec)

      local ok = PassesStaticFilters(rec, minQuality, ignoreSoulbound, categories)
      if ok == false then
        -- Drop permanently (capture filter)
        sess.pending[itemID] = nil
        changed = true
      elseif ok == true then
        if (tonumber(rec.count) or 0) >= minQty then
          -- Evaluate pending value (always) so "0 then later priced" can promote correctly.

          local unit = 0

          if WF.Price and WF.Price.GetItemValue then

            unit = tonumber(WF.Price:GetItemValue(itemID, rec.link)) or 0

          end

          rec.unitValue = unit

          rec.value = (tonumber(unit) or 0) * (tonumber(rec.count) or 0)

          -- Unverified TSM pricing guard: record but exclude from totals by default.
          if (not rec._wfUnverified) and WF.Price and WF.Price.IsTSMUnverified then
            local unv, meta = WF.Price:IsTSMUnverified(itemID, unit)
            if unv then
              rec._wfUnverified = true
              rec._wfUnverifiedMeta = meta
              _SetExcluded(sess, itemID, true)
            end
          end


          -- Optional value gate: keep pending until the stack is worth enough.

          if ValueGateBlocks(rec, minGold) then

            -- stay pending

          else

            -- Promote into recorded items

            sess.pending[itemID] = nil

            sess.items[itemID] = rec


            if not _IsExcluded(sess, itemID) then
            sess.totalValue = (tonumber(sess.totalValue) or 0) + (tonumber(rec.value) or 0)
          end

            changed = true

          end
        end
      end
    end
  end

  if changed then
    self._sortedCache = nil
    self:MarkDirty()
  end

  return changed
end

function Tracker:RepriceSession(sess, force)
  if not sess or not sess.items then
    return
  end

  -- Process pending first so newly-resolved items appear without needing another loot event.
  self:_ProcessPending(sess)

  local now = Now()
  local provider = WF.Price and WF.Price.GetProvider and WF.Price:GetProvider() or "vendor"

  if not force then
    if self._lastRepriceAt and (now - self._lastRepriceAt) < 1.0 and self._lastPriceProvider == provider then
      return
    end
  end

  self._lastRepriceAt = now
  self._lastPriceProvider = provider

  local changed = false
  local total = 0
  local itemCount = 0
  local itemsValue = 0

  for itemID, e in pairs(sess.items) do
    local oldUnit = tonumber(e.unitValue) or 0
    local unit = 0

    if WF.Price and WF.Price.GetItemValue then
      unit = tonumber(WF.Price:GetItemValue(itemID, e.link)) or 0
    end

    if unit ~= oldUnit then
      changed = true
      e.unitValue = unit
    end

    local newValue = (tonumber(e.count) or 0) * (tonumber(e.unitValue) or 0)
    if (tonumber(e.value) or 0) ~= newValue then
      changed = true
      e.value = newValue
    end

    itemCount = itemCount + math.max(0, tonumber(e.count) or 0)

    if not _IsExcluded(sess, itemID) then
      local v = tonumber(e.value) or 0
      total = total + v
      itemsValue = itemsValue + v
    end
  end

  -- money is included separately (OnMoney adds directly to totalValue)
  total = total + (tonumber(sess.moneyGained) or 0)

  if (tonumber(sess.totalValue) or 0) ~= total then
    changed = true
    sess.totalValue = total
  end

  -- Cache derived scalars so RefreshMetrics never has to iterate sess.items.
  sess._cachedItemCount = itemCount
  sess._cachedItemsValue = itemsValue

  if changed then
    self._sortedCache = nil
    self:MarkDirty()
  end
end

function Tracker:RepriceCurrent(force)
  if not self.current then
    return
  end
  self:RepriceSession(self.current, force)
end

function Tracker:IsActive()
  return self.current and self.current.active
end

function Tracker:GetElapsed()
  if not self.current or not self.current.active then
    return 0
  end
  return Now() - (self.current.startTime or Now())
end

function Tracker:GetTotalValue()
  self:RepriceCurrent(false)
  if not self.current then
    return 0
  end
  return self.current.totalValue or 0
end

function Tracker:GetGPH()
  self:RepriceCurrent(false)
  local elapsed = self:GetElapsed()
  if elapsed <= 0 then
    return 0
  end
  return (self:GetTotalValue() / elapsed) * 3600
end

function Tracker:SetLastKill(mobName)
  if not self:IsActive() then
    return
  end
  local sess = self.current
  sess.lastKillName = mobName
  sess.lastKillAt = Now()
end

function Tracker:GetLikelySource()
  if not self.current then
    return nil
  end
  local sess = self.current
  local name = sess.lastKillName
  local t = sess.lastKillAt
  if not name or not t then
    return nil
  end
  if (Now() - t) > 6.0 then
    return nil
  end
  return name
end

function Tracker:Start(opts)
  if self:IsActive() then
    return
  end
  opts = opts or {}

  local mapId, x, y = Util:GetPlayerMapXY()
  local zone = Util:GetZoneLabel()

  -- Instance runs may not have map coords; keep a fallback to last known outdoor position.
  local worldMapId, worldX, worldY = nil, nil, nil
  local inInstance = false
  local instanceType = nil
  if Util and Util.GetInstanceContext then
    inInstance, instanceType = Util:GetInstanceContext()
  else
    inInstance, instanceType = IsInInstance()
  end

  if inInstance and (x == nil or y == nil) and Util and Util.GetLastWorldMapXY then
    worldMapId, worldX, worldY = Util:GetLastWorldMapXY()
  end


  local name = opts.name
  if not name or name == "" then
    name = zone
  end


  local mode = "Default"
  if WF.DB and WF.DB.GetActiveProfileName then
    mode = tostring(WF.DB:GetActiveProfileName() or "Default")
  end

  self.current = {
    active = true,
    farmId = opts.farmId,
    farmName = name,
    mode = mode,
    zone = zone,
    mapId = mapId,
    x = x,
    y = y,

    worldMapId = worldMapId,
    worldX = worldX,
    worldY = worldY,
    instanceType = instanceType,

    startTime = Now(),
    startServerTime = time(),

    kills = 0,
    moneyGained = 0,

    items = {},        -- recorded (passes capture filters)
    pending = {},      -- counts captured but not yet promoted (below minQty, meta not ready, etc.)
    totalValue = 0,

    lastKillName = nil,
    lastKillAt = nil,

    _wfPaused = false,
    _wfPausedTotal = 0,
    _wfPausedElapsed = nil,

    resets = {},
    _wfResetBreakdownBase = {
      elapsed = 0,
      moneyGained = 0,
      totalValue = 0,
      kills = 0,
      items = {},
      serverTime = time(),
    },
  }

  self._sortedCache = nil
  self:MarkDirty()

  -- /reload-safe persistence
  self:_PersistActiveSession()
end

function Tracker:Stop(saveToHistory)
  if not self.current then
    return
  end

  local sess = self.current

  -- Save the same elapsed basis used by the live Est. GPH display so
  -- history GPH stays aligned after pauses/resumes.
  local Main = WF and WF.UI and WF.UI.Main
  local stopElapsedOverride = tonumber(Main and Main._wfStopElapsedOverride)
  if stopElapsedOverride and stopElapsedOverride < 0 then
    stopElapsedOverride = nil
  end

  local effectiveElapsed = stopElapsedOverride or (tonumber(self:GetElapsed()) or 0)
  if effectiveElapsed <= 0 then
    effectiveElapsed = Now() - (sess.startTime or Now())

    -- Defensive fallback in case the pause UI exists but its GetElapsed hook
    -- has not been installed yet for some reason.
    local Main = WF and WF.UI and WF.UI.Main
    if Main and not self._wfOrigGetElapsed then
      effectiveElapsed = effectiveElapsed - (tonumber(Main._pausedTotal) or 0)
      if effectiveElapsed < 0 then
        effectiveElapsed = 0
      end
      if Main.IsPaused and Main:IsPaused() then
        effectiveElapsed = tonumber(Main._pausedElapsed) or effectiveElapsed
      end
    end
  end

  sess.endServerTime = time()
  sess.elapsed = math.floor((tonumber(effectiveElapsed) or 0) + 0.0001)
  if sess.elapsed < 0 then
    sess.elapsed = 0
  end
  sess.active = false

  if Main then
    Main._wfStopElapsedOverride = nil
  end

  -- Ensure pending promotions/filters are applied before saving.
  self:RepriceSession(sess, true)
  if saveToHistory then
    self:RecordInstanceResetSegment("session_end", sess.elapsed, sess.endServerTime)
  end
  if saveToHistory then
    local minSec = 0
    local minGPH = 0
    if WF and WF.DB and WF.DB.GetSetting then
      minSec = tonumber(WF.DB:GetSetting("historyMinSeconds")) or 0
      minGPH = tonumber(WF.DB:GetSetting("historyMinGPH")) or 0
    end

    local elapsed = tonumber(sess.elapsed) or 0
    local totalValue = tonumber(sess.totalValue) or 0
    local gph = 0
    if elapsed > 0 then
      gph = (totalValue / elapsed) * 3600
    end

    local skipReason = nil
    if minSec > 0 and elapsed > 0 and elapsed < minSec then
      skipReason = string.format("under %ds", minSec)
    elseif minGPH > 0 and elapsed > 0 and gph < minGPH then
      skipReason = string.format("under %s/hr", Util:FormatMoney(minGPH))
    end

    if skipReason then
      sess._wfSkipHistory = true
      if UIErrorsFrame and UIErrorsFrame.AddMessage then
        UIErrorsFrame:AddMessage(string.format("What Farm?: Run not saved (%s).", skipReason), 0.8, 0.8, 0.8, 1)
      end
    else
        local itemsCopy = {}
    
        if sess.items then
          for itemID, e in pairs(sess.items) do
            itemsCopy[itemID] = {
              link = e.link,
              name = e.name,
              icon = e.icon,
              count = e.count,
              value = e.value,
              unitValue = e.unitValue,
              lastSource = e.lastSource,
              sources = e.sources,
            }
          end
        end
    
        local record = {
          farmId = sess.farmId,
          farmName = sess.farmName,
          mode = tostring(sess.mode or "Default"),
          zone = sess.zone,
          mapId = sess.mapId,
          x = sess.x,
          y = sess.y,

          worldMapId = sess.worldMapId,
          worldX = sess.worldX,
          worldY = sess.worldY,
          instanceType = sess.instanceType,
    
          startedAt = sess.startServerTime,
          endedAt = sess.endServerTime,
          elapsed = sess.elapsed,
    
          kills = sess.kills or 0,
          moneyGained = sess.moneyGained or 0,
          totalValue = sess.totalValue or 0,
    
          items = itemsCopy,
          resets = CopyResetBreakdownForHistory(sess),
        }
    
        -- Ensure the record has a stable id so farm averages can be updated/moved safely.
        if WF and WF.DB and WF.DB.EnsureSessionId then
          WF.DB:EnsureSessionId(record)
        end

        -- If the run name matches an existing farm, link it (even if it was started
        -- with a different farm selected).
        if WF and WF.DB and WF.DB.FindFarmByName then
          local fid, farm = WF.DB:FindFarmByName(record.farmName, record.mapId)
          if fid and (not record.farmId or tonumber(record.farmId) ~= tonumber(fid)) then
            record.farmId = fid
            if farm and farm.name and farm.name ~= "" then
              record.farmName = farm.name
            end
          end
        end

        WF.DB:AddSession(record)

        -- Ingest so the farm library Avg GPH/LIV updates instantly (and persists).
        if record.farmId and WF and WF.DB and WF.DB.IngestSessionToFarmStats then
          WF.DB:IngestSessionToFarmStats(record, record.farmId)
        end
        end
  end


-- If the Help window is open, refresh pages so Farm Library / History
-- update without needing to close + reopen the window.
if WF and WF.UI and WF.UI.Help then
  local Help = WF.UI.Help
  if Help.RefreshOverview then pcall(Help.RefreshOverview, Help) end
  if Help.RefreshHistory then pcall(Help.RefreshHistory, Help) end
  if Help.MarkFarmAveragesDirty then pcall(Help.MarkFarmAveragesDirty, Help) end
end

  self:MarkDirty()

  -- Clear /reload persistence once a run is ended.
  self:_ClearPersistedActiveSession()
end

function Tracker:Reset()
  self.current = nil
  self._sortedCache = nil
  self:MarkDirty()

  -- Clear /reload persistence.
  self:_ClearPersistedActiveSession()
end

function Tracker:OnLoot(link, qty)
  -- Pause mode overrides Tracker:IsActive() at the UI layer, so we must
  -- attempt the auto-resume before checking activity. Otherwise paused
  -- gathering loot (herbs, mining nodes, etc.) never reaches the resume path.
  MaybeResumePausedRunFromLoot()

  if not self:IsActive() then
    return
  end

  local itemID = GetItemInfoInstant(link)
  -- Some APIs are picky when a colored hyperlink is passed. If needed,
  -- fall back to an uncolored |Hitem:...|h[...]|h form.
  if not itemID and type(link) == "string" then
    local uncolored = link:match("(|Hitem:.-|h%[.-%]|h)")
    if uncolored then
      itemID = GetItemInfoInstant(uncolored)
    end
  end
  if not itemID then
    return
  end

  qty = tonumber(qty) or 1
  if qty < 1 then qty = 1 end

  local sess = self.current

  local entry = sess.items[itemID] or sess.pending[itemID]
  if not entry then
    entry = {
      itemID = itemID,
      link = link,

      count = 0,

      unitValue = nil,     -- cached once promoted
      value = 0,

      sources = {},        -- [mobName] = count
      lastSource = nil,
    }
    sess.pending[itemID] = entry
  end

  -- Keep the most informative link. The CHAT_MSG_LOOT link can include
  -- instance-specific bonus IDs (Remix/upgraded variants) and can be
  -- fully colored. If we already have a link, replace it when the new
  -- one is "better" (colored or longer).
  if type(link) == "string" then
    if not entry.link then
      entry.link = link
    else
      local cur = entry.link
      if type(cur) ~= "string" then
        entry.link = link
      else
        local newIsColored = link:find("^|c%x%x%x%x%x%x%x%x") ~= nil
        local curIsColored = cur:find("^|c%x%x%x%x%x%x%x%x") ~= nil
        if (newIsColored and not curIsColored) or (#link > #cur) then
          entry.link = link
        end
      end
    end
  end
  entry.count = (entry.count or 0) + qty

  -- Source capture (works for pending and recorded items)
  local src = self:GetLikelySource()
  if src then
    _WF_AddSourceCount(entry, src, qty)
  end

  -- Try to resolve meta + promote immediately if it now qualifies.
  CacheItemMeta(entry)

  local minQty, minQuality, ignoreSoulbound, minGold, categories = GetLootCaptureSettings()
  local ok = PassesStaticFilters(entry, minQuality, ignoreSoulbound, categories)

  if ok == false then
    -- Capture filter says "never record"
    sess.pending[itemID] = nil
    sess.items[itemID] = nil
    self._sortedCache = nil
    self:MarkDirty()
    return
  end

  if ok == true then
    if (tonumber(entry.count) or 0) >= minQty then
      -- Promote if it's still pending

      if sess.pending[itemID] then

        -- Evaluate the FULL stack value first; only promote once it meets the minimum value (if set).

        local unit = 0

        if WF.Price and WF.Price.GetItemValue then

          unit = tonumber(WF.Price:GetItemValue(itemID, entry.link)) or 0

        end

        entry.unitValue = unit

        entry.value = (tonumber(unit) or 0) * (tonumber(entry.count) or 0)


        -- Unverified TSM pricing guard: record but exclude from totals by default.
        if (not entry._wfUnverified) and WF.Price and WF.Price.IsTSMUnverified then
          local unv, meta = WF.Price:IsTSMUnverified(itemID, unit)
          if unv then
            entry._wfUnverified = true
            entry._wfUnverifiedMeta = meta
            _SetExcluded(sess, itemID, true)
          end
        end


        -- Optional value gate: keep pending until the stack is worth enough.

        if ValueGateBlocks(entry, minGold) then

          self:MarkDirty()

          return

        end


        sess.pending[itemID] = nil

        sess.items[itemID] = entry


        if not _IsExcluded(sess, itemID) then
          sess.totalValue = (tonumber(sess.totalValue) or 0) + (tonumber(entry.value) or 0)
        end


        self._sortedCache = nil

        self:MarkDirty()

        return

      end

      -- Already recorded: add delta for this loot
      if sess.items[itemID] then
        if entry.unitValue == nil and WF.Price and WF.Price.GetItemValue then
          entry.unitValue = tonumber(WF.Price:GetItemValue(itemID, entry.link)) or 0
        end
        local add = (tonumber(entry.unitValue) or 0) * qty
        entry.value = (tonumber(entry.value) or 0) + add
        if not _IsExcluded(sess, itemID) then
          sess.totalValue = (tonumber(sess.totalValue) or 0) + add
        end

        self._sortedCache = nil
        self:MarkDirty()
        return
      end
    end
  end

  -- Still pending (below minQty or meta not ready)
  self:MarkDirty()
end

function Tracker:AddManualItem(link, qty)
  local sess = self.current
  if type(sess) ~= "table" then
    return false
  end

  if type(link) ~= "string" or link == "" then
    return false
  end

  local itemID = GetItemInfoInstant(link)
  -- Some APIs are picky when a colored hyperlink is passed.
  if not itemID and type(link) == "string" then
    local uncolored = link:match("(|Hitem:.-|h%[.-%]|h)")
    if uncolored then
      itemID = GetItemInfoInstant(uncolored)
    end
  end
  if not itemID then
    return false
  end

  qty = tonumber(qty) or 1
  qty = math.floor(qty + 0.0001)
  if qty < 1 then qty = 1 end

  local wasRecorded = (sess.items and sess.items[itemID] ~= nil)
  local wasPending = (sess.pending and sess.pending[itemID] ~= nil)

  local entry = nil
  if wasRecorded then
    entry = sess.items[itemID]
  elseif wasPending then
    entry = sess.pending[itemID]
  end

  if not entry then
    entry = {
      itemID = itemID,
      link = link,
      count = 0,
      unitValue = nil,
      value = 0,
      sources = {},
      lastSource = nil,
    }
  end

  -- Keep the most informative link.
  if type(link) == "string" then
    if not entry.link then
      entry.link = link
    else
      local cur = entry.link
      if type(cur) ~= "string" then
        entry.link = link
      else
        local newIsColored = link:find("^|c%x%x%x%x%x%x%x%x") ~= nil
        local curIsColored = cur:find("^|c%x%x%x%x%x%x%x%x") ~= nil
        if (newIsColored and not curIsColored) or (#link > #cur) then
          entry.link = link
        end
      end
    end
  end

  local oldCount = tonumber(entry.count) or 0
  entry.count = oldCount + qty

  entry.sources = entry.sources or {}
  entry.sources["Manual"] = (tonumber(entry.sources["Manual"]) or 0) + qty
  entry.lastSource = "Manual"
  entry._wfManual = true

  CacheItemMeta(entry)

  local unit = entry.unitValue
  if unit == nil and WF and WF.Price and WF.Price.GetItemValue then
    unit = tonumber(WF.Price:GetItemValue(itemID, entry.link or link))
  end
  unit = tonumber(unit) or 0

  local deltaTotal = 0

  if (not wasRecorded) and wasPending then
    -- Promote pending -> recorded, and count the FULL value (pending items were not in totals).
    entry.unitValue = unit
    entry.value = unit * (tonumber(entry.count) or 0)
    deltaTotal = tonumber(entry.value) or 0

    sess.pending[itemID] = nil
    sess.items[itemID] = entry
  else
    -- Recorded already (or brand new): only count the delta.
    entry.unitValue = unit

    local baseValue = tonumber(entry.value)
    if baseValue == nil or baseValue < 0 then
      baseValue = unit * oldCount
    end

    local add = unit * qty
    entry.value = baseValue + add
    deltaTotal = add

    if not wasRecorded then
      sess.items[itemID] = entry
      if sess.pending and sess.pending[itemID] then
        sess.pending[itemID] = nil
      end
    end
  end

  -- Unverified guard: never include unknown TSM values in totals.
  if (not entry._wfUnverified) and WF and WF.Price and WF.Price.IsTSMUnverified then
    local unv, meta = WF.Price:IsTSMUnverified(itemID, entry.unitValue)
    if unv then
      entry._wfUnverified = true
      entry._wfUnverifiedMeta = meta
      _SetExcluded(sess, itemID, true)
    end
  end

  if deltaTotal and deltaTotal ~= 0 then
    if not _IsExcluded(sess, itemID) then
      sess.totalValue = (tonumber(sess.totalValue) or 0) + deltaTotal
    end
  end

  self._sortedCache = nil
  self:MarkDirty()
  return true
end

function Tracker:OnMoney(copper)
  if not self:IsActive() then
    return
  end
  copper = tonumber(copper) or 0
  if copper <= 0 then
    return
  end
  local sess = self.current
  sess.moneyGained = (sess.moneyGained or 0) + copper
  sess.totalValue = (sess.totalValue or 0) + copper

  self:MarkDirty()
end

function Tracker:OnKill()
  if not self:IsActive() then
    return
  end
  self.current.kills = (self.current.kills or 0) + 1
  self:MarkDirty()
end

function Tracker:SetLootSortMode(mode)
  -- mode: "value" | "count" | "price" | "name"
  if type(mode) ~= "string" or mode == "" then
    mode = "value"
  end
  if mode ~= "value" and mode ~= "count" and mode ~= "price" and mode ~= "name" then
    mode = "value"
  end
  self._lootSortMode = mode
  self._sortedCache = nil
  if self.MarkDirty then
    self:MarkDirty()
  end
end

local function _WF_NameKey(entry)
  if type(entry) ~= "table" then
    return ""
  end
  local n = entry.name
  if (not n or n == "") and type(entry.link) == "string" then
    n = entry.link:match("%[(.-)%]")
  end
  n = tostring(n or "")
  return n:lower()
end


-- ---------------------------------------------------------
-- Loot sorting (main window)
-- ---------------------------------------------------------
function Tracker:SetLootSortKey(key)
  if type(key) ~= "string" or key == "" then
    key = "value"
  end

  -- Allowed keys: value, count, price, name
  if key ~= "value" and key ~= "count" and key ~= "price" and key ~= "name" then
    key = "value"
  end

  if self._lootSortKey == key then
    local d = tostring(self._lootSortDir or "desc")
    if d == "asc" then
      self._lootSortDir = "desc"
    else
      self._lootSortDir = "asc"
    end
  else
    self._lootSortKey = key
    self._lootSortDir = "desc"
  end

  self._sortedCache = nil
  self:MarkDirty()
end

local function _WF_SortName(entry)
  if type(entry) ~= "table" then return "" end
  local n = entry.name
  if (not n or n == "") and type(entry.link) == "string" then
    n = entry.link:match("%[(.-)%]")
  end
  n = tostring(n or "")
  n = n:gsub("|c%x%x%x%x%x%x%x%x", "")
  n = n:gsub("|r", "")
  return n:lower()
end
function Tracker:GetSortedItems()
  self:RepriceCurrent(false)
  if not self.current then
    return {}
  end
  if self._sortedCache then
    return self._sortedCache
  end

  local t = {}
  for itemID, entry in pairs(self.current.items) do
    t[#t + 1] = { itemID = itemID, entry = entry }
  end

  local key = self._lootSortKey or self._lootSortMode or "value"
  local dir = tostring(self._lootSortDir or "desc")

  table.sort(t, function(a, b)
    local ea = a.entry or {}
    local eb = b.entry or {}

    if key == "count" then
      local va = tonumber(ea.count) or 0
      local vb = tonumber(eb.count) or 0
      if va == vb then
        local vva = tonumber(ea.value) or 0
        local vvb = tonumber(eb.value) or 0
        if vva == vvb then
          local na = _WF_SortName(ea)
          local nb = _WF_SortName(eb)
          if na == nb then
            return tostring(a.itemID or "") < tostring(b.itemID or "")
          end
          if dir == "asc" then return na < nb end
          return na > nb
        end
        if dir == "asc" then return vva < vvb end
        return vva > vvb
      end
      if dir == "asc" then return va < vb end
      return va > vb

    elseif key == "price" then
      local va = tonumber(ea.unitValue) or 0
      local vb = tonumber(eb.unitValue) or 0
      if va == vb then
        local vva = tonumber(ea.value) or 0
        local vvb = tonumber(eb.value) or 0
        if vva == vvb then
          local na = _WF_SortName(ea)
          local nb = _WF_SortName(eb)
          if na == nb then
            return tostring(a.itemID or "") < tostring(b.itemID or "")
          end
          if dir == "asc" then return na < nb end
          return na > nb
        end
        if dir == "asc" then return vva < vvb end
        return vva > vvb
      end
      if dir == "asc" then return va < vb end
      return va > vb

    elseif key == "name" then
      local na = _WF_SortName(ea)
      local nb = _WF_SortName(eb)
      if na == nb then
        local vva = tonumber(ea.value) or 0
        local vvb = tonumber(eb.value) or 0
        if vva == vvb then
          local ca = tonumber(ea.count) or 0
          local cb = tonumber(eb.count) or 0
          if ca == cb then
            return tostring(a.itemID or "") < tostring(b.itemID or "")
          end
          if dir == "asc" then return ca < cb end
          return ca > cb
        end
        if dir == "asc" then return vva < vvb end
        return vva > vvb
      end
      if dir == "asc" then return na < nb end
      return na > nb

    else -- value
      local va = tonumber(ea.value) or 0
      local vb = tonumber(eb.value) or 0
      if va == vb then
        local ca = tonumber(ea.count) or 0
        local cb = tonumber(eb.count) or 0
        if ca == cb then
          local na = _WF_SortName(ea)
          local nb = _WF_SortName(eb)
          if na == nb then
            return tostring(a.itemID or "") < tostring(b.itemID or "")
          end
          if dir == "asc" then return na < nb end
          return na > nb
        end
        if dir == "asc" then return ca < cb end
        return ca > cb
      end
      if dir == "asc" then return va < vb end
      return va > vb
    end
  end)

  self._sortedCache = t
  return t
end

WF.Events:On("CHAT_MSG_LOOT", function(_, msg)
  local link, qty = ParseLootMessage(msg)
  if link and qty and qty > 0 then
    Tracker:OnLoot(link, qty)
  end
end)

WF.Events:On("CHAT_MSG_MONEY", function(_, msg)
  local copper = ParseMoneyMessage(msg)
  if copper and copper > 0 then
    Tracker:OnMoney(copper)
  end
end)

-- Kill tracking disabled (prepatch taint mitigation)
-- Removed COMBAT_LOG_EVENT_UNFILTERED listener that incremented session kills.
