-- SimWishlist.lua v3.4
-- Per-toon upgrade wishlist viewer. Boss tooltips, instance/boss grouping,
-- Upgrades by Slot, in-game import box, resizable window.

local ADDON_NAME = ...


local VERSION = "3.4"

-- ============================================================
-- Instance name → content type (matches GetInstanceInfo())
-- ============================================================
local INSTANCE_TO_TYPE = {
  -- Midnight Season 1 dungeons
  ["Windrunner Spire"]         = "DUNGEON",
  ["Magisters' Terrace"]       = "DUNGEON",
  ["Maisara Caverns"]          = "DUNGEON",
  ["Nexus-Point Xenas"]        = "DUNGEON",
  -- Legacy dungeons in rotation
  ["Algeth'ar Academy"]        = "DUNGEON",
  ["Pit of Saron"]             = "DUNGEON",
  ["Seat of the Triumvirate"]  = "DUNGEON",
  ["Skyreach"]                 = "DUNGEON",
  -- Midnight Season 1 raids (all three)
  ["The Voidspire"]            = "RAID",
  ["The Dreamrift"]            = "RAID",
  ["March on Quel'Danas"]      = "RAID",
}

-- Slot display order for "Upgrades by Slot" tab
local SLOT_ORDER = {
  "Head","Neck","Shoulder","Back","Chest","Wrist",
  "Hands","Waist","Legs","Feet","Finger","Trinket",
  "1H Weapon","Shield","Off-hand","2H Weapon","Ranged",
}

local TABS = { "DUNGEON", "RAID", "CRAFTED", "DELVES", "BY SLOT" }
local TAB_LABELS = {
  DUNGEON="Dungeon", RAID="Raid", CRAFTED="Crafted",
  DELVES="Delves", ["BY SLOT"]="By Slot",
}

local DEFAULT_SETTINGS = {
  sortMode   = "upgrade",  -- "upgrade" | "ilvl" | "name"
  minUpgrade = 0.01,
  windowPos  = { point="CENTER", x=0, y=0 },
  windowSize = { w=780, h=560 },
  lastTab    = "DUNGEON",
  colBoss    = 210,  -- boss column width
  colIlvl    = 42,   -- ilvl column width
  colUpg     = 72,   -- upgrade column width
}

-- ============================================================
-- State
-- ============================================================
local frame          = nil
local tabButtons     = {}
local rowPool        = {}
local hdrPool        = {}
local currentTab     = "DUNGEON"
local manualFilter   = nil   -- nil=auto, false=all, string=locked source
local settings       = nil
local importFrame    = nil
local collapsedGroups = {}  -- [tabKey..source] = true if collapsed

-- ============================================================
-- Per-toon helpers
-- ============================================================
local function CharKey()
  local name  = UnitName("player") or "Unknown"
  local realm = GetRealmName() or "Unknown"
  return name .. "-" .. realm
end

local function GetCharData()
  local key = CharKey()
  SimWishlistDB = SimWishlistDB or {}
  SimWishlistDB[key] = SimWishlistDB[key] or { items={} }
  return SimWishlistDB[key]
end

local function SetCharData(data)
  SimWishlistDB = SimWishlistDB or {}
  SimWishlistDB[CharKey()] = data
end

-- ============================================================
-- Settings (per-account in SavedVariablesPerCharacter)
-- ============================================================
local function LoadSettings()
  SimWishlistCharDB = SimWishlistCharDB or {}
  settings = SimWishlistCharDB.settings or {}
  for k, v in pairs(DEFAULT_SETTINGS) do
    if settings[k] == nil then
      if type(v) == "table" then
        settings[k] = {}
        for k2,v2 in pairs(v) do settings[k][k2] = v2 end
      else
        settings[k] = v
      end
    end
  end
  SimWishlistCharDB.settings = settings
end

local function SaveSettings()
  if SimWishlistCharDB then
    SimWishlistCharDB.settings = settings
  end
end

-- ============================================================
-- LootDB lookup
-- ============================================================
local INVTYPE_TO_SLOT = {
  INVTYPE_HEAD          = "Head",
  INVTYPE_NECK          = "Neck",
  INVTYPE_SHOULDER      = "Shoulder",
  INVTYPE_CLOAK         = "Back",
  INVTYPE_CHEST         = "Chest",
  INVTYPE_ROBE          = "Chest",
  INVTYPE_WRIST         = "Wrist",
  INVTYPE_HAND          = "Hands",
  INVTYPE_WAIST         = "Waist",
  INVTYPE_LEGS          = "Legs",
  INVTYPE_FEET          = "Feet",
  INVTYPE_FINGER        = "Finger",
  INVTYPE_TRINKET       = "Trinket",
  INVTYPE_WEAPON        = "1H Weapon",
  INVTYPE_WEAPONMAINHAND= "1H Weapon",
  INVTYPE_WEAPONOFFHAND = "1H Weapon",
  INVTYPE_SHIELD        = "Shield",
  INVTYPE_HOLDABLE      = "Off-hand",
  INVTYPE_2HWEAPON      = "2H Weapon",
  INVTYPE_RANGED        = "Ranged",
  INVTYPE_RANGEDRIGHT   = "Ranged",
  INVTYPE_THROWN        = "Ranged",
}

local function GetItemSlot(itemID)
  local _, _, _, _, _, _, _, _, invType = GetItemInfo(itemID)
  if invType and invType ~= "" then
    return INVTYPE_TO_SLOT[invType] or invType
  end
  return nil
end

local function GetLootInfo(itemID)
  if SimWishlistLootDB and itemID then
    return SimWishlistLootDB[tonumber(itemID)]
  end
  return nil
end

-- ============================================================
-- Instance detection
-- ============================================================
local function GetCurrentInstance()
  local name, _, instanceType = GetInstanceInfo()
  if not name or name == "" or instanceType == "none" then
    return nil, nil
  end
  local mapped = INSTANCE_TO_TYPE[name]
  if mapped then return name, mapped end
  if instanceType == "raid"  then return name, "RAID"    end
  if instanceType == "party" then return name, "DUNGEON" end
  return nil, nil
end

