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

-- -----------------------------------------------------
-- Farm name matching + persistent stats ingestion
-- -----------------------------------------------------
local function _WFNormName(s)
  s = tostring(s or "")
  s = s:lower()
  s = s:gsub("^%s+", ""):gsub("%s+$", "")
  s = s:gsub("%s+", " ")
  return s
end

local function _WFSessionNumbers(sess)
  local elapsed = tonumber(sess.elapsedSec or sess.elapsed or sess.durationSec or 0) or 0
  local value = tonumber(sess.totalValue or sess.valueCopper or sess.value or 0) or 0
  local gold = tonumber(sess.moneyGained or sess.lootedGold or sess.gold or 0) or 0
  local kills = tonumber(sess.kills or 0) or 0
  return elapsed, value, gold, kills
end

-- ---------------------------------------------------------
-- Defaults
-- ---------------------------------------------------------
local DEFAULTS = {
  settings = {
    historyMinSeconds = 60,
    historyMinGPH = 0,
    historyAvgMinSeconds = 0,
    debug = false,

    -- pricing
    priceSource = "vendor",  -- vendor | tsm | auctionator


    -- TSM custom price string (used when priceSource = "tsm")
    tsmPriceString = "dbregionmarketavg",
    -- ui
    uiScale = 1.0,
    uiLocked = false,
    uiLocked_main = false,
    uiLocked_help = false,
    uiLocked_cmp = false,
    uiLocked_comp = false,
    uiLocked_notepad = false,
    -- Removed from UI (deprecated): always off
    showColumnDividers = false,
    -- Removed from UI (always on)
    colorByQuality = true,
    -- Tooltip: show "Dropped by" in tooltips
    tooltipShowSource = true,

    -- Minimap button toggle (default on)
    showMinimapButton = true,

    autoStartSessionOnOpen = false,
    autoResumeOnLootWhilePaused = false,

    -- history / dungeon resets
    -- Reset breakdown stats are recorded automatically; this only controls Top Loot display.
    groupTopLootByReset = false,

    -- profile / mode safety
    lockProfileWhileRecording = false,       -- if true, blocks profile switches while tracking
    splitRunOnProfileChange = true,          -- if true, switching profile mid-run ends + saves current run and starts a new one
    -- capture / farms
    allowInstanceFarms = true,
    farmDedupTolerance = 0.003,     -- ~0.3% of map coords

    -- loot capture filters (all values in copper where applicable)
    lootMinQty = 1,                 -- minimum total quantity required before an item is recorded
    lootMinGold = 0,                -- minimum STACK value before item is recorded (0 = off)
    lootMinQuality = 0,             -- 0=poor, 1=common, 2=uncommon...
    lootIncludeMaterials = true,    -- legacy compatibility
    lootOnlyMaterials = false,      -- legacy compatibility
    lootOnlyGear = false,           -- legacy compatibility
    lootRecordMaterials = false,
    lootRecordGear = false,
    lootRecordRecipes = false,
    lootRecordPets = false,
    lootRecordToys = false,
    lootRecordMounts = false,
    lootRecordConsumables = false,
    lootRecordMisc = false,
    lootIgnoreSoulbound = true,

    -- Unverified price guard (TSM):
    -- If an item has no AH postings (dbminbuyout) and no regional sale rate (dbregionsalerate),
    -- but TSM prices it at a high value, record it but exclude it from totals by default.
    -- You can restore/include it later from History > Loot (right click item).
    lootFlagUnverifiedTSM = true,
    lootUnverifiedMinUnit = 100000000, -- 10,000g in copper (per-unit threshold)

    -- metrics
    overviewLast5Metric = "gph",    -- gph | liv (UI toggle)

    -- main-window metric pills (4 slots; Hidden = disabled slot)
    mainWindowPill1 = "Time",
    mainWindowPill2 = "Looted Gold",
    mainWindowPill3 = "Looted Items",
    mainWindowPill4 = "Hidden",

    -- optional double-tap hotkey to toggle the main window
    doubleTapToggleEnabled = false,
    doubleTapToggleKey = "ESCAPE",
  },

  -- Profile bundles: "farming intent" settings you switch frequently (mats vs transmog, etc)
  profiles = {
    active = BASE_PROFILE_NAME,
    list = {
      -- The starter/base profile is created from your current settings on first migration.
    }
  },

  minimap = { hide = false, minimapPos = 220 },

  farms = {},

  history = {
    sessions = {},
    totalsByFarm = {},
    cap = 200,
  },

  -- Persistent per-farm aggregates used for averages. Unlike history totals,
  -- these do NOT shrink when history is deleted, so farm averages stay consistent.
  farmStats = {},

  -- Ephemeral runtime state (kept small). Used for /reload-safe session resume.
  runtime = {
    activeSession = nil,
  },

  _nextFarmId = 1,
}

-- These are the ONLY settings that profiles are allowed to touch.
local PROFILE_KEYS = {
  "lootMinGold",
  "lootMinQty",
  "lootMinQuality",
  "lootIncludeMaterials",
  "lootOnlyMaterials",
  "lootOnlyGear",
  "lootRecordMaterials",
  "lootRecordGear",
  "lootRecordRecipes",
  "lootRecordPets",
  "lootRecordToys",
  "lootRecordMounts",
  "lootRecordConsumables",
  "lootRecordMisc",
  "lootIgnoreSoulbound",
  "priceSource",
  "tsmPriceString",
  "overviewLast5Metric",
}

local PROFILE_KEYSET = {}
do
  for i = 1, #PROFILE_KEYS do
    PROFILE_KEYSET[PROFILE_KEYS[i]] = true
  end
end

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 ClearCategorySelections(t)
  if type(t) ~= "table" then return end
  for i = 1, #CATEGORY_KEYS do
    t[CATEGORY_KEYS[i]] = false
  end
end

local function ApplyLegacyCategorySettings(t)
  if type(t) ~= "table" then return end

  local includeMaterials = t.lootIncludeMaterials
  if includeMaterials == nil then includeMaterials = true end
  includeMaterials = includeMaterials and true or false

  local onlyMaterials = t.lootOnlyMaterials and true or false
  local onlyGear = t.lootOnlyGear and true or false

  ClearCategorySelections(t)

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

  if onlyMaterials then
    t.lootRecordMaterials = true
    return
  end

  if onlyGear then
    t.lootRecordGear = true
    return
  end

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