-- ============================================================
-- Data helpers
-- ============================================================
local function GetItemsForTab(tabKey)
  local data = GetCharData()
  if not data.items then return {} end
  local out = {}
  for _, item in ipairs(data.items) do
    local loot = GetLootInfo(item.itemID)
    local loc  = (item.dropLoc or ""):upper()
    if tabKey == "BY SLOT" then
      -- always included
      out[#out+1] = item
    elseif tabKey == "DUNGEON" and loc == "DUNGEON" then
      out[#out+1] = item
    elseif tabKey == "RAID" and loc == "RAID" then
      out[#out+1] = item
    elseif tabKey == "CRAFTED" and loc == "CRAFTED" then
      out[#out+1] = item
    elseif tabKey == "DELVES" and loc == "DELVES" then
      out[#out+1] = item
    end
  end
  return out
end

local function FilterBySource(items, sourceFilter)
  if not sourceFilter then return items end
  local out = {}
  for _, item in ipairs(items) do
    local loot = GetLootInfo(item.itemID)
    local src  = (loot and loot.instance) or (item.dropLoc or "Unknown")
    if src == sourceFilter then out[#out+1] = item end
  end
  return out
end

local function FilterByMinUpgrade(items)
  local min = settings.minUpgrade or 0
  if min <= 0 then return items end
  local out = {}
  for _, item in ipairs(items) do
    if (item.upgrade or 0) >= min then out[#out+1] = item end
  end
  return out
end

-- ============================================================
-- Tier Set exclusion (by item name substring)
-- ============================================================
local TIER_SET_KEYWORDS = {
  "Relentless Rider's",    -- DK
  "Devouring Reaver's",    -- DH
  "Luminous Bloom",         -- Druid
  "Black Talon",            -- Evoker
  "Primal Sentry's",       -- Hunter
  "Voidbreaker's",          -- Mage
  "Ra-den's Chosen",       -- Monk
  "Luminant Verdict's",    -- Paladin
  "Blind Oath's",           -- Priest
  "Grim Jest",              -- Rogue
  "Primal Core",            -- Shaman
  "Abyssal Immolator",     -- Warlock
  "Night Ender",            -- Warrior
}

local function IsTierSetByName(itemID)
  local name = GetItemInfo(itemID)
  if not name then return false end
  for _, keyword in ipairs(TIER_SET_KEYWORDS) do
    if name:find(keyword, 1, true) then return true end
  end
  return false
end

local function FilterOutTierSets(items)
  local out = {}
  for _, item in ipairs(items) do
    if not IsTierSetByName(item.itemID) then
      out[#out+1] = item
    end
  end
  return out
end

local function SortItems(items)
  local mode = settings.sortMode or "upgrade"
  table.sort(items, function(a, b)
    if mode == "ilvl" then
      if a.ilvl ~= b.ilvl then return (a.ilvl or 0) > (b.ilvl or 0) end
    elseif mode == "name" then
      local an = GetItemInfo(a.itemID) or ("Item#"..tostring(a.itemID))
      local bn = GetItemInfo(b.itemID) or ("Item#"..tostring(b.itemID))
      if an ~= bn then return an < bn end
    end
    -- default / tiebreak: upgrade desc
    return (a.upgrade or 0) > (b.upgrade or 0)
  end)
end

local function GroupBySource(items)
  -- Returns ordered list of { source, boss?, items[] }
  local groups = {}
  local map    = {}
  for _, item in ipairs(items) do
    local loot = GetLootInfo(item.itemID)
    local src  = (loot and loot.instance) or (item.dropLoc or "Unknown")
    if not map[src] then
      map[src] = { source=src, items={} }
      groups[#groups+1] = map[src]
    end
    map[src].items[#map[src].items+1] = item
  end
  -- Sort groups by best upgrade in group desc
  table.sort(groups, function(a, b)
    local ab, bb = 0, 0
    for _, i in ipairs(a.items) do if (i.upgrade or 0) > ab then ab = i.upgrade end end
    for _, i in ipairs(b.items) do if (i.upgrade or 0) > bb then bb = i.upgrade end end
    return ab > bb
  end)
  return groups
end


local function GroupByBoss(items)
  -- Returns ordered list of { boss, items[] }
  local groups = {}
  local map    = {}
  for _, item in ipairs(items) do
    local loot = GetLootInfo(item.itemID)
    local boss = (loot and loot.boss) or "Uncategorized"
    if not map[boss] then
      map[boss] = { boss=boss, items={} }
      groups[#groups+1] = map[boss]
    end
    map[boss].items[#map[boss].items+1] = item
  end
  table.sort(groups, function(a, b)
    if a.boss == "Uncategorized" then return false end
    if b.boss == "Uncategorized" then return true end
    local ab, bb = 0, 0
    for _, i in ipairs(a.items) do if (i.upgrade or 0) > ab then ab = i.upgrade end end
    for _, i in ipairs(b.items) do if (i.upgrade or 0) > bb then bb = i.upgrade end end
    return ab > bb
  end)
  return groups
end

local function GroupBySlot(items)
  local groups = {}
  local map    = {}
  for _, item in ipairs(items) do
    local loot = GetLootInfo(item.itemID)
    local slot = GetItemSlot(item.itemID) or (loot and loot.slot) or "Other"
    if not map[slot] then
      map[slot] = { source=slot, items={} }
      groups[#groups+1] = map[slot]
    end
    map[slot].items[#map[slot].items+1] = item
  end
  -- Sort by SLOT_ORDER
  local order = {}
  for i, s in ipairs(SLOT_ORDER) do order[s] = i end
  table.sort(groups, function(a, b)
    local ai = order[a.source] or 99
    local bi = order[b.source] or 99
    return ai < bi
  end)
  return groups
end

-- ============================================================
-- Formatting
-- ============================================================
local function FmtUpgrade(val)
  val = tonumber(val) or 0
  if val == 0 then return "|cff999999+0.00%|r" end
  local color = val >= 2.0 and "44ff88" or val >= 1.0 and "ffee00" or "ffffff"
  return string.format("|cff%s+%.2f%%|r", color, val)
end

-- ============================================================
-- BiS (Best in Slot) computation
-- ============================================================
-- Maps slot name → itemID of the highest-upgrade item for that slot.
-- Computed across ALL imported items regardless of tab/filter.
local bisMap = {}  -- { [slotName] = itemID }

local function ComputeBiSMap()
  bisMap = {}
  local data = GetCharData()
  if not data.items then return end

  local bestBySlot = {}  -- { [slot] = { itemID, upgrade } }
  for _, item in ipairs(data.items) do
    if not IsTierSetByName(item.itemID) then
      local slot = GetItemSlot(item.itemID)
      if slot then
        local upg = item.upgrade or 0
        if upg > 0 and (not bestBySlot[slot] or upg > bestBySlot[slot].upgrade) then
          bestBySlot[slot] = { itemID = item.itemID, upgrade = upg }
        end
      end
    end
  end

  for slot, info in pairs(bestBySlot) do
    bisMap[info.itemID] = true
  end
end

local function IsBiS(itemID)
  return bisMap[itemID] == true
end

local BIS_TAG = " |cff00ff00[BiS]|r"

-- ============================================================
-- Import parsing
-- ============================================================
-- Accepts SWL! encoded strings or raw JSON and stores results
-- into the current toon's data.

local importCallbacks = {}

local function ParseAndStoreJSON(jsonStr)
  -- Parses the SimWishlist JSON format
  local ok, result = pcall(function()
    local items = {}
    for entry in jsonStr:gmatch('%{[^{}]+%}') do
      local itemID   = tonumber(entry:match('"item"%s*:%s*(%d+)'))
      local dropLoc  = entry:match('"dropLoc"%s*:%s*"([^"]+)"')
      local level    = tonumber(entry:match('"level"%s*:%s*(%d+)'))
      local percDiff = tonumber(entry:match('"percDiff"%s*:%s*([%d%.%-]+)'))
      if itemID and dropLoc then
        items[#items+1] = {
          itemID   = itemID,
          dropLoc  = dropLoc:upper(),
          ilvl     = level or 0,
          upgrade  = percDiff or 0,
        }
      end
    end
    local spec      = jsonStr:match('"spec"%s*:%s*"([^"]+)"')
    local player    = jsonStr:match('"playername"%s*:%s*"([^"]+)"')
    local realm     = jsonStr:match('"realm"%s*:%s*"([^"]+)"')
    local reportId  = jsonStr:match('"id"%s*:%s*"([^"]+)"')
    return { items=items, spec=spec, player=player, realm=realm, reportId=reportId }
  end)
  if not ok or not result then return false, "Parse failed" end
  if #result.items == 0 then return false, "No items found in response" end

  local charData = {
    items      = result.items,
    spec       = result.spec,
    player     = result.player,
    realm      = result.realm,
    reportId   = result.reportId,
    source     = "SimWishlist",
    importTime = time(),
  }
  SetCharData(charData)
  return true, #result.items
end

local function Base64Decode(data)
  local lookup = {}
  local chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
  for i = 1, #chars do
    lookup[chars:sub(i,i)] = i - 1
  end
  -- Strip non-base64 chars
  local cleaned = data:gsub('[^ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=]', '')
  local result = {}
  local i = 1
  while i <= #cleaned do
    local c1 = lookup[cleaned:sub(i,i)]   or 0
    local c2 = lookup[cleaned:sub(i+1,i+1)] or 0
    local c3 = lookup[cleaned:sub(i+2,i+2)]
    local c4 = lookup[cleaned:sub(i+3,i+3)]
    result[#result+1] = string.char(c1 * 4 + math.floor(c2 / 16))
    if cleaned:sub(i+2,i+2) ~= '=' and c3 then
      result[#result+1] = string.char((c2 % 16) * 16 + math.floor(c3 / 4))
    end
    if cleaned:sub(i+3,i+3) ~= '=' and c4 then
      result[#result+1] = string.char((c3 % 4) * 64 + c4)
    end
    i = i + 4
  end
  return table.concat(result)
end

local function ParseSWLString(input)
  local encoded = input:match('^SWL!(.+)$')
  if not encoded then return false, 'Not a SWL! string' end
  local ok, csv = pcall(Base64Decode, encoded)
  if not ok or not csv then return false, 'Decode failed' end
  local items = {}
  local meta = {}
  for line in csv:gmatch('[^\n]+') do
    local parts = {}
    for p in (line .. '|'):gmatch('([^|]*)|') do
      parts[#parts+1] = p
    end
    if parts[1] == 'H' then
      meta.reportId = parts[2]
      meta.spec     = parts[3]
      meta.player   = parts[4]
      meta.realm    = parts[5]
      meta.source   = parts[6] or 'SimWishlist'
    elseif parts[1] == 'I' then
      local itemID = tonumber(parts[2])
      if itemID then
        items[#items+1] = {
          itemID  = itemID,
          dropLoc = parts[3] or 'DUNGEON',
          ilvl    = tonumber(parts[4]) or 0,
          upgrade = tonumber(parts[5]) or 0,
        }
      end
    end
  end
  if #items == 0 then return false, 'No items in SWL string' end
  SetCharData({
    items      = items,
    spec       = meta.spec,
    player     = meta.player,
    realm      = meta.realm,
    reportId   = meta.reportId,
    source     = meta.source or 'SimWishlist',
    importTime = time(),
  })
  return true, #items
end

local function DoImport(input)
  input = strtrim(input or '')
  if input == '' then return false, 'Empty input' end

  -- SWL! encoded string
  if input:sub(1,4) == 'SWL!' then
    return ParseSWLString(input)
  end

  -- Strip outer quotes if JSON was wrapped in a quoted string
  if input:sub(1,1) == '"' then
    input = input:sub(2)
    if input:sub(-1) == '"' then input = input:sub(1,-2) end
    input = input:gsub('\\"', '"')
  end

  -- Raw JSON
  if input:sub(1,1) == '{' then
    return ParseAndStoreJSON(input)
  end

  return false, 'Expected SWL! string or JSON. Got: ' .. input:sub(1,40)
end


-- ============================================================
-- Import frame
-- ============================================================
local function CreateImportFrame()
  if importFrame then
    importFrame.editBox:SetText("")
    importFrame.statusLbl:SetText("")
    importFrame:Show()
    return
  end

  local f = CreateFrame("Frame", "SimWishlistImportFrame", UIParent, "BackdropTemplate")
  f:SetSize(540, 280)
  f:SetPoint("CENTER")
  f:SetMovable(true)
  f:EnableMouse(true)
  f:RegisterForDrag("LeftButton")
  f:SetScript("OnDragStart", f.StartMoving)
  f:SetScript("OnDragStop", f.StopMovingOrSizing)
  f:SetClampedToScreen(true)
  f:SetFrameStrata("DIALOG")

  f:SetBackdrop({
    bgFile="Interface/Tooltips/UI-Tooltip-Background",
    edgeFile="Interface/Tooltips/UI-Tooltip-Border",
    tile=true, tileSize=16, edgeSize=16,
    insets={left=4,right=4,top=4,bottom=4}
  })
  f:SetBackdropColor(0.05, 0.05, 0.12, 0.98)
  f:SetBackdropBorderColor(0.5, 0.35, 0.8, 1)

  local title = f:CreateFontString(nil,"OVERLAY","GameFontNormal")
  title:SetPoint("TOP", f, "TOP", 0, -12)
  title:SetText("|cffcc88ffSim Wishlist — Import|r")

  local close = CreateFrame("Button",nil,f,"UIPanelCloseButton")
  close:SetPoint("TOPRIGHT",f,"TOPRIGHT",2,2)
  close:SetScript("OnClick", function() f:Hide() end)

  local instructions = f:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
  instructions:SetPoint("TOPLEFT",f,"TOPLEFT",12,-34)
  instructions:SetPoint("TOPRIGHT",f,"TOPRIGHT",-12,-34)
  instructions:SetJustifyH("LEFT")
  instructions:SetWordWrap(true)
  instructions:SetText(
    "|cffffff88Paste one of the following:|r\n" ..
    "  • SWL! string from the SimWishlist fetcher\n" ..
    "  • Raw JSON from QE Live\n\n" ..
    "|cffaaaaaa" .. CharKey() .. "|r"
  )

  local editBox = CreateFrame("EditBox", nil, f, "InputBoxTemplate")
  editBox:SetPoint("TOPLEFT",  f, "TOPLEFT",  16, -110)
  editBox:SetPoint("TOPRIGHT", f, "TOPRIGHT", -16, -110)
  editBox:SetHeight(80)
  editBox:SetMultiLine(true)
  editBox:SetAutoFocus(false)
  editBox:SetFontObject("ChatFontNormal")
  editBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
  f.editBox = editBox

  local scrollFrame = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
  scrollFrame:SetPoint("TOPLEFT",  f, "TOPLEFT",  16, -110)
  scrollFrame:SetPoint("TOPRIGHT", f, "TOPRIGHT", -30, -110)
  scrollFrame:SetHeight(80)
  scrollFrame:SetScrollChild(editBox)
  editBox:SetWidth(490)

  local statusLbl = f:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
  statusLbl:SetPoint("BOTTOMLEFT", f,"BOTTOMLEFT", 16, 44)
  statusLbl:SetPoint("BOTTOMRIGHT",f,"BOTTOMRIGHT",-120, 44)
  statusLbl:SetJustifyH("LEFT")
  statusLbl:SetText("")
  f.statusLbl = statusLbl

  local importBtn = CreateFrame("Button",nil,f,"UIPanelButtonTemplate")
  importBtn:SetSize(100, 24)
  importBtn:SetPoint("BOTTOMRIGHT",f,"BOTTOMRIGHT",-12,12)
  importBtn:SetText("Import")
  importBtn:SetScript("OnClick", function()
    local input = strtrim(editBox:GetText() or "")
    if input == "" then
      statusLbl:SetText("|cffff8888Paste a URL or JSON first.|r")
      return
    end
    local ok, result = DoImport(input)
    if ok then
      statusLbl:SetText("|cff44ff88Imported " .. tostring(result) .. " items for " .. CharKey() .. ".|r")
      editBox:SetText("")
      if frame and frame:IsShown() then SimWishlist_Refresh() end
    else
      statusLbl:SetText("|cffff8888Error: " .. tostring(result) .. "|r")
    end
  end)

  local clearBtn = CreateFrame("Button",nil,f,"UIPanelButtonTemplate")
  clearBtn:SetSize(80, 24)
  clearBtn:SetPoint("RIGHT", importBtn, "LEFT", -8, 0)
  clearBtn:SetText("Clear Data")
  clearBtn:SetScript("OnClick", function()
    SetCharData({ items={} })
    statusLbl:SetText("|cffaaaaaa Data cleared for " .. CharKey() .. ".|r")
    if frame and frame:IsShown() then SimWishlist_Refresh() end
  end)

  importFrame = f
  f:Show()
end

-- ============================================================
-- Main frame construction
-- ============================================================
local ROW_H  = 22
local HDR_H  = 22
local PAD    = 3
local HDR_GAP = 10  -- extra space above each section header

-- ============================================================
-- Column layout
-- ============================================================
function SimWishlist_LayoutColumns()
  if not frame or not settings then return end
  local cW = frame.colBar and frame.colBar:GetWidth() or (frame:GetWidth() - 36)
  if not cW or cW <= 0 then return end
  local colUpg  = settings.colUpg  or 58
  local colIlvl = settings.colIlvl or 36
  local colBoss = settings.colBoss or 140

  if frame.colUpgLbl then
    frame.colUpgLbl:ClearAllPoints()
    frame.colUpgLbl:SetPoint("RIGHT", frame.colBar, "RIGHT", 0, 0)
    frame.colUpgLbl:SetWidth(colUpg)
  end
  if frame.colIlvlLbl then
    frame.colIlvlLbl:ClearAllPoints()
    frame.colIlvlLbl:SetPoint("RIGHT", frame.colBar, "RIGHT", -colUpg, 0)
    frame.colIlvlLbl:SetWidth(colIlvl)
  end
  if frame.colBossLbl then
    frame.colBossLbl:ClearAllPoints()
    frame.colBossLbl:SetPoint("RIGHT", frame.colBar, "RIGHT", -(colUpg + colIlvl), 0)
    frame.colBossLbl:SetWidth(colBoss)
  end
  if frame.colName then
    frame.colName:ClearAllPoints()
    frame.colName:SetPoint("LEFT", frame.colBar, "LEFT", 6, 0)
    frame.colName:SetPoint("RIGHT", frame.colBar, "RIGHT", -(colUpg + colIlvl + colBoss + 4), 0)
  end
  if frame.dragBossIlvl then
    frame.dragBossIlvl:ClearAllPoints()
    frame.dragBossIlvl:SetPoint("RIGHT", frame.colBar, "RIGHT", -(colUpg + colIlvl), 0)
  end
  if frame.dragIlvlUpg then
    frame.dragIlvlUpg:ClearAllPoints()
    frame.dragIlvlUpg:SetPoint("RIGHT", frame.colBar, "RIGHT", -colUpg, 0)
  end
  -- Update existing rows
  for _, row in pairs(rowPool) do
    if row.bossLbl then
      row.bossLbl:ClearAllPoints()
      row.bossLbl:SetWidth(colBoss)
      row.bossLbl:SetPoint("RIGHT", row, "RIGHT", -(colUpg + colIlvl + 4), 0)
    end
    if row.ilvlLbl then
      row.ilvlLbl:ClearAllPoints()
      row.ilvlLbl:SetWidth(colIlvl)
      row.ilvlLbl:SetPoint("RIGHT", row, "RIGHT", -colUpg, 0)
    end
    if row.upgLbl then
      row.upgLbl:ClearAllPoints()
      row.upgLbl:SetWidth(colUpg)
      row.upgLbl:SetPoint("RIGHT", row, "RIGHT", 0, 0)
    end
    if row.nameLbl then
      row.nameLbl:ClearAllPoints()
      row.nameLbl:SetPoint("LEFT", row, "LEFT", 6, 0)
      row.nameLbl:SetPoint("RIGHT", row, "RIGHT", -(colUpg + colIlvl + colBoss + 8), 0)
    end
  end
end

local function NewRow(parent)
  local row = CreateFrame("Button", nil, parent)
  row:SetHeight(ROW_H)

  local bg = row:CreateTexture(nil,"BACKGROUND")
  bg:SetAllPoints(); bg:SetColorTexture(0,0,0,0)
  row.bg = bg

  local dot = row:CreateTexture(nil,"ARTWORK")
  dot:SetSize(3,14); dot:SetPoint("LEFT",row,"LEFT",0,0)
  dot:SetColorTexture(0.5,0.3,0.85,0.7)
  row.dot = dot

  local nameLbl = row:CreateFontString(nil,"OVERLAY","GameFontNormal")  -- Normal = bold
  nameLbl:SetPoint("LEFT",row,"LEFT",6,0)
  nameLbl:SetPoint("RIGHT",row,"RIGHT",-150,0)
  nameLbl:SetJustifyH("LEFT"); nameLbl:SetWordWrap(false)
  row.nameLbl = nameLbl

  local bossLbl = row:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
  bossLbl:SetJustifyH("RIGHT"); bossLbl:SetTextColor(0.65,0.65,0.75,1)
  row.bossLbl = bossLbl

  local ilvlLbl = row:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
  ilvlLbl:SetJustifyH("CENTER")
  row.ilvlLbl = ilvlLbl

  local upgLbl = row:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
  upgLbl:SetJustifyH("RIGHT")
  row.upgLbl = upgLbl

  row:SetScript("OnEnter", function(self)
    self.bg:SetColorTexture(0.20,0.15,0.35,0.6)
    local item = self.itemData
    if not item then return end
    local loot = GetLootInfo(item.itemID)
    GameTooltip:SetOwner(self,"ANCHOR_RIGHT")
    local name, link = GetItemInfo(item.itemID)
    if link then
      GameTooltip:SetHyperlink(link)
      GameTooltip:AddLine(" ")
    else
      GameTooltip:AddLine(name or ("Item #"..tostring(item.itemID)), 1,1,1)
    end
    if loot then
      GameTooltip:AddLine("Location: " .. (loot.instance or "?"), 0.8,0.65,1)
      GameTooltip:AddLine("Source: " .. (loot.boss or "?"), 0.6,0.85,1)
      GameTooltip:AddLine("Slot: " .. (GetItemSlot(item.itemID) or loot.slot or "?"), 0.7,0.7,0.7)
    end
    GameTooltip:AddLine(string.format("Upgrade: +%.2f%%", item.upgrade or 0), 0.4,1,0.5)
    if IsBiS(item.itemID) then
      GameTooltip:AddLine("Best in Slot upgrade", 0,1,0)
    end
    GameTooltip:AddLine("|cffaaaaaa[Click to open Adventure Guide]|r", 0.6,0.6,0.6)
    GameTooltip:Show()
  end)
  row:SetScript("OnLeave", function(self)
    local c = self.altRow and 0.06 or 0
    self.bg:SetColorTexture(c,c,c*2, self.altRow and 0.3 or 0)
    GameTooltip:Hide()
  end)
  row:SetScript("OnClick", function(self, btn)
    if btn ~= "LeftButton" then return end
    local item = self.itemData
    if not item or not item.itemID then return end
    local itemID = item.itemID
    local loot = GetLootInfo(itemID)

    if C_AddOns and C_AddOns.LoadAddOn then
      C_AddOns.LoadAddOn("Blizzard_EncounterJournal")
    elseif LoadAddOn then
      LoadAddOn("Blizzard_EncounterJournal")
    end

    C_Timer.After(0.05, function()
      if not EncounterJournal_OpenJournal then return end

      if loot and loot.instance and loot.boss then
        local targetInst = loot.instance
        local targetBoss = loot.boss
        local journalInstID, journalEncID

        for _, isRaid in ipairs({false, true}) do
          local idx = 1
          while true do
            local instID, instName = EJ_GetInstanceByIndex(idx, isRaid)
            if not instID then break end
            if instName == targetInst then
              EJ_SelectInstance(instID)
              local encIdx = 1
              while true do
                -- Returns: name, description, journalEncounterID, rootSectionID,
                --          link, journalInstanceID, dungeonEncounterID, instanceID
                local eName, _, encID, _, _, jInstID = EJ_GetEncounterInfoByIndex(encIdx)
                if not eName then break end
                if eName == targetBoss then
                  journalInstID = jInstID or instID
                  journalEncID  = encID
                  break
                end
                encIdx = encIdx + 1
              end
              break
            end
            idx = idx + 1
          end
          if journalEncID then break end
        end

        if journalEncID then
          pcall(EncounterJournal_OpenJournal, nil, journalInstID, journalEncID, nil, nil, itemID)
          return
        end
      end

      -- Fallback
      pcall(EncounterJournal_OpenJournal, nil, nil, nil, nil, nil, itemID)
    end)
  end)
  row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
  return row
end

local function GetRow(idx, parent)
  if not rowPool[idx] then rowPool[idx] = NewRow(parent) end
  return rowPool[idx]
end

local function NewHeader(parent)
  local h = CreateFrame("Button", nil, parent)
  h:SetHeight(HDR_H)
  local bg = h:CreateTexture(nil,"BACKGROUND")
  bg:SetAllPoints(); bg:SetColorTexture(0.12,0.08,0.25,1)
  h.bg = bg

  -- Collapse arrow
  local arrow = h:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
  arrow:SetPoint("LEFT",h,"LEFT",4,0)
  arrow:SetWidth(14); arrow:SetJustifyH("CENTER")
  arrow:SetTextColor(0.7,0.6,0.9,1)
  arrow:SetText("-")
  h.arrow = arrow

  local lbl = h:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
  lbl:SetPoint("LEFT",h,"LEFT",20,0)
  lbl:SetPoint("RIGHT",h,"RIGHT",-58,0)
  lbl:SetJustifyH("LEFT"); lbl:SetTextColor(0.85,0.65,1,1)
  h.lbl = lbl

  local filterBtn = CreateFrame("Button",nil,h)
  filterBtn:SetSize(50,18); filterBtn:SetPoint("RIGHT",h,"RIGHT",-4,0)
  filterBtn:SetNormalFontObject("GameFontNormalSmall")
  filterBtn:SetText("|cff88aaff[Only]|r")
  filterBtn:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self,"ANCHOR_RIGHT")
    GameTooltip:SetText("Filter to this source only",1,1,1)
    GameTooltip:Show()
  end)
  filterBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
  h.filterBtn = filterBtn

  -- Click header to collapse/expand
  h:SetScript("OnClick", function(self)
    if self.collapseKey then
      collapsedGroups[self.collapseKey] = not collapsedGroups[self.collapseKey]
      SimWishlist_Refresh()
    end
  end)
  h:SetScript("OnEnter", function(self)
    self.bg:SetColorTexture(0.18,0.12,0.32,1)
  end)
  h:SetScript("OnLeave", function(self)
    self.bg:SetColorTexture(0.12,0.08,0.25,1)
  end)

  return h
end

local function GetHeader(idx, parent)
  if not hdrPool[idx] then hdrPool[idx] = NewHeader(parent) end
  return hdrPool[idx]
end

local function HidePool(pool)
  for _,v in pairs(pool) do v:Hide() end
end

local function CreateMainFrame()
  local f = CreateFrame("Frame","SimWishlistFrame",UIParent,"BackdropTemplate")
  f:SetMovable(true); f:SetResizable(true)
  f:SetResizeBounds(500, 380)
  f:EnableMouse(true)
  f:RegisterForDrag("LeftButton")
  f:SetScript("OnDragStart", f.StartMoving)
  f:SetScript("OnDragStop", function(self)
    self:StopMovingOrSizing()
    local p,_,_,x,y = self:GetPoint(1)
    settings.windowPos = {point=p, x=x, y=y}
    SaveSettings()
  end)
  f:SetClampedToScreen(true)
  f:SetFrameStrata("HIGH")
  f:Hide()

  f:SetBackdrop({
    bgFile="Interface/Tooltips/UI-Tooltip-Background",
    edgeFile="Interface/Tooltips/UI-Tooltip-Border",
    tile=true, tileSize=16, edgeSize=16,
    insets={left=4,right=4,top=4,bottom=4}
  })
  f:SetBackdropColor(0.05,0.05,0.10,0.96)
  f:SetBackdropBorderColor(0.45,0.32,0.72,1)

  -- Restore size/pos
  local sz = settings.windowSize
  f:SetSize(sz.w or 700, sz.h or 560)
  local pos = settings.windowPos
  f:SetPoint(pos.point or "CENTER", UIParent, pos.point or "CENTER", pos.x or 0, pos.y or 0)

  -- Title bar
  local bar = f:CreateTexture(nil,"BACKGROUND")
  bar:SetPoint("TOPLEFT",f,"TOPLEFT",4,-4)
  bar:SetPoint("TOPRIGHT",f,"TOPRIGHT",-4,-4)
  bar:SetHeight(22); bar:SetColorTexture(0.12,0.08,0.28,1)

  local titleLbl = f:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
  titleLbl:SetPoint("LEFT",bar,"LEFT",8,0)
  titleLbl:SetText("|cffcc88ffSim Wishlist|r  |cff666666v"..VERSION.."|r")

  f.charLabel = f:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
  f.charLabel:SetPoint("CENTER",bar,"CENTER",0,0)
  f.charLabel:SetTextColor(0.8,0.8,0.5,1)

  f.instanceLabel = f:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
  f.instanceLabel:SetPoint("RIGHT",bar,"RIGHT",-28,0)
  f.instanceLabel:SetText("")

  local closeBtn = CreateFrame("Button",nil,f,"UIPanelCloseButton")
  closeBtn:SetPoint("TOPRIGHT",f,"TOPRIGHT",2,2)
  closeBtn:SetScript("OnClick", function() f:Hide() end)

  -- Import button in title bar
  local importBtn = CreateFrame("Button",nil,f)
  importBtn:SetSize(52,18); importBtn:SetPoint("LEFT",bar,"LEFT",130,0)
  importBtn:SetNormalFontObject("GameFontNormalSmall")
  importBtn:SetText("|cff88ffcc[Import]|r")
  importBtn:SetScript("OnClick", function() CreateImportFrame() end)

  -- Meta info line
  f.metaLabel = f:CreateFontString(nil,"OVERLAY","GameFontDisableSmall")
  f.metaLabel:SetPoint("TOPLEFT",f,"TOPLEFT",10,-28)
  f.metaLabel:SetPoint("TOPRIGHT",f,"TOPRIGHT",-10,-28)
  f.metaLabel:SetJustifyH("LEFT")

  -- Tabs
  local TAB_Y = -46
  for i, tabKey in ipairs(TABS) do
    local btn = CreateFrame("Button",nil,f)
    local tabW = (700 - 8) / #TABS  -- will be re-laid out on resize
    btn:SetSize(tabW, 26)
    btn:SetPoint("TOPLEFT",f,"TOPLEFT", 4+(i-1)*tabW, TAB_Y)
    local bg = btn:CreateTexture(nil,"BACKGROUND")
    bg:SetAllPoints(); bg:SetColorTexture(0.10,0.08,0.20,1)
    btn.bg = bg
    local lbl = btn:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
    lbl:SetAllPoints(); lbl:SetJustifyH("CENTER")
    lbl:SetText(TAB_LABELS[tabKey]); lbl:SetTextColor(0.7,0.7,0.7,1)
    btn.lbl = lbl; btn.tabKey = tabKey
    btn:SetScript("OnClick", function()
      currentTab = tabKey
      settings.lastTab = tabKey
      SaveSettings()
      -- Only reset specific-source filters (string), preserve "show all" (false)
      if type(manualFilter) == "string" then
        manualFilter = nil
      end
      SimWishlist_Refresh()
    end)
    btn:SetScript("OnEnter", function(self)
      if self.tabKey ~= currentTab then self.bg:SetColorTexture(0.18,0.14,0.30,1) end
    end)
    btn:SetScript("OnLeave", function(self)
      if self.tabKey ~= currentTab then self.bg:SetColorTexture(0.10,0.08,0.20,1) end
    end)
    tabButtons[tabKey] = btn
  end

  -- Filter/sort bar
  local filterBar = CreateFrame("Frame",nil,f)
  filterBar:SetPoint("TOPLEFT",f,"TOPLEFT",8, TAB_Y-26-4)
  filterBar:SetPoint("TOPRIGHT",f,"TOPRIGHT",-8, TAB_Y-26-4)
  filterBar:SetHeight(20)
  f.filterBar = filterBar

  f.filterLabel = filterBar:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
  f.filterLabel:SetPoint("LEFT",filterBar,"LEFT",2,0)
  f.filterLabel:SetPoint("RIGHT",filterBar,"RIGHT",-285,0)
  f.filterLabel:SetJustifyH("LEFT"); f.filterLabel:SetWordWrap(false)

  local function MakeBtn(w, text, onClick)
    local b = CreateFrame("Button",nil,filterBar,"UIPanelButtonTemplate")
    b:SetSize(w,18); b:SetText(text)
    b:SetScript("OnClick", onClick)
    return b
  end

  local showAllBtn = MakeBtn(72,"Show All", function()
    manualFilter = false; SimWishlist_Refresh()
  end)
  showAllBtn:SetPoint("RIGHT",filterBar,"RIGHT",0,0)
  f.showAllBtn = showAllBtn

  local autoBtn = MakeBtn(70,"Use Auto", function()
    manualFilter = nil; SimWishlist_Refresh()
  end)
  autoBtn:SetPoint("RIGHT",showAllBtn,"LEFT",-4,0)
  f.autoBtn = autoBtn

  local minBtn = MakeBtn(72,"Min: 0.0", function()
    local opts = {0, 0.5, 1.0, 2.0}
    for i,v in ipairs(opts) do
      if math.abs(v - (settings.minUpgrade or 0)) < 0.01 then
        settings.minUpgrade = opts[(i % #opts)+1]
        SaveSettings()
        SimWishlist_Refresh()
        return
      end
    end
    settings.minUpgrade = 0; SaveSettings(); SimWishlist_Refresh()
  end)
  minBtn:SetPoint("RIGHT",autoBtn,"LEFT",-4,0)
  f.minBtn = minBtn

  local sortBtn = MakeBtn(88,"Sort: Upgrade", function()
    local modes = {"upgrade","ilvl","name"}
    for i,m in ipairs(modes) do
      if m == settings.sortMode then
        settings.sortMode = modes[(i % #modes)+1]
        SaveSettings(); SimWishlist_Refresh()
        return
      end
    end
    settings.sortMode = "upgrade"; SaveSettings(); SimWishlist_Refresh()
  end)
  sortBtn:SetPoint("RIGHT",minBtn,"LEFT",-4,0)
  f.sortBtn = sortBtn

  -- Divider
  local div = f:CreateTexture(nil,"BACKGROUND")
  div:SetPoint("TOPLEFT",f,"TOPLEFT",4, TAB_Y-26-26)
  div:SetPoint("TOPRIGHT",f,"TOPRIGHT",-4, TAB_Y-26-26)
  div:SetHeight(1); div:SetColorTexture(0.4,0.3,0.6,0.4)

  -- Column header bar
  local colBar = CreateFrame("Frame",nil,f)
  colBar:SetPoint("TOPLEFT",f,"TOPLEFT",8, TAB_Y-26-28)
  colBar:SetPoint("TOPRIGHT",f,"TOPRIGHT",-28, TAB_Y-26-28)
  colBar:SetHeight(18)
  local colBarBg = colBar:CreateTexture(nil,"BACKGROUND")
  colBarBg:SetAllPoints(); colBarBg:SetColorTexture(0.08,0.06,0.18,1)
  f.colBar = colBar

  -- Name column header (left-anchored, fills remaining space)
  local colName = colBar:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
  colName:SetPoint("LEFT",colBar,"LEFT",6,0)
  colName:SetTextColor(0.7,0.6,0.9,1)
  colName:SetText("Item Name")
  f.colName = colName

  -- Boss column header + drag handle
  local colBossLbl = colBar:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
  colBossLbl:SetTextColor(0.7,0.6,0.9,1); colBossLbl:SetText("Source")
  colBossLbl:SetJustifyH("RIGHT")
  f.colBossLbl = colBossLbl

  local colIlvlLbl = colBar:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
  colIlvlLbl:SetTextColor(0.7,0.6,0.9,1); colIlvlLbl:SetText("iLvl")
  colIlvlLbl:SetJustifyH("CENTER")
  f.colIlvlLbl = colIlvlLbl

  local colUpgLbl = colBar:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
  colUpgLbl:SetTextColor(0.7,0.6,0.9,1); colUpgLbl:SetText("Upgrade")
  colUpgLbl:SetJustifyH("RIGHT")
  f.colUpgLbl = colUpgLbl

  -- Drag handles between columns
  local function MakeDragHandle(parent, cursor, onDrag)
    local h = CreateFrame("Button",nil,parent)
    h:SetSize(6,18); h:SetFrameLevel(parent:GetFrameLevel()+2)
    local tex = h:CreateTexture(nil,"ARTWORK")
    tex:SetAllPoints(); tex:SetColorTexture(0.5,0.3,0.8,0.4)
    h:SetScript("OnEnter", function(self)
      tex:SetColorTexture(0.7,0.5,1,0.8)
      SetCursor(cursor or "Interface\CURSOR\UI-Cursor-SizeLeftRight")
    end)
    h:SetScript("OnLeave", function(self)
      tex:SetColorTexture(0.5,0.3,0.8,0.4)
      ResetCursor()
    end)
    local dragging = false
    local startX = 0
    h:SetScript("OnMouseDown", function(self, btn)
      if btn == "LeftButton" then
        dragging = true
        startX = GetCursorPosition()
        h:SetScript("OnUpdate", function()
          if not dragging then h:SetScript("OnUpdate",nil) return end
          local cx = GetCursorPosition()
          local delta = math.floor((cx - startX) / UIParent:GetEffectiveScale())
          if math.abs(delta) > 0 then
            onDrag(delta)
            startX = cx
          end
        end)
      end
    end)
    h:SetScript("OnMouseUp", function(self, btn)
      dragging = false
      h:SetScript("OnUpdate",nil)
      SaveSettings()
    end)
    return h
  end

  -- Handle between boss/ilvl
  f.dragBossIlvl = MakeDragHandle(colBar, nil, function(delta)
    settings.colBoss = math.max(60, math.min(220, (settings.colBoss or 140) - delta))
    SimWishlist_LayoutColumns()
    SimWishlist_Refresh()
  end)

  -- Handle between ilvl/upgrade
  f.dragIlvlUpg = MakeDragHandle(colBar, nil, function(delta)
    settings.colIlvl = math.max(28, math.min(80, (settings.colIlvl or 36) - delta))
    SimWishlist_LayoutColumns()
    SimWishlist_Refresh()
  end)

  -- Scroll
  local scrollTop = TAB_Y - 26 - 48
  local sf = CreateFrame("ScrollFrame","SimWishlistScroll",f,"UIPanelScrollFrameTemplate")
  sf:SetPoint("TOPLEFT",f,"TOPLEFT",8,scrollTop)
  sf:SetPoint("BOTTOMRIGHT",f,"BOTTOMRIGHT",-28,20)

  local content = CreateFrame("Frame",nil,sf)
  content:SetSize(660,1)
  sf:SetScrollChild(content)
  f.content = content
  f.scrollFrame = sf

  -- Resize grip
  local grip = CreateFrame("Button",nil,f)
  grip:SetSize(16,16)
  grip:SetPoint("BOTTOMRIGHT",f,"BOTTOMRIGHT",0,0)
  grip:SetFrameLevel(f:GetFrameLevel() + 10)
  grip:SetNormalTexture("Interface/ChatFrame/UI-ChatIM-SizeGrabber-Up")
  grip:SetHighlightTexture("Interface/ChatFrame/UI-ChatIM-SizeGrabber-Highlight")
  grip:SetPushedTexture("Interface/ChatFrame/UI-ChatIM-SizeGrabber-Down")
  grip:SetScript("OnMouseDown", function(_,btn)
    if btn == "LeftButton" then f:StartSizing("BOTTOMRIGHT") end
  end)
  grip:SetScript("OnMouseUp", function()
    f:StopMovingOrSizing()
    local w = math.floor(f:GetWidth()+0.5)
    local h = math.floor(f:GetHeight()+0.5)
    settings.windowSize = {w=w, h=h}
    SaveSettings()
    -- Reflow tabs
    local tw = (f:GetWidth()-8)/#TABS
    for i,tabKey in ipairs(TABS) do
      local btn = tabButtons[tabKey]
      if btn then
        btn:ClearAllPoints()
        btn:SetSize(tw,26)
        btn:SetPoint("TOPLEFT",f,"TOPLEFT",4+(i-1)*tw, TAB_Y)
      end
    end
    content:SetWidth(f:GetWidth()-40)
    SimWishlist_LayoutColumns()
    if f:IsShown() then SimWishlist_Refresh() end
  end)

  -- Update layout continuously while resizing
  f:SetScript("OnSizeChanged", function(self)
    local tw = (self:GetWidth()-8)/#TABS
    for i,tabKey in ipairs(TABS) do
      local btn = tabButtons[tabKey]
      if btn then
        btn:ClearAllPoints()
        btn:SetSize(tw,26)
        btn:SetPoint("TOPLEFT",self,"TOPLEFT",4+(i-1)*tw, TAB_Y)
      end
    end
    content:SetWidth(math.max(200, self:GetWidth()-40))
    SimWishlist_LayoutColumns()
  end)

  f:SetScript("OnShow", function(self)
    -- Defer one frame so the frame has fully rendered before laying out columns
    C_Timer.After(0, function()
      if not self:IsShown() then return end
      content:SetWidth(math.max(200, self:GetWidth()-40))
      SimWishlist_LayoutColumns()
      SimWishlist_Refresh()
    end)
  end)

  return f
end

-- ============================================================
-- Render
-- ============================================================
function SimWishlist_Refresh()
  if not frame or not frame:IsShown() then return end
  local ok, err = pcall(SimWishlist_RefreshInner)
  if not ok then
    print("|cffff4444SimWishlist Refresh ERROR: " .. tostring(err) .. "|r")
  end
end

function SimWishlist_RefreshInner()
  if not frame or not frame:IsShown() then return end

  -- Update char label
  frame.charLabel:SetText(CharKey())

  -- Update tab highlights
  for _, tabKey in ipairs(TABS) do
    local btn = tabButtons[tabKey]
    if btn then
      if tabKey == currentTab then
        btn.bg:SetColorTexture(0.25,0.15,0.45,1)
        btn.lbl:SetTextColor(1,0.85,1,1)
      else
        btn.bg:SetColorTexture(0.10,0.08,0.20,1)
        btn.lbl:SetTextColor(0.7,0.7,0.7,1)
      end
    end
  end

  -- Instance detection + auto-filter
  local instName, instType = GetCurrentInstance()
  if instName then
    frame.instanceLabel:SetText("|cff88ff88📍 "..instName.."|r")
    -- Auto-switch tab
    if manualFilter == nil and instType then
      currentTab = instType
    end
  else
    frame.instanceLabel:SetText("")
  end

  -- Filter label
  local autoSrc = instName
  if manualFilter == false then
    frame.filterLabel:SetText("|cffaaaaaa Showing all sources|r")
  elseif type(manualFilter) == "string" then
    frame.filterLabel:SetText("|cffcc88ff Filter: |r|cffffff88"..manualFilter.."|r")
  elseif autoSrc then
    frame.filterLabel:SetText("|cff88ff88 Auto: |r|cffffff88"..autoSrc.."|r")
  else
    frame.filterLabel:SetText("|cffaaaaaa Showing all sources|r")
  end

  -- Buttons
  local smode = settings.sortMode or "upgrade"
  frame.sortBtn:SetText("Sort: "..(smode:sub(1,1):upper()..smode:sub(2)))
  frame.minBtn:SetText(string.format("Min: %.1f", settings.minUpgrade or 0))

  -- Meta label
  local data = GetCharData()
  ComputeBiSMap()
  if data.importTime and data.importTime > 0 then
    frame.metaLabel:SetText(
      "|cff88c0ff"..(data.source or "Unknown").."|r  •  "..
      "Items: |cffffff88"..(data.items and #data.items or 0).."|r  •  "..
      "Imported: |cffaaaaaa"..date("%Y-%m-%d %H:%M", data.importTime).."|r"
    )
  else
    frame.metaLabel:SetText("|cffff8888No data. Click [Import] to get started.|r")
  end

  -- Get and filter items
  local items = GetItemsForTab(currentTab)

  -- Apply source filter
  local srcFilter = nil
  if type(manualFilter) == "string" then
    srcFilter = manualFilter
  elseif manualFilter == nil and instName then
    srcFilter = instName
  end

  if currentTab ~= "BY SLOT" then
    items = FilterBySource(items, srcFilter)
  end
  items = FilterByMinUpgrade(items)
  items = FilterOutTierSets(items)
  SortItems(items)

  -- Group
  local groups
  if currentTab == "BY SLOT" then
    groups = GroupBySlot(items)
  else
    groups = GroupBySource(items)
  end

  HidePool(rowPool)
  HidePool(hdrPool)

  if #groups == 0 or (#groups == 1 and #groups[1].items == 0) then
    local h = GetHeader(1, frame.content)
    h:SetPoint("TOPLEFT",frame.content,"TOPLEFT",0,0)
    h:SetWidth(frame.content:GetWidth())
    h.lbl:SetText("|cffaaaaaa No items. Try 'Show All' or import data.|r")
    h.filterBtn:Hide()
    h:Show()
    frame.content:SetHeight(24)
    return
  end

  local yOff  = -PAD
  local rowI  = 0
  local hdrI  = 0
  local cW    = frame.content:GetWidth()

  -- Sync content width to frame, then apply column layout
  frame.content:SetWidth(math.max(200, frame:GetWidth() - 40))
  SimWishlist_LayoutColumns()

  for _, group in ipairs(groups) do
    if #group.items > 0 then
      local colKey = currentTab .. "|" .. group.source
      local isCollapsed = collapsedGroups[colKey]

      hdrI = hdrI + 1
      local hdr = GetHeader(hdrI, frame.content)
      yOff = yOff - HDR_GAP
      hdr:SetPoint("TOPLEFT",frame.content,"TOPLEFT",0,yOff)
      hdr:SetWidth(cW)
      hdr.collapseKey = colKey
      hdr.arrow:SetText(isCollapsed and "+" or "-")
      hdr.lbl:SetText(group.source.."  |cff888888("..#group.items..")|r")
      hdr.filterBtn:Show()
      local srcCapture = group.source
      hdr.filterBtn:SetScript("OnClick", function(self)
        manualFilter = srcCapture
        SimWishlist_Refresh()
      end)
      hdr:Show()
      yOff = yOff - HDR_H - PAD

      if not isCollapsed then
        if currentTab == "RAID" then
          -- Boss sub-headers for raids
          local bossGroups = GroupByBoss(group.items)
          for _, bg in ipairs(bossGroups) do
            if #bg.items > 0 then
              local bossColKey = colKey .. "|" .. bg.boss
              local bossCollapsed = collapsedGroups[bossColKey]
              hdrI = hdrI + 1
              local bossHdr = GetHeader(hdrI, frame.content)
              yOff = yOff - 2
              bossHdr:SetPoint("TOPLEFT",frame.content,"TOPLEFT",10,yOff)
              bossHdr:SetWidth(cW - 10)
              bossHdr.collapseKey = bossColKey
              bossHdr.arrow:SetText(bossCollapsed and "+" or "-")
              bossHdr.lbl:SetText("|cffaaaaaa"..bg.boss.."|r  |cff666666("..#bg.items..")|r")
              bossHdr.filterBtn:Hide()
              bossHdr:Show()
              yOff = yOff - HDR_H - PAD
              if not bossCollapsed then
                for bi, item in ipairs(bg.items) do
                  rowI = rowI + 1
                  local row = GetRow(rowI, frame.content)
                  row:SetPoint("TOPLEFT",frame.content,"TOPLEFT",10,yOff)
                  row:SetWidth(cW - 10)
                  row.itemData = item
                  row.altRow   = (bi % 2 == 0)
                  local name = GetItemInfo(item.itemID) or ("Item #"..tostring(item.itemID))
                  if IsBiS(item.itemID) then name = name .. BIS_TAG end
                  row.nameLbl:SetText(name)
                  row.bossLbl:SetText("")
                  row.ilvlLbl:SetText(item.ilvl and item.ilvl > 0 and ("|cff999999"..item.ilvl.."|r") or "")
                  row.upgLbl:SetText(FmtUpgrade(item.upgrade))
                  row.bg:SetColorTexture(row.altRow and 0.06 or 0, row.altRow and 0.05 or 0, row.altRow and 0.12 or 0, row.altRow and 0.4 or 0)
                  row:Show()
                  yOff = yOff - ROW_H
                end -- for bi
                yOff = yOff - PAD
              end -- if not bossCollapsed
            end -- if #bg.items
          end -- for bossGroups
        else
          -- Dungeon and other tabs: flat list
          for i, item in ipairs(group.items) do
            rowI = rowI + 1
            local row = GetRow(rowI, frame.content)
            row:SetPoint("TOPLEFT",frame.content,"TOPLEFT",0,yOff)
            row:SetWidth(cW)
            row.itemData = item
            row.altRow   = (i % 2 == 0)
            local loot = GetLootInfo(item.itemID)
            local name = GetItemInfo(item.itemID) or ("Item #"..tostring(item.itemID))
            if IsBiS(item.itemID) then name = name .. BIS_TAG end
            row.nameLbl:SetText(name)
            local sourceLabel
            local loc = (item.dropLoc or ""):upper()
            if currentTab == "CRAFTED" or loc == "CRAFTED" then
              sourceLabel = "Crafting"
            elseif currentTab == "DELVES" or loc == "DELVES" then
              sourceLabel = "Delves"
            else
              sourceLabel = (loot and loot.boss) or ""
            end
            row.bossLbl:SetText(sourceLabel ~= "" and ("|cff888888"..sourceLabel.."|r") or "")
            row.ilvlLbl:SetText(item.ilvl and item.ilvl > 0 and ("|cff999999"..item.ilvl.."|r") or "")
            row.upgLbl:SetText(FmtUpgrade(item.upgrade))
            row.bg:SetColorTexture(row.altRow and 0.06 or 0, row.altRow and 0.05 or 0, row.altRow and 0.12 or 0, row.altRow and 0.4 or 0)
            row:Show()
            yOff = yOff - ROW_H
          end -- for group.items
          yOff = yOff - PAD
        end -- if RAID
      end -- if not isCollapsed
    end -- if #group.items
  end -- for groups

  frame.content:SetHeight(math.abs(yOff) + 8)
end

-- ============================================================
-- Init & Events
-- ============================================================
function SlashHandler(msg)
  msg = strtrim((msg or ""):lower())
  if not frame then SimWishlist_Init() end
  if not frame then return end

  if msg == "import" then
    CreateImportFrame()
    return
  elseif msg == "all" then
    manualFilter = false
    if frame:IsShown() then SimWishlist_Refresh() end
    return
  elseif msg == "auto" then
    manualFilter = nil
    local instName, instType = GetCurrentInstance()
    if instType then currentTab = instType end
    frame:Show()
    SimWishlist_Refresh()
    return
  elseif msg == "test" then
    local instName, instType = GetCurrentInstance()
    if instType then currentTab = instType end
    manualFilter = nil
    frame:Show()
    SimWishlist_Refresh()
    print("|cffcc88ff{{ Sim Wishlist }}|r |cffffff00[TEST]|r Simulating M+ completion.")
    return
  elseif msg == "help" then
    print("|cffcc88ff{{ Sim Wishlist }}|r |cffffff00/swl|r commands:")
    print("  |cffffff00/swl|r          - Toggle window")
    print("  |cffffff00/swl import|r   - Open import box")
    print("  |cffffff00/swl all|r      - Show all items regardless of location")
    print("  |cffffff00/swl auto|r     - Open filtered to current instance")
    print("  |cffffff00/swl test|r     - Simulate M+ completion (for testing)")
    print("  |cffffff00/swl help|r     - Show this message")
    return
  end

  if frame:IsShown() then
    frame:Hide()
  else
    local instName, instType = GetCurrentInstance()
    if instType then currentTab = instType end
    manualFilter = nil
    frame:Show()
    SimWishlist_Refresh()
  end
end

function SimWishlist_Init()
  if frame then return end
  LoadSettings()
  SimWishlistDB = SimWishlistDB or {}
  currentTab = settings.lastTab or "DUNGEON"
  frame = CreateMainFrame()
  SLASH_SWL1 = "/swl"
  SLASH_SWL2 = "/simwishlist"
  SlashCmdList["SWL"] = SlashHandler
  C_Timer.After(1, function()
    print("|cffcc88ff{{Sim Wishlist}}|r|cffffffff v"..VERSION.."|r Loaded.")
	print("|cffcc88ff{{Sim Wishlist}}|r|cffffff00 /swl|r to open. |cffffff00/swl help|r to show all available commands.")
  end)
end


-- Direct init call with error surfacing
local _initOk, _initErr = pcall(SimWishlist_Init)
if not _initOk then
  print("|cffff4444SimWishlist INIT ERROR: " .. tostring(_initErr) .. "|r")
end



-- Zone change and M+ completion handling
local evFrame = CreateFrame("Frame")
evFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
evFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
evFrame:RegisterEvent("GET_ITEM_INFO_RECEIVED")
evFrame:RegisterEvent("CHALLENGE_MODE_COMPLETED")
evFrame:SetScript("OnEvent", function(_, event)
  if event == "GET_ITEM_INFO_RECEIVED" then
    if frame and frame:IsShown() then SimWishlist_Refresh() end
    return
  end
  if event == "PLAYER_ENTERING_WORLD" or event == "ZONE_CHANGED_NEW_AREA" then
    if frame and frame:IsShown() then
      manualFilter = nil
      SimWishlist_Refresh()
    end
    return
  end
  if event == "CHALLENGE_MODE_COMPLETED" then
    -- Auto-open after M+ completion, filtered to current dungeon
    if not frame then SimWishlist_Init() end
    C_Timer.After(3, function()  -- slight delay so the end-of-run UI settles
      if not frame then return end
      local instName, instType = GetCurrentInstance()
      if instType then currentTab = instType end
      manualFilter = nil
      frame:Show()
      SimWishlist_Refresh()
      print("|cffcc88ff{{Sim Wishlist}}|r |cff44ff88Key complete! Showing upgrades for this dungeon.|r")
    end)
    return
  end
end)