local function NormalizeCategorySettings(t)
  if type(t) ~= "table" then return end
  for i = 1, #CATEGORY_KEYS do
    local k = CATEGORY_KEYS[i]
    t[k] = t[k] and true or false
  end
end

local function NormalizeLootCategoryProfiles(db)
  if type(db) ~= "table" or type(db.settings) ~= "table" then return end

  local migrated = db.settings._wfLootCategoryMigrate_20260318 == true

  if not migrated then
    ApplyLegacyCategorySettings(db.settings)
  else
    NormalizeCategorySettings(db.settings)
  end

  if type(db.profiles) == "table" and type(db.profiles.list) == "table" then
    for _, p in pairs(db.profiles.list) do
      if type(p) == "table" then
        if not migrated then
          ApplyLegacyCategorySettings(p)
        else
          NormalizeCategorySettings(p)
        end
      end
    end
  end

  db.settings._wfLootCategoryMigrate_20260318 = true
end

local BASE_PROFILE_NAME = "Default"
local LEGACY_BASE_PROFILE_NAME = "Custom"

local DEFAULT_PROFILE_PRESETS = {
  ["Transmog"] = {
    lootMinGold = 100 * 10000,     -- 100g
    lootMinQty = 1,
    lootMinQuality = 2,            -- Uncommon+
    lootIncludeMaterials = false,
    lootOnlyMaterials = false,
    lootOnlyGear = true,
    lootRecordMaterials = false,
    lootRecordGear = true,
    lootRecordRecipes = false,
    lootRecordPets = false,
    lootRecordToys = false,
    lootRecordMounts = false,
    lootRecordConsumables = false,
    lootRecordMisc = false,
    lootIgnoreSoulbound = true,
    overviewLast5Metric = "liv",
  },
  ["Mats"] = {
    lootMinGold = 0,
    lootMinQty = 1,
    lootMinQuality = 0,            -- Poor+
    lootIncludeMaterials = true,
    lootOnlyMaterials = true,      -- ONLY reagents/materials
    lootOnlyGear = false,
    lootRecordMaterials = true,
    lootRecordGear = false,
    lootRecordRecipes = false,
    lootRecordPets = false,
    lootRecordToys = false,
    lootRecordMounts = false,
    lootRecordConsumables = false,
    lootRecordMisc = false,
    lootIgnoreSoulbound = false,   -- allow BoP mats (only-mats mode prevents BoP gear anyway)
    overviewLast5Metric = "gph",
  },
  ["Raw Gold"] = {
    lootMinGold = 1000000 * 10000, -- 1,000,000g (effectively disables item recording)
    lootMinQty = 1,
    lootMinQuality = 5,            -- Legendary+
    lootIncludeMaterials = false,
    lootOnlyMaterials = false,
    lootOnlyGear = false,
    lootRecordMaterials = false,
    lootRecordGear = false,
    lootRecordRecipes = false,
    lootRecordPets = false,
    lootRecordToys = false,
    lootRecordMounts = false,
    lootRecordConsumables = false,
    lootRecordMisc = false,
    lootIgnoreSoulbound = true,
    priceSource = "vendor",
    overviewLast5Metric = "gph",
  },
}


local function DeepCopy(dst, src)
  for k, v in pairs(src) do
    if type(v) == "table" then
      if type(dst[k]) ~= "table" then
        dst[k] = {}
      end
      DeepCopy(dst[k], v)
    else
      if dst[k] == nil then
        dst[k] = v
      end
    end
  end
end

local function ShallowCopy(src)
  if type(src) ~= "table" then return {} end
  local t = {}
  for k, v in pairs(src) do
    t[k] = v
  end
  return t
end

-- ---------------------------------------------------------
-- Profiles
-- ---------------------------------------------------------
local function CaptureCurrentProfileSettings(db)
  local out = {}
  db.settings = db.settings or {}
  for i = 1, #PROFILE_KEYS do
    local k = PROFILE_KEYS[i]
    out[k] = db.settings[k]
  end
  return out
end

local function MergeNumericFields(dst, src, keys)
  if type(dst) ~= "table" or type(src) ~= "table" or type(keys) ~= "table" then return end
  for i = 1, #keys do
    local k = keys[i]
    dst[k] = (tonumber(dst[k]) or 0) + (tonumber(src[k]) or 0)
  end
end

local function RenameProfileReferencesInDb(db, oldName, newName)
  if type(db) ~= "table" then return end
  if type(oldName) ~= "string" or oldName == "" then return end
  if type(newName) ~= "string" or newName == "" then return end
  if oldName == newName then return end

  if type(db.settings) == "table" then
    local viewMode = tostring(db.settings.farmLibraryViewMode or "")
    if viewMode == oldName then
      db.settings.farmLibraryViewMode = newName
    end
  end

  if type(db.runtime) == "table" and type(db.runtime.activeSession) == "table" then
    local s = db.runtime.activeSession
    if tostring(s.mode or "") == oldName then s.mode = newName end
    if tostring(s.profile or "") == oldName then s.profile = newName end
    if tostring(s.profileName or "") == oldName then s.profileName = newName end
    if tostring(s.farmMode or "") == oldName then s.farmMode = newName end
    if tostring(s.modeName or "") == oldName then s.modeName = newName end
  end

  if type(db.history) == "table" then
    local h = db.history

    if type(h.sessions) == "table" then
      for i = 1, #h.sessions do
        local s = h.sessions[i]
        if type(s) == "table" then
          if tostring(s.mode or "") == oldName then s.mode = newName end
          if tostring(s.profile or "") == oldName then s.profile = newName end
          if tostring(s.profileName or "") == oldName then s.profileName = newName end
          if tostring(s.farmMode or "") == oldName then s.farmMode = newName end
          if tostring(s.modeName or "") == oldName then s.modeName = newName end
        end
      end
    end

    if type(h.totalsByFarmMode) == "table" then
      for _, byMode in pairs(h.totalsByFarmMode) do
        if type(byMode) == "table" and type(byMode[oldName]) == "table" then
          if type(byMode[newName]) ~= "table" then
            byMode[newName] = byMode[oldName]
          else
            MergeNumericFields(byMode[newName], byMode[oldName], { "time", "value", "gold", "kills", "runs", "sessions" })
          end
          byMode[oldName] = nil
        end
      end
    end
  end

  if type(db.farmStats) == "table" then
    for _, fs in pairs(db.farmStats) do
      if type(fs) == "table" and type(fs.modes) == "table" and type(fs.modes[oldName]) == "table" then
        if type(fs.modes[newName]) ~= "table" then
          fs.modes[newName] = fs.modes[oldName]
        else
          MergeNumericFields(fs.modes[newName], fs.modes[oldName], { "time", "value", "gold", "kills", "runs", "sessions" })
        end
        fs.modes[oldName] = nil
      end
    end
  end

  if type(db.farmStatsBySid) == "table" then
    for _, rec in pairs(db.farmStatsBySid) do
      if type(rec) == "table" then
        if tostring(rec.mode or "") == oldName then rec.mode = newName end
        if tostring(rec.profile or "") == oldName then rec.profile = newName end
        if tostring(rec.profileName or "") == oldName then rec.profileName = newName end
      end
    end
  end
end

local function MigrateLegacyBaseProfileName(db)
  if type(db) ~= "table" then return end

  db.profiles = db.profiles or {}
  db.profiles.list = db.profiles.list or {}

  if db.profiles._wfBaseProfileRename_20260323 == true then
    return
  end

  local list = db.profiles.list

  if type(list[LEGACY_BASE_PROFILE_NAME]) == "table" and list[BASE_PROFILE_NAME] == nil then
    list[BASE_PROFILE_NAME] = list[LEGACY_BASE_PROFILE_NAME]
    list[LEGACY_BASE_PROFILE_NAME] = nil

    if db.profiles.active == LEGACY_BASE_PROFILE_NAME or type(db.profiles.active) ~= "string" or db.profiles.active == "" then
      db.profiles.active = BASE_PROFILE_NAME
    end

    RenameProfileReferencesInDb(db, LEGACY_BASE_PROFILE_NAME, BASE_PROFILE_NAME)
  end

  db.profiles._wfBaseProfileRename_20260323 = true
end

local function GetPreferredProfileName(list)
  if type(list) ~= "table" then return nil end
  if type(list[BASE_PROFILE_NAME]) == "table" then return BASE_PROFILE_NAME end
  if type(list[LEGACY_BASE_PROFILE_NAME]) == "table" then return LEGACY_BASE_PROFILE_NAME end

  local best = nil
  for name, profile in pairs(list) do
    if type(name) == "string" and name ~= "" and type(profile) == "table" then
      if best == nil or tostring(name) < tostring(best) then
        best = name
      end
    end
  end
  return best
end

function WF.DB:EnsureProfiles()
  local db = self._db
  if not db then return end

  db.settings = db.settings or {}

  -- Migration: older builds used lootMinValueCopper; promote to lootMinGold.
  if db.settings.lootMinGold == nil and db.settings.lootMinValueCopper ~= nil then
    db.settings.lootMinGold = db.settings.lootMinValueCopper
  end

  db.profiles = db.profiles or {}
  db.profiles.list = db.profiles.list or {}

  local list = db.profiles.list
  local hadAnyProfiles = next(list) ~= nil

  MigrateLegacyBaseProfileName(db)
  list = db.profiles.list
  hadAnyProfiles = next(list) ~= nil

  -- Fresh installs / empty profile tables get the starter profile set once.
  -- Existing installs do NOT recreate missing profile names, which keeps renames
  -- and deletions from silently spawning replacement profiles on the next load.
  if not hadAnyProfiles then
    list[BASE_PROFILE_NAME] = CaptureCurrentProfileSettings(db)
    for name, preset in pairs(DEFAULT_PROFILE_PRESETS) do
      if not list[name] then
        list[name] = ShallowCopy(preset)
      end
    end
  end

  -- Backfill missing keys only for profiles that still exist under their current names.
  for name, preset in pairs(DEFAULT_PROFILE_PRESETS) do
    if type(list[name]) == "table" then
      for k, v in pairs(preset) do
        if list[name][k] == nil then
          list[name][k] = v
        end
      end
    end
  end

  -- Backfill per-profile TSM price strings (newer builds store tsmPriceString in profiles).
  -- This keeps existing profiles stable and makes the Profiles UI show the actual TSM price.
  do
    local fallback = tostring(db.settings.tsmPriceString or "dbregionmarketavg")
    for pname, p in pairs(list) do
      if type(p) == "table" then
        local ps = tostring(p.priceSource or ""):lower()

        -- If someone stored "tsm:<pricestring>" historically, normalize it.
        local prov, extra = ps:match("^([%a_]+)%:(.+)$")
        if prov == "tsm" and extra and extra ~= "" then
          p.priceSource = "tsm"
          if p.tsmPriceString == nil or tostring(p.tsmPriceString) == "" then
            p.tsmPriceString = tostring(extra)
          end
          ps = "tsm"
        end

        if ps == "tsm" then
          if p.tsmPriceString == nil or tostring(p.tsmPriceString) == "" then
            p.tsmPriceString = fallback
          end
        end
      end
    end
  end

  local active = db.profiles.active
  if type(active) ~= "string" or active == "" or not list[active] then
    local fallback = GetPreferredProfileName(list)
    if not fallback then
      list[BASE_PROFILE_NAME] = CaptureCurrentProfileSettings(db)
      fallback = BASE_PROFILE_NAME
    end
    db.profiles.active = fallback
  end
end

function WF.DB:GetActiveProfileName()
  if not self._db then return BASE_PROFILE_NAME end
  self:EnsureProfiles()
  return tostring(self._db.profiles and self._db.profiles.active or BASE_PROFILE_NAME)
end

function WF.DB:GetProfile(name)
  if not self._db then return nil end
  self:EnsureProfiles()
  local list = self._db.profiles and self._db.profiles.list
  if not list then return nil end
  return list[name]
end

function WF.DB:GetProfileNames()
  if not self._db then return { BASE_PROFILE_NAME } end
  self:EnsureProfiles()
  local list = (self._db.profiles and self._db.profiles.list) or {}
  local names = {}

  for n, profile in pairs(list) do
    if type(profile) == "table" then
      names[#names + 1] = n
    end
  end

  table.sort(names, function(a, b)
    return tostring(a) < tostring(b)
  end)

  return names
end

function WF.DB:ApplyProfile(name)
  local db = self._db
  if not db then return false end

  self:EnsureProfiles()

  local list = db.profiles and db.profiles.list
  if not list then return false end

  local prof = list[name]
  if type(prof) ~= "table" then
    return false
  end

  -- -----------------------------------------------------
  -- Safety: switching profiles while recording
  -- -----------------------------------------------------
  local function Notify(msg)
    msg = tostring(msg or "")
    if msg == "" then return end
    if UIErrorsFrame and UIErrorsFrame.AddMessage then
      UIErrorsFrame:AddMessage(msg, 0.8, 0.8, 0.8, 1)
      return
    end
    if DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then
      DEFAULT_CHAT_FRAME:AddMessage(msg)
    end
  end

  local tracker = WF and WF.Tracker
  local isActive = tracker and tracker.IsActive and tracker:IsActive() or false
  local curActive = (db.profiles and db.profiles.active) or BASE_PROFILE_NAME

  local lockWhile = db.settings and db.settings.lockProfileWhileRecording and true or false
  local splitWhile = db.settings and db.settings.splitRunOnProfileChange and true or false

  local switching = (isActive == true) and (tostring(name) ~= tostring(curActive))

  -- If recording and user switches profile, either block or split into a new run.
  local splitCarry = nil
  if switching then
    local sess = tracker and tracker.current
    local startedMode = sess and sess.mode or curActive

    if lockWhile then
      Notify(string.format("What Farm?: Stop or split the run to change Mode (recording: %s).", tostring(startedMode or curActive)))
      return false
    end

    if splitWhile and tracker and tracker.Stop and tracker.Reset and tracker.Start then
      splitCarry = {
        farmId = sess and sess.farmId or nil,
        farmName = sess and sess.farmName or nil,
        fromMode = startedMode,
        toMode = tostring(name),
      }

      -- Save and end current run under the original mode.
      tracker:Stop(true)
      tracker:Reset()
    else
      -- Allowed, but warn about stamping.
      Notify(string.format("What Farm?: Mode changed while recording; this run will save as %s.", tostring(startedMode or curActive)))
    end
  end

  db.settings = db.settings or {}

  for i = 1, #PROFILE_KEYS do
    local k = PROFILE_KEYS[i]
    if prof[k] ~= nil then
      db.settings[k] = prof[k]
    end
  end

  db.profiles.active = name

  -- If we split due to mode change, start a fresh run under the new profile.
  if splitCarry and tracker and tracker.Start then
    tracker:Start({ farmId = splitCarry.farmId, name = splitCarry.farmName })
    Notify(string.format("What Farm?: Split run (%s → %s).", tostring(splitCarry.fromMode), tostring(splitCarry.toMode)))
  end

-- -----------------------------------------------------
-- UI refresh + repricing (so HelpWindow updates instantly
-- when you switch Modes / change price source)
-- -----------------------------------------------------
if WF and WF.Tracker and WF.Tracker.RepriceCurrent then
  pcall(WF.Tracker.RepriceCurrent, WF.Tracker, true)
end

if WF and WF.UI then
  local Main = WF.UI.Main
  if Main then
    if Main.RefreshProfileUI then pcall(Main.RefreshProfileUI, Main) end
    if Main.RefreshTitle then pcall(Main.RefreshTitle, Main) end
    if Main.RefreshMetrics then pcall(Main.RefreshMetrics, Main) end
    if Main.RefreshLoot then pcall(Main.RefreshLoot, Main, true) end
  end

  local Help = WF.UI.Help
  if Help then
    if Help.RefreshOverview then pcall(Help.RefreshOverview, Help) end
    if Help.RefreshHistory then pcall(Help.RefreshHistory, Help) end
    if Help.RefreshFarms then pcall(Help.RefreshFarms, Help) end
    if Help.RefreshLootSettings then pcall(Help.RefreshLootSettings, Help) end
  end

  local Minimap = WF.UI.Minimap
  if Minimap and Minimap.Refresh then
    pcall(Minimap.Refresh, Minimap)
  end
end
  return true
end

function WF.DB:SaveCurrentToProfile(name)
  local db = self._db
  if not db then return false end

  self:EnsureProfiles()

  local list = db.profiles and db.profiles.list
  if not list then return false end

  name = tostring(name or "")
  name = name:gsub("^%s+", ""):gsub("%s+$", "")
  if name == "" then return false end
  if #name > 32 then
    name = name:sub(1, 32)
  end

  list[name] = CaptureCurrentProfileSettings(db)
  return true
end

-- ---------------------------------------------------------
-- Profile management
-- ---------------------------------------------------------
local function NormalizeProfileName(name)
  name = tostring(name or "")
  name = name:gsub("^%s+", ""):gsub("%s+$", "")
  if name == "" then return nil end
  if #name > 32 then
    name = name:sub(1, 32)
  end
  return name
end

function WF.DB:GetBaseProfileName()
  return BASE_PROFILE_NAME
end

function WF.DB:IsDefaultProfile(name)
  name = NormalizeProfileName(name)
  if not name then return false end
  return DEFAULT_PROFILE_PRESETS[name] ~= nil
end

function WF.DB:ResetProfileToDefault(name)
  local db = self._db
  if not db then return false end
  self:EnsureProfiles()

  name = NormalizeProfileName(name)
  if not name then return false end

  local preset = DEFAULT_PROFILE_PRESETS[name]
  if not preset then return false end

  local list = db.profiles and db.profiles.list
  if not list then return false end

  list[name] = ShallowCopy(preset)
  return true
end

function WF.DB:DeleteProfile(name)
  local db = self._db
  if not db then return false end
  self:EnsureProfiles()

  name = NormalizeProfileName(name)
  if not name then return false end

  local list = db.profiles and db.profiles.list
  if not list or not list[name] then return false end

  local count = 0
  for _, profile in pairs(list) do
    if type(profile) == "table" then
      count = count + 1
    end
  end
  if count <= 1 then
    return false
  end

  list[name] = nil

  if db.profiles and db.profiles.active == name then
    db.profiles.active = GetPreferredProfileName(list)
  end

  return true
end

function WF.DB:RenameProfile(oldName, newName)
  local db = self._db
  if not db then return false end
  self:EnsureProfiles()

  oldName = NormalizeProfileName(oldName)
  newName = NormalizeProfileName(newName)
  if not oldName or not newName then return false end

  local list = db.profiles and db.profiles.list
  if not list or not list[oldName] then return false end
  if oldName == newName then return true end
  if list[newName] then return false end

  list[newName] = list[oldName]
  list[oldName] = nil

  if db.profiles and db.profiles.active == oldName then
    db.profiles.active = newName
  end

  RenameProfileReferencesInDb(db, oldName, newName)

  local Help = WF and WF.UI and WF.UI.Help
  if Help and Help.GetModeFilter and Help.SetModeFilter and Help:GetModeFilter() == oldName then
    Help:SetModeFilter(newName)
  else
    if Help and Help.RefreshOverview then pcall(Help.RefreshOverview, Help) end
    if Help and Help.RefreshHistory then pcall(Help.RefreshHistory, Help) end
    if Help and Help.RefreshFarms then pcall(Help.RefreshFarms, Help) end
  end

  local Main = WF and WF.UI and WF.UI.Main
  if Main then
    if Main.RefreshProfileUI then pcall(Main.RefreshProfileUI, Main) end
    if Main.RefreshTitle then pcall(Main.RefreshTitle, Main) end
    if Main.RefreshMetrics then pcall(Main.RefreshMetrics, Main) end
    if Main.RefreshLoot then pcall(Main.RefreshLoot, Main, true) end
  end

  return true
end

-- ---------------------------------------------------------
-- Seed Farms
-- ---------------------------------------------------------
local function NextFarmIdInternal(db)
  db._nextFarmId = tonumber(db._nextFarmId) or 1
  local id = db._nextFarmId
  db._nextFarmId = id + 1
  return id
end

local function CopyTableValue(src)
  if type(src) ~= "table" then return src end
  local out = {}
  for k, v in pairs(src) do
    out[k] = CopyTableValue(v)
  end
  return out
end

local function ApplySeedFarmOverrides(dst, src)
  if type(dst) ~= "table" or type(src) ~= "table" then return end

  local overrideKeys = {
    "name",
    "zone",
    "x",
    "y",
    "expansion",
    "aim",
    "farmType",
    "expectedGPH",
    "image",
    "tags",
    "notes",
    "mapId",
    "createdAt",
    "updatedAt",
    "archived",
  }

  for i = 1, #overrideKeys do
    local key = overrideKeys[i]
    if src[key] ~= nil then
      dst[key] = CopyTableValue(src[key])
    end
  end
end

local function BuildSeedFarmIndex(farms)
  local byKey = {}
  local bySignature = {}

  if type(farms) ~= "table" then
    return byKey, bySignature
  end

  for id, farm in pairs(farms) do
    if type(farm) == "table" and tostring(farm.source or "") == "seed" then
      local seedKey = tostring(farm.seedKey or "")
      if seedKey ~= "" then
        byKey[seedKey] = id
      else
        local sig = tostring(farm.expansion or "") .. "" .. tostring(farm.zone or "") .. "" .. tostring(farm.name or "")
        if sig ~= "" then
          bySignature[sig] = id
        end
      end
    end
  end

  return byKey, bySignature
end

function WF.DB:RememberDeletedSeedFarmByKey(seedKey)
  if not self._db then return end

  seedKey = tostring(seedKey or "")
  if seedKey == "" then return end

  local db = self._db
  db._seed = db._seed or { version = 0, keys = {}, deletedKeys = {} }
  db._seed.deletedKeys = db._seed.deletedKeys or {}
  db._seed.deletedKeys[seedKey] = true
end

function WF.DB:ApplySeedFarms()
  if not self._db then return end

  local db = self._db
  db.farms = db.farms or {}

  -- Prefer the bridged table (WF.Seeds.farms) but fall back to WF.FARM_SEEDS
  -- so seed farms still load even if the bridge wasn't created (load-order/custom builds).
  local seeds = (WF.Seeds and WF.Seeds.farms) or WF.FARM_SEEDS
  if type(seeds) ~= "table" or #seeds == 0 then return end

  db._seed = db._seed or { version = 0, keys = {}, deletedKeys = {} }
  local seedState = db._seed

  local keys = {}
  seedState.keys = keys
  seedState.deletedKeys = seedState.deletedKeys or {}
  local deletedKeys = seedState.deletedKeys

  local currentSeedKeys = {}
  local liveByKey, liveBySignature = BuildSeedFarmIndex(db.farms)

  for i = 1, #seeds do
    local s = seeds[i]
    local key = s and tostring(s.seedKey or "") or ""
    if key ~= "" then
      currentSeedKeys[key] = true
      keys[key] = true

      if not deletedKeys[key] then
        local farmId = liveByKey[key]
        if not farmId then
          local sig = tostring(s.expansion or "") .. "" .. tostring(s.zone or "") .. "" .. tostring(s.name or "")
          farmId = liveBySignature[sig]
        end

        local existing = farmId and db.farms[farmId] or nil
        local farm = {}
        DeepCopy(farm, s)

        if existing then
          farm.id = existing.id or farm.id or farmId
          farm.createdAt = existing.createdAt or farm.createdAt or time()
          farm.archived = existing.archived and true or nil
          ApplySeedFarmOverrides(farm, existing)
        else
          farm.id = farm.id or NextFarmIdInternal(db)
          farm.createdAt = farm.createdAt or time()
        end

        farm.source = "seed"
        farm.seedKey = key

        db.farms[farm.id] = farm
      end
    end
  end

  for key in pairs(deletedKeys) do
    if not currentSeedKeys[key] then
      deletedKeys[key] = nil
    end
  end

  for id, farm in pairs(db.farms) do
    if type(farm) == "table" and tostring(farm.source or "") == "seed" then
      local key = tostring(farm.seedKey or "")
      if key == "" or not currentSeedKeys[key] or deletedKeys[key] then
        db.farms[id] = nil
      end
    end
  end

  local seedVer = tonumber((WF.Seeds and WF.Seeds.version) or WF.FARM_SEED_VERSION or 0) or 0
  seedState.version = seedVer
end

function WF.DB:Touch()
  -- SavedVariables persist automatically; Touch is a no-op hook for UI refreshes.
  return true
end


-- Normalize legacy settings where lootMinQuality may have been saved as a label string (e.g. "Uncommon+").
local function NormalizeLootMinQuality(db)
  if type(db) ~= "table" or type(db.settings) ~= "table" then return end

  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,
  }

  local function NormalizeOne(v)
    if type(v) == "string" then
      v = MAP[v] or tonumber(v)
    end
    v = tonumber(v) or 0
    if v < 0 then v = 0 end
    if v > 7 then v = 7 end
    return v
  end

  db.settings.lootMinQuality = NormalizeOne(db.settings.lootMinQuality)

  if type(db.profiles) == "table" and type(db.profiles.list) == "table" then
    for _, p in pairs(db.profiles.list) do
      if type(p) == "table" then
        p.lootMinQuality = NormalizeOne(p.lootMinQuality)
      end
    end
  end
end

function WF.DB:Init()
  WhatFarmDB = WhatFarmDB or {}
  DeepCopy(WhatFarmDB, DEFAULTS)
  self._db = WhatFarmDB

  -- Capture & UI normalization / one-time migrations.
  if self._db and self._db.settings then
    local s = self._db.settings


  -- Per-window UI lock migration (independent window locks)
  do
    local legacy = s.uiLocked and true or false
    if s.uiLocked_main == nil then s.uiLocked_main = legacy end
    if s.uiLocked_help == nil then s.uiLocked_help = legacy end
    if s.uiLocked_cmp == nil then s.uiLocked_cmp = legacy end
    if s.uiLocked_comp == nil then s.uiLocked_comp = legacy end
    if s.uiLocked_notepad == nil then s.uiLocked_notepad = legacy end
  end
    -- Deprecated toggle: always off.
    s.showColumnDividers = false

    -- Always-on visual (removed from UI).
    s.colorByQuality = true

    -- One-time defaults for options we previously removed/forced.
    -- (Avoid forcing every load so user choices persist.)
    if s._wfCapUIMigrate_20260109 ~= true then
      if s.allowInstanceFarms == nil then s.allowInstanceFarms = true end
      if s.tooltipShowSource == nil or s.tooltipShowSource == false then s.tooltipShowSource = true end
      if s.showMinimapButton == nil then s.showMinimapButton = true end
      s._wfCapUIMigrate_20260109 = true
    end
  end

  NormalizeLootMinQuality(self._db)
  self:ApplySeedFarms()
  self:EnsureProfiles()
  NormalizeLootCategoryProfiles(self._db)
  self:EnsureFarmStats()
end

function WF.DB:EnsureFarmStats()
  local db = self._db
  if not db then return end
  db.farmStats = db.farmStats or {}
  db.farmStatsBySid = db.farmStatsBySid or {}

  local h = db.history
  if type(h) ~= "table" or type(h.sessions) ~= "table" or #h.sessions == 0 then return end

  local function _NormFarmId(id)
    return tonumber(id) or id
  end

  local function _NormModeName(mode)
    mode = tostring(mode or BASE_PROFILE_NAME)
    if mode == "" then mode = BASE_PROFILE_NAME end
    return mode
  end

  local function _FarmModeKey(farmId, mode)
    return tostring(_NormFarmId(farmId) or "") .. "" .. _NormModeName(mode)
  end

  -- Re-link older history sessions when their visible name exactly matches a live farm.
  for i = 1, #h.sessions do
    local s = h.sessions[i]
    if type(s) == "table" and not s.farmId then
      local name = tostring(s._wfCustomFarmName or s.farmName or "")
      name = name:gsub("^%s+", ""):gsub("%s+$", "")
      if name ~= "" then
        local fid, farm = self:FindFarmByName(name, s.mapId)
        if fid and farm then
          s.farmId = _NormFarmId(fid)
          if farm.name and farm.name ~= "" then
            s.farmName = farm.name
          end
        end
      end
    elseif type(s) == "table" and s.farmId then
      s.farmId = _NormFarmId(s.farmId)
    end
  end

  local hasTotals = (next(db.farmStats) ~= nil)

  local visibleCounts = {}
  for i = 1, #h.sessions do
    local s = h.sessions[i]
    if type(s) == "table" and s.farmId then
      local key = _FarmModeKey(s.farmId, s.mode)
      visibleCounts[key] = (tonumber(visibleCounts[key]) or 0) + 1
    end
  end

  for i = 1, #h.sessions do
    local s = h.sessions[i]
    if type(s) == "table" and s.farmId then
      local farmId = _NormFarmId(s.farmId)
      local mode = _NormModeName(s.mode)
      local sid = self:EnsureSessionId(s)
      local elapsed, value, gold, kills = _WFSessionNumbers(s)

      if sid and sid ~= "" then
        local prev = db.farmStatsBySid[sid]
        if type(prev) == "table" then
          prev.farmId = _NormFarmId(prev.farmId or farmId)
          prev.mode = _NormModeName(prev.mode or mode)
          if prev.time == nil then prev.time = elapsed end
          if prev.value == nil then prev.value = value end
          if prev.gold == nil then prev.gold = gold end
          if prev.kills == nil then prev.kills = kills end
        elseif not hasTotals then
          self:AccumulateFarmTotals(farmId, elapsed, value, gold, kills, mode)
          db.farmStatsBySid[sid] = { farmId = farmId, mode = mode, time = elapsed, value = value, gold = gold, kills = kills }
        else
          local fs = db.farmStats[farmId]
          local bucket = fs and fs.modes and fs.modes[mode] or nil
          local bucketSessions = tonumber(bucket and bucket.sessions or 0) or 0
          local visibleCount = tonumber(visibleCounts[_FarmModeKey(farmId, mode)] or 0) or 0

          if visibleCount > 0 and bucketSessions >= visibleCount then
            -- Already represented in farm totals; restore the missing sid link only.
            db.farmStatsBySid[sid] = { farmId = farmId, mode = mode, time = elapsed, value = value, gold = gold, kills = kills }
          else
            -- Missing from farm totals entirely; ingest now.
            self:IngestSessionToFarmStats(s, farmId)
          end
        end
      end
    end
  end
end

function WF.DB:EnsureSessionId(sess)
  if not sess then return nil end
  if sess._wfSid and tostring(sess._wfSid) ~= "" then
    return tostring(sess._wfSid)
  end

  local db = self._db or {}
  db._wfSidCounter = (db._wfSidCounter or 0) + 1
  local c = db._wfSidCounter

  local a = tonumber(sess.startedAt or sess.startServerTime or sess.startTime) or time()
  local b = tonumber(sess.endedAt or sess.endServerTime) or 0
  local z = tostring(sess.zone or "")
  local n = tostring(sess.farmName or sess._wfCustomFarmName or "")
  local base = tostring(a) .. ":" .. tostring(b) .. ":" .. z .. ":" .. n .. ":" .. tostring(c)
  base = base:gsub("[^%w]+", "-")
  base = base:gsub("%-+", "-")
  base = base:gsub("^%-", ""):gsub("%-$", "")
  sess._wfSid = base
  return base
end

function WF.DB:FindFarmByName(name, mapId)
  local db = self._db
  if not db or not db.farms then return nil end
  local want = _WFNormName(name)
  if want == "" then return nil end

  local bestId = nil
  local best = nil
  local bestScore = -1
  for id, farm in pairs(db.farms) do
    if type(farm) == "table" then
      local fn = _WFNormName(farm.name)
      if fn ~= "" and fn == want then
        local score = 0
        if not farm.archived then score = score + 10 end
        if mapId and farm.mapId and tonumber(farm.mapId) == tonumber(mapId) then
          score = score + 5
        end
        if score > bestScore then
          bestScore = score
          bestId = id
          best = farm
        end
      end
    end
  end

  if best and bestId then
    return bestId, best
  end
  return nil
end

local function _Clamp0(n)
  n = tonumber(n) or 0
  if n < 0 then return 0 end
  return n
end

function WF.DB:_ApplyFarmStatsDelta(farmId, dt, dv, dg, dk, ds)
  if not farmId then return end
  farmId = tonumber(farmId) or farmId
  local db = self._db
  if not db then return end
  db.farmStats = db.farmStats or {}
  local fs = db.farmStats[farmId]
  if not fs then
    fs = { time = 0, value = 0, gold = 0, kills = 0, sessions = 0 }
    db.farmStats[farmId] = fs
  end
  fs.time = _Clamp0((fs.time or 0) + (dt or 0))
  fs.value = _Clamp0((fs.value or 0) + (dv or 0))
  fs.gold = _Clamp0((fs.gold or 0) + (dg or 0))
  fs.kills = _Clamp0((fs.kills or 0) + (dk or 0))
  fs.sessions = _Clamp0((fs.sessions or 0) + (ds or 0))
end

function WF.DB:_ApplyFarmStatsDeltaMode(farmId, mode, dt, dv, dg, dk, ds)
  if not farmId then return end
  farmId = tonumber(farmId) or farmId
  local db = self._db
  if not db then return end

  mode = tostring(mode or "")
  if mode == "" then mode = BASE_PROFILE_NAME end

  -- Apply to overall farm totals first.
  self:_ApplyFarmStatsDelta(farmId, dt, dv, dg, dk, ds)

  -- Apply to per-mode bucket.
  db.farmStats = db.farmStats or {}
  local fs = db.farmStats[farmId]
  if not fs then
    fs = { time = 0, value = 0, gold = 0, kills = 0, sessions = 0, modes = {} }
    db.farmStats[farmId] = fs
  end
  fs.modes = fs.modes or {}

  local b = fs.modes[mode]
  if not b then
    b = { time = 0, value = 0, gold = 0, kills = 0, sessions = 0 }
    fs.modes[mode] = b
  end

  b.time = _Clamp0((b.time or 0) + (dt or 0))
  b.value = _Clamp0((b.value or 0) + (dv or 0))
  b.gold = _Clamp0((b.gold or 0) + (dg or 0))
  b.kills = _Clamp0((b.kills or 0) + (dk or 0))
  b.sessions = _Clamp0((b.sessions or 0) + (ds or 0))
end

function WF.DB:IngestSessionToFarmStats(sess, farmId)
  if not sess or not farmId then return false end
  farmId = tonumber(farmId) or farmId
  local db = self._db
  if not db then return false end
  db.farmStatsBySid = db.farmStatsBySid or {}

  local sid = self:EnsureSessionId(sess)
  if not sid or sid == "" then return false end

  local elapsed, value, gold, kills = _WFSessionNumbers(sess)
  local mode = tostring(sess.mode or BASE_PROFILE_NAME)
  if mode == "" then mode = BASE_PROFILE_NAME end

  local prev = db.farmStatsBySid[sid]
  local prevFarm = prev and (tonumber(prev.farmId) or prev.farmId) or nil
  local prevMode = prev and tostring(prev.mode or BASE_PROFILE_NAME) or BASE_PROFILE_NAME
  if prevMode == "" then prevMode = BASE_PROFILE_NAME end

  if prevFarm and tonumber(prevFarm) == tonumber(farmId) and tostring(prevMode) == tostring(mode) then
    -- Already counted into this farm+mode.
    sess.farmId = farmId
    return false
  end

  -- If previously counted, remove its contribution first (supports moving farms and/or modes).
  if prevFarm then
    self:_ApplyFarmStatsDeltaMode(prevFarm, prevMode, -(prev.time or 0), -(prev.value or 0), -(prev.gold or 0), -(prev.kills or 0), -1)
  end

  self:_ApplyFarmStatsDeltaMode(farmId, mode, elapsed, value, gold, kills, 1)
  db.farmStatsBySid[sid] = { farmId = farmId, mode = mode, time = elapsed, value = value, gold = gold, kills = kills }
  sess.farmId = farmId
  return true
end

function WF.DB:TryLinkSessionToFarmByName(sess)
  if not sess then return false end
  local name = tostring(sess._wfCustomFarmName or sess.farmName or "")
  name = name:gsub("^%s+", ""):gsub("%s+$", "")
  if name == "" then return false end

  local fid, farm = self:FindFarmByName(name, sess.mapId)
  if not fid or not farm then return false end

  -- Keep names aligned to the saved farm record.
  sess.farmId = fid
  if farm.name and farm.name ~= "" then
    sess.farmName = farm.name
  end

  self:IngestSessionToFarmStats(sess, fid)
  return true
end



function WF.DB:IsSessionCounted(sess)
  if not sess then return false end

  -- Rebuild the session->farm stats map when older data has totals but no sid map yet.
  self:EnsureFarmStats()

  local db = self._db
  if not db then return false end
  db.farmStatsBySid = db.farmStatsBySid or {}

  local sid = self:EnsureSessionId(sess)
  if not sid or sid == "" then return false end
  return db.farmStatsBySid[sid] ~= nil
end

-- Optional destructive action: remove this session's contribution from persistent farm averages.
-- Note: This only works while the session still exists (i.e., before auto-prune removes it).
function WF.DB:RemoveSessionFromFarmStats(sess)
  if not sess then return false end

  -- Make sure the sid map exists before we try to remove a visible run from averages.
  self:EnsureFarmStats()

  local db = self._db
  if not db then return false end
  db.farmStatsBySid = db.farmStatsBySid or {}

  local sid = self:EnsureSessionId(sess)
  if not sid or sid == "" then return false end

  local prev = db.farmStatsBySid[sid]
  if not prev then return false end

  local farmId = tonumber(prev.farmId) or prev.farmId
  local mode = tostring(prev.mode or BASE_PROFILE_NAME)
  if mode == "" then mode = BASE_PROFILE_NAME end

  self:_ApplyFarmStatsDeltaMode(
    farmId,
    mode,
    -(prev.time or 0),
    -(prev.value or 0),
    -(prev.gold or 0),
    -(prev.kills or 0),
    -1
  )

  db.farmStatsBySid[sid] = nil
  return true
end
function WF.DB:Get()
  return self._db
end

function WF.DB:GetSetting(key)
  local db = self._db
  if not db or not db.settings then
    return nil
  end
  return db.settings[key]
end

function WF.DB:SetSetting(key, value)
  local db = self._db
  if not db then
    return
  end
  db.settings = db.settings or {}
  db.settings[key] = value

  -- If this is a profile-managed setting, keep the active profile in sync so
  -- profile tweaks persist without requiring an explicit "save".
  if PROFILE_KEYSET[key] then
    self:EnsureProfiles()
    local p = db.profiles
    local list = p and p.list
    local active = p and p.active
    if list and active and list[active] then
      list[active][key] = value
    end
  end
end

function WF.DB:NextFarmId()
  local db = self._db
  local id = db._nextFarmId or 1
  db._nextFarmId = id + 1
  return id
end

function WF.DB:GetFarm(id)
  local db = self._db
  if not db or not db.farms then
    return nil
  end
  local key = tonumber(id) or id
  return db.farms[key] or db.farms[id]
end


function WF.DB:FindFarmNear(mapId, x, y, tol)
  if not mapId or not x or not y then
    return nil
  end
  local db = self._db
  local farms = db.farms or {}
  tol = tonumber(tol) or tonumber(self:GetSetting("farmDedupTolerance")) or 0.003

  for id, farm in pairs(farms) do
    if farm and farm.mapId == mapId and farm.x and farm.y then
      local dx = math.abs(farm.x - x)
      local dy = math.abs(farm.y - y)
      if dx <= tol and dy <= tol then
        return id, farm
      end
    end
  end

  return nil
end

function WF.DB:UpsertFarm(record)
  local db = self._db
  db.farms = db.farms or {}

  local existingId = nil
  if record.mapId and record.x and record.y then
    existingId = self:FindFarmNear(record.mapId, record.x, record.y, self:GetSetting("farmDedupTolerance"))
  end

  if existingId then
    local farm = db.farms[existingId]
    for k, v in pairs(record) do
      farm[k] = v
    end
    farm.updatedAt = time()
    return existingId, farm
  end

  local id = self:NextFarmId()
  record.id = id
  record.createdAt = record.createdAt or time()
  db.farms[id] = record
  return id, record
end

function WF.DB:AddSession(sessionRecord)
  local db = self._db
  local h = db.history
  h.sessions = h.sessions or {}

  table.insert(h.sessions, sessionRecord)

  local cap = h.cap or 200
  while #h.sessions > cap do
    table.remove(h.sessions, 1)
  end
end

function WF.DB:AccumulateFarmTotals(farmId, elapsedSec, valueCopper, goldCopper, kills, mode)
  if not farmId then
    return
  end
  farmId = tonumber(farmId) or farmId

  -- Backward compatible: older calls were (farmId, elapsed, value, kills)
  if kills == nil then
    kills = goldCopper
    goldCopper = 0
  end

  local db = self._db
  local h = db.history
  h.totalsByFarm = h.totalsByFarm or {}

  local t = h.totalsByFarm[farmId]
  if not t then
    t = { time = 0, value = 0, kills = 0, sessions = 0 }
    h.totalsByFarm[farmId] = t
  end

  t.time = (t.time or 0) + (elapsedSec or 0)
  t.value = (t.value or 0) + (valueCopper or 0)
  t.kills = (t.kills or 0) + (kills or 0)
  t.sessions = (t.sessions or 0) + 1

  -- Persistent farmStats (used by Farms averages even if history is cleared).
  db.farmStats = db.farmStats or {}
  local fs = db.farmStats[farmId]
  if not fs then
    fs = { time = 0, value = 0, gold = 0, kills = 0, sessions = 0 }
    db.farmStats[farmId] = fs
  end
  fs.time = (fs.time or 0) + (elapsedSec or 0)
  fs.value = (fs.value or 0) + (valueCopper or 0)
  fs.gold = (fs.gold or 0) + (goldCopper or 0)
  fs.kills = (fs.kills or 0) + (kills or 0)
  fs.sessions = (fs.sessions or 0) + 1

  -- Per-mode bucket (Mode = active profile name)
  mode = tostring(mode or BASE_PROFILE_NAME)
  if mode == "" then mode = BASE_PROFILE_NAME end
  fs.modes = fs.modes or {}
  local b = fs.modes[mode]
  if not b then
    b = { time = 0, value = 0, gold = 0, kills = 0, sessions = 0 }
    fs.modes[mode] = b
  end
  b.time = (b.time or 0) + (elapsedSec or 0)
  b.value = (b.value or 0) + (valueCopper or 0)
  b.gold = (b.gold or 0) + (goldCopper or 0)
  b.kills = (b.kills or 0) + (kills or 0)
  b.sessions = (b.sessions or 0) + 1
end