---@diagnostic disable: undefined-global
-- luacheck: globals CreateFrame UIParent DEFAULT_CHAT_FRAME SlashCmdList InCombatLockdown C_LFGList GameTooltip GetTime C_Spell C_SpellBook LibStub Settings C_Timer C_AddOns SecondsToTime ChallengesFrame TELEPORT_TO_DUNGEON READY SPELL_FAILED_NOT_KNOWN GetInstanceInfo

local ADDON_NAME, addonTable = ...
local L = addonTable.L
local FALLBACK_PORTAL_ICON = FALLBACK_PORTAL_ICON
local TEST_PORTAL_ICON = TEST_PORTAL_ICON
local DEFAULT_FONT_NAME = DEFAULT_FONT_NAME


-- Cache frequently used Lua functions for performance
local pairs, ipairs, type, tostring, tonumber = pairs, ipairs, type, tostring, tonumber
local floor, format = math.floor, string.format

-- Dev guard: stop if not in WoW client.
if not CreateFrame or not UIParent then
  if print then print("[TPB] WoW API not available; skipping addon init.") end
  return
end

-- Defaults
local DEFAULTS = {
  pos = { point = "CENTER", x = 0, y = 250 },
  size = { w = 50, h = 50 },
  locked = true,
  fontKey = nil, -- LibSharedMedia font key; nil means addon default
  fontSize = 25,
  onlyWhenReady = false, -- When true, only show button when teleport cooldown is ready
}

TPB_DB = TPB_DB or {}

local function mergeDefaults(db, defaults)
  db = db or {}
  for k, v in pairs(defaults) do
    if type(v) == "table" then
      if type(db[k]) ~= "table" then db[k] = {} end
      mergeDefaults(db[k], v)
    elseif db[k] == nil then
      db[k] = v
    end
  end
  return db
end

-- Midnight Season 1 Dungeons
-- Icon is fetched dynamically via C_Spell.GetSpellTexture(spellID)
local DUNGEONS = {
  { name = "Windrunner Spire",        spell = 1254400, instanceID = 2773, triggers = { "windrunner", "spire" } },
  { name = "Skyreach",                spell = 1254557, instanceID = 1209, altSpells = { 159898 }, triggers = { "skyreach" } },
  { name = "Seat of the Triumvirate", spell = 1254551, instanceID = 1753, triggers = { "seat", "triumvirate" } },
  { name = "Pit of Saron",            spell = 1254555, instanceID = 658,  triggers = { "pit of saron", "saron", "pit" } },
  { name = "Nexus-Point Xenas",       spell = 1254563, instanceID = 2779, triggers = { "nexus", "xenas" } },
  { name = "Maisara Caverns",         spell = 1254559, instanceID = 2777, triggers = { "maisara", "caverns" } },
  { name = "Magisters' Terrace",      spell = 1254572, instanceID = 585,  triggers = { "magister", "terrace" } },
  { name = "Al'gethar Academy",       spell = 393273,  instanceID = 2526, triggers = { "algethar", "academy" } },
}

-- Inject locale-specific dungeon name triggers from localization files
if addonTable.DUNGEONS_EXTRA_TRIGGERS then
  for _, d in ipairs(DUNGEONS) do
    local extra = addonTable.DUNGEONS_EXTRA_TRIGGERS[d.name]
    if extra then
      for _, trig in ipairs(extra) do
        d.triggers[#d.triggers + 1] = trig
      end
    end
  end
end

-- Activity ID to Dungeon Index Map
local ACTIVITY_TO_DUNGEON = {
  -- [ID] = Index in DUNGEONS table
  [1542] = 1, -- Windrunner Spire
  [182]  = 2, -- Skyreach
  [486]  = 3, -- Seat of the Triumvirate
  [1770] = 4, -- Pit of Saron
  [1768] = 5, -- Nexus-Point Xenas
  [1764] = 6, -- Maisara Caverns
  [1760] = 7, -- Magisters' Terrace
  [1160] = 8, -- Al'gethar Academy
}

-- MapChallengeMode ID to Spell ID for M+ UI overlay buttons
local MAP_ID_TO_SPELL = {
  -- Midnight Season 1
  [558] = 1254572, -- Magisters' Terrace
  [560] = 1254559, -- Maisara Caverns
  [559] = 1254563, -- Nexus-Point Xenas
  [557] = 1254400, -- Windrunner Spire
  [402] = 393273,  -- Al'gethar Academy
  [556] = 1254555, -- Pit of Saron
  [239] = 1254551, -- Seat of the Triumvirate
  [161] = 1254557, -- Skyreach
}

local function Print(msg)
  if DEFAULT_CHAT_FRAME then
    DEFAULT_CHAT_FRAME:AddMessage("|cff9be28f[TPB]|r " .. tostring(msg))
  else
    print("[TPB] " .. tostring(msg))
  end
end

-- Spell name helper
local function getSpellName(spellID)
  if not spellID then return nil end
  return C_Spell.GetSpellName(spellID)
end

-- Spell knowledge helper (12.0.5: uses C_SpellBook.IsSpellKnownOrInSpellBook)
local function isTeleportKnown(spellID)
  if not spellID then return false end
  if C_SpellBook and C_SpellBook.IsSpellKnownOrInSpellBook then
    return C_SpellBook.IsSpellKnownOrInSpellBook(spellID)
  end
  return false
end

-- Resolve the best spell ID for a dungeon: prefer the primary spell if known,
-- otherwise try each altSpells entry. Falls back to primary if none are known.
local function resolveSpellID(dungeon)
  if not dungeon then return nil end
  if isTeleportKnown(dungeon.spell) then return dungeon.spell end
  if dungeon.altSpells then
    for _, altID in ipairs(dungeon.altSpells) do
      if isTeleportKnown(altID) then return altID end
    end
  end
  return dungeon.spell -- fallback to primary even if not known
end

-- Check if the teleport spell is off cooldown (i.e. ready to cast)
local function isTeleportReady(spellID)
  if not spellID then return false end
  if not isTeleportKnown(spellID) then return false end
  local ready = true
  pcall(function()
    local info = C_Spell.GetSpellCooldown(spellID)
    if info and info.startTime and info.duration and info.duration > 1.5 then
      local remaining = info.startTime + info.duration - GetTime()
      if remaining > 0 then
        ready = false
      end
    end
  end)
  return ready
end

local function norm(s)
  -- WoW may pass secret/tainted strings from protected LFG APIs that cannot be
  -- indexed or manipulated in any way. pcall lets us fail gracefully.
  local ok, result = pcall(function()
    s = (s or ""):lower()
    s = s:gsub("[^%a%d%'%s]", " ")
    s = s:gsub("%s+", " ")
    return s
  end)
  return ok and result or ""
end

local function matchDungeonFromListingName(name)
  local ln = norm(name)
  for _, d in ipairs(DUNGEONS) do
    for _, trig in ipairs(d.triggers) do
      if ln:find(trig, 1, true) then
        return d
      end
    end
  end
  return nil
end

local function matchDungeonFromActivity(activityID, fallbackName)
  -- Wrap in pcall: LFG API values can be tainted/secret during raids
  local ok, result = pcall(function()
    if activityID then
      -- Direct static mapping gets priority for exactness
      if ACTIVITY_TO_DUNGEON[activityID] then
        return DUNGEONS[ACTIVITY_TO_DUNGEON[activityID]]
      end
      local full
      if C_LFGList and C_LFGList.GetActivityFullName then
        full = C_LFGList.GetActivityFullName(activityID)
      end
      if not full then
        local a = C_LFGList.GetActivityInfoTable and C_LFGList.GetActivityInfoTable(activityID)
        if a then full = a.fullName or a.shortName end
      end
      if full and full ~= "" then
        local d = matchDungeonFromListingName(full)
        if d then return d end
      end
    end
    return matchDungeonFromListingName(fallbackName or "")
  end)
  return ok and result or nil
end

local function matchDungeonFromActivityIDs(activityIDs)
  -- Wrap in pcall: LFG API values can be tainted/secret during raids
  local ok, result = pcall(function()
    if type(activityIDs) == "table" then
      for _, actId in pairs(activityIDs) do
        if ACTIVITY_TO_DUNGEON[actId] then
          return DUNGEONS[ACTIVITY_TO_DUNGEON[actId]]
        end
        local fullName = nil
        if C_LFGList and C_LFGList.GetActivityFullName then
          fullName = C_LFGList.GetActivityFullName(actId)
        end
        if not fullName or fullName == "" then
          local a = C_LFGList.GetActivityInfoTable and C_LFGList.GetActivityInfoTable(actId)
          if a then fullName = a.fullName or a.shortName end
        end
        if type(fullName) == "string" and fullName:find("(Mythic Keystone)", 1, true) then
          local d = matchDungeonFromListingName(fullName)
          if d then return d end
        end
      end
    end
    return nil
  end)
  return ok and result or nil
end

-- Secure Button
---@diagnostic disable-next-line: redundant-parameter
local btn = CreateFrame("Button", "TPB_Button", UIParent, "SecureActionButtonTemplate")
btn:SetFrameStrata("MEDIUM")
btn:Hide()
btn:SetMovable(true)
btn:EnableMouse(true)
btn:RegisterForDrag("LeftButton")
btn:SetClampedToScreen(true)
-- Only allow secure action on left click; still listen to right-click for our script
btn:RegisterForClicks("LeftButtonDown", "LeftButtonUp")

local icon = btn:CreateTexture(nil, "ARTWORK")
icon:SetAllPoints(btn)
icon:Hide() -- use button normal texture like portal-buttons

-- Cooldown
---@diagnostic disable-next-line: redundant-parameter
local cd = CreateFrame("Cooldown", nil, btn, "CooldownFrameTemplate")
cd:SetAllPoints(btn)
-- Keep default swipe texture; some client builds error on nil swipes
cd:EnableMouse(false)
if cd.SetDrawEdge then cd:SetDrawEdge(false) end

-- Highlights
-- Only show strong highlight while pressing; use weaker border glow on hover
btn:SetHighlightTexture("")
btn:SetNormalTexture("")
btn:SetPushedTexture("Interface\\Buttons\\CheckButtonHilight")
do
  local pt = btn:GetPushedTexture()
  if pt and pt.SetBlendMode then pt:SetBlendMode("ADD") end
end

-- Text Overlay
local FONT_PATH = "Interface\\AddOns\\LFGTeleportButtonMidnight\\BebasNeue-Regular.ttf"
local overlayFont = CreateFont("TPB_OverlayFont")
do
  local ok = overlayFont:SetFont(FONT_PATH, 25, "OUTLINE")
  if not ok then
    local default, size, flags = GameFontNormal:GetFont()
    overlayFont:SetFont(default or DEFAULT_FONT_NAME, 25, "OUTLINE")
  end
end
local overlayText = btn:CreateFontString(nil, "OVERLAY")
overlayText:SetFontObject(overlayFont)
overlayText:SetTextColor(1, 1, 1, 1)
overlayText:SetJustifyH("CENTER")
overlayText:SetJustifyV("BOTTOM")
-- Keep the bottom of the text 35px above the icon/button top so it never overlaps
overlayText:ClearAllPoints()
overlayText:SetPoint("BOTTOM", btn, "TOP", 0, 5)

-- Hover Glow
local hoverGlow = btn:CreateTexture(nil, "HIGHLIGHT")
hoverGlow:SetTexture("Interface\\Buttons\\CheckButtonHilight")
hoverGlow:SetAllPoints(btn)
hoverGlow:SetBlendMode("ADD")
-- Tint hover with rgba(146,171,255,255) and lower alpha while hovering
hoverGlow:SetVertexColor(146 / 255, 171 / 255, 255 / 255, 1)
hoverGlow:SetAlpha(0.6)
hoverGlow:Hide()

local currentDungeon        = nil
local currentSpellID        = nil
local currentApplicationID  = nil -- Track which application the button is showing for
local pendingApply          = false
local pendingSecureUpdate   = false
local suppressedByCombat    = false
local hiddenForCombat       = false -- Auto-hide during combat
local userDismissed         = false
local teleportedForListing  = false -- Already teleported for the current active listing
local notLearnedSoundPlayed = false
local showGitGudUntil       = nil
local NOT_LEARNED_SFX       = "Interface\\AddOns\\LFGTeleportButtonMidnight\\SHaoKhanlaughing.mp3"
local currentIconID         = nil
local currentSource         = "invite" -- "invite" or "party" to control overlay text
local pendingInvites        = {} -- Store multiple pending invites: [searchResultID] = dungeon

-- Returns the appropriate overlay prefix based on how the button was triggered
local function getOverlayPrefix()
  if currentSource == "party" then
    return L["PARTY_PREFIX"]
  end
  return L["INVITE_PREFIX"]
end

-- Unknown teleport suffix helper
local function getUnknownSuffix(known)
  if known then return "" end
  local now = GetTime and GetTime() or 0
  if showGitGudUntil and now < showGitGudUntil then
    return L["GIT_GUD"]
  end
  return L["NO_TELEPORT_YET"]
end

local IsInCombat
local UpdateOverlayText
local UpdateButtonDisplay

-- LSM Support
local lsm = LibStub and LibStub("LibSharedMedia-3.0", true) or nil

local function applyFontFromDB()
  local size = (TPB_DB and TPB_DB.fontSize) or DEFAULTS.fontSize
  local path = FONT_PATH
  if lsm and TPB_DB and TPB_DB.fontKey then
    local fetched = lsm:Fetch("font", TPB_DB.fontKey, true)
    if type(fetched) == "string" and fetched ~= "" then
      path = fetched
    end
  end
  local ok = overlayFont:SetFont(path, size, "OUTLINE")
  if not ok then
    local default = select(1, GameFontNormal:GetFont())
    overlayFont:SetFont(default or DEFAULT_FONT_NAME, size, "OUTLINE")
  end
  if overlayText and overlayText.SetFont then
    local applied = overlayText:SetFont(path, size, "OUTLINE")
    if not applied then
      local default = select(1, GameFontNormal:GetFont())
      overlayText:SetFont(default or DEFAULT_FONT_NAME, size, "OUTLINE")
    end
  end
end

-- Forward declaration so UI can call it before it's defined
local applyPositionAndSizeFromDB

-- Options Panel
local optionsPanel
local standaloneOptionsFrame
local standaloneLSMCallbacksHooked = false

local function EnsureStandaloneOptions()
  if standaloneOptionsFrame then
    standaloneOptionsFrame:Show()
    return
  end

  ---@diagnostic disable-next-line: redundant-parameter
  local frame = CreateFrame("Frame", nil, UIParent)
  frame:SetSize(450, 420)
  frame:SetPoint("CENTER")
  frame:SetMovable(true)
  frame:EnableMouse(true)
  frame:RegisterForDrag("LeftButton")
  frame:SetScript("OnDragStart", function(self) self:StartMoving() end)
  frame:SetScript("OnDragStop", function(self) self:StopMovingOrSizing() end)
  frame:SetClampedToScreen(true)
  frame:SetAlpha(1)
  frame:SetFrameStrata("DIALOG")

  -- Subtle background without border
  local bg = frame:CreateTexture(nil, "BACKGROUND")
  bg:SetAllPoints(true)
  bg:SetColorTexture(0, 0, 0, 0.70)

  -- Title
  local title = frame:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge")
  title:ClearAllPoints()
  title:SetPoint("TOP", frame, "TOP", 0, -48)
  title:SetText(L["OPTIONS_TITLE"])

  -- Close button
  ---@diagnostic disable-next-line: redundant-parameter
  local closeBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
  closeBtn:SetSize(22, 22)
  closeBtn:ClearAllPoints()
  closeBtn:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -10, -10)
  closeBtn:SetText("X")
  closeBtn:SetScript("OnClick", function() frame:Hide() end)

  -- Preview
  local preview = frame:CreateFontString(nil, "ARTWORK", "GameFontHighlight")
  if preview and preview.SetFontObject and GameFontHighlight then
    preview:SetFontObject(GameFontHighlight)
  end
  preview:ClearAllPoints()
  preview:SetPoint("TOP", title, "BOTTOM", 0, -16)
  preview:SetTextColor(1, 1, 1)
  preview:SetText(L["OPTIONS_PREVIEW"])

  local function refreshPreview()
    local size = 20
    local path = FONT_PATH
    if lsm and TPB_DB and TPB_DB.fontKey then
      local fetched = lsm:Fetch("font", TPB_DB.fontKey, true)
      if type(fetched) == "string" and fetched ~= "" then
        path = fetched
      end
    end
    local ok = preview:SetFont(path, size, "OUTLINE")
    if not ok then
      local default = select(1, GameFontNormal:GetFont())
      preview:SetFont(default or DEFAULT_FONT_NAME, size, "OUTLINE")
    end
  end

  -- Font selector
  local label = frame:CreateFontString(nil, "ARTWORK", "GameFontNormal")
  label:ClearAllPoints()
  label:SetPoint("TOP", preview, "BOTTOM", 0, -16)
  label:SetText("")

  local AceGUI_local = LibStub and LibStub("AceGUI-3.0", true)
  local fontWidgetRefStandalone = nil
  local fontAnchor = label
  local fontWidget = AceGUI_local and AceGUI_local.Create and AceGUI_local:Create("LSM30_Font")
  if fontWidget then
    fontWidgetRefStandalone = fontWidget
    fontWidget:SetLabel(L["FONT_LABEL"])
    if lsm and lsm.HashTable then
      fontWidget:SetList(lsm:HashTable("font"))
    end
    fontWidget:SetValue(TPB_DB.fontKey or "")
    fontWidget:SetCallback("OnValueChanged", function(widget, event, key)
      TPB_DB.fontKey = key ~= "" and key or nil
      if fontWidgetRefStandalone and fontWidgetRefStandalone.SetValue then
        fontWidgetRefStandalone:SetValue(key or "")
      end
      applyFontFromDB()
      refreshPreview()
    end)
    local fw = fontWidget.frame
    fw:SetParent(frame)
    fw:ClearAllPoints()
    fw:SetPoint("TOP", label, "BOTTOM", 0, -8)
    fw:SetWidth(300)
    fw:Show()
    fontAnchor = fw
  else
    ---@diagnostic disable-next-line: redundant-parameter
    local fontSelectBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
    fontSelectBtn:SetPoint("TOP", label, "BOTTOM", 0, -8)
    fontSelectBtn:SetSize(240, 24)
    fontSelectBtn:SetText(L["FONT_LSM_REQUIRED"])
    fontSelectBtn:SetScript("OnClick", function()
      Print(L["LSM_NOT_LOADED"])
    end)
    fontAnchor = fontSelectBtn
  end

  -- Font size
  local sizeWidgetStandalone, sizeValueLabelStandalone, sizeSliderStandalone
  sizeWidgetStandalone = AceGUI_local and AceGUI_local.Create and AceGUI_local:Create("Slider")
  if sizeWidgetStandalone then
    sizeWidgetStandalone:SetLabel(L["FONT_SIZE_LABEL"])
    sizeWidgetStandalone:SetSliderValues(8, 72, 1)
    sizeWidgetStandalone:SetValue((TPB_DB and TPB_DB.fontSize) or DEFAULTS.fontSize)
    local swf = sizeWidgetStandalone.frame
    swf:SetParent(frame)
    swf:ClearAllPoints()
    swf:SetPoint("TOP", fontAnchor, "BOTTOM", 0, -24)
    swf:SetWidth(300)
    swf:Show()
    sizeValueLabelStandalone = frame:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")
    sizeValueLabelStandalone:SetPoint("TOP", swf, "BOTTOM", 0, -4)
    sizeValueLabelStandalone:SetText(tostring((TPB_DB and TPB_DB.fontSize) or DEFAULTS.fontSize))
    sizeWidgetStandalone:SetCallback("OnValueChanged", function(widget, event, value)
      value = tonumber(value) or ((TPB_DB and TPB_DB.fontSize) or DEFAULTS.fontSize)
      value = floor(value + 0.5)
      TPB_DB.fontSize = value
      if sizeValueLabelStandalone then sizeValueLabelStandalone:SetText(tostring(value)) end
      applyFontFromDB()
    end)
  else
    ---@diagnostic disable-next-line: redundant-parameter
    sizeSliderStandalone = CreateFrame("Slider", nil, frame, "OptionsSliderTemplate")
    sizeSliderStandalone:SetPoint("TOP", fontAnchor, "BOTTOM", 0, -24)
    sizeSliderStandalone:SetMinMaxValues(8, 72)
    sizeSliderStandalone:SetObeyStepOnDrag(true)
    sizeSliderStandalone:SetValueStep(1)
    sizeSliderStandalone:SetWidth(240)
    _G[sizeSliderStandalone:GetName() .. 'Low']:SetText('8')
    _G[sizeSliderStandalone:GetName() .. 'High']:SetText('72')
    _G[sizeSliderStandalone:GetName() .. 'Text']:SetText(L["FONT_SIZE_LABEL"])
    sizeSliderStandalone:SetScript("OnValueChanged", function(self, value)
      value = floor(value + 0.5)
      TPB_DB.fontSize = value
      applyFontFromDB()
    end)
    sizeValueLabelStandalone = frame:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")
    sizeValueLabelStandalone:SetPoint("TOP", sizeSliderStandalone, "BOTTOM", 0, -4)
    sizeValueLabelStandalone:SetText(tostring((TPB_DB and TPB_DB.fontSize) or DEFAULTS.fontSize))
  end

  -- "Show only when ready" checkbox
  ---@diagnostic disable-next-line: redundant-parameter
  local readyOnlyCheck = CreateFrame("CheckButton", nil, frame, "UICheckButtonTemplate")
  readyOnlyCheck:SetPoint("TOP", (sizeValueLabelStandalone or fontAnchor), "BOTTOM", -100, -16)
  readyOnlyCheck:SetSize(26, 26)
  local readyOnlyLabel = frame:CreateFontString(nil, "ARTWORK", "GameFontNormal")
  readyOnlyLabel:SetPoint("LEFT", readyOnlyCheck, "RIGHT", 4, 0)
  readyOnlyLabel:SetText(L["SHOW_ONLY_READY"])
  readyOnlyCheck:SetChecked(TPB_DB.onlyWhenReady or false)
  readyOnlyCheck:SetScript("OnClick", function(self)
    TPB_DB.onlyWhenReady = self:GetChecked()
    if TPB_DB.onlyWhenReady then
      Print(L["ONLY_READY_ON"])
      -- If button is currently shown and spell is not ready, hide it
      if currentSpellID and btn:IsShown() and not isTeleportReady(currentSpellID) then
        if not IsInCombat() then
          btn:Hide()
        end
      end
    else
      Print(L["ONLY_READY_OFF"])
      -- If we have a dungeon tracked but button is hidden, show it
      if currentDungeon and not btn:IsShown() and not userDismissed and not IsInCombat() then
        applyDungeon(currentDungeon)
      end
    end
  end)

  -- Drag hint
  local dragHint = frame:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
  dragHint:ClearAllPoints()
  dragHint:SetPoint("BOTTOM", frame, "BOTTOM", 0, 30)
  dragHint:SetText(L["DRAG_HINT"])

  -- Reset position
  ---@diagnostic disable-next-line: redundant-parameter
  local resetBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
  resetBtn:ClearAllPoints()
  resetBtn:SetPoint("BOTTOM", frame, "BOTTOM", 0, 5)
  resetBtn:SetSize(140, 24)
  resetBtn:SetText(L["RESET_POSITION"])
  resetBtn:SetScript("OnClick", function()
    TPB_DB.pos.point = DEFAULTS.pos.point
    TPB_DB.pos.x = DEFAULTS.pos.x
    TPB_DB.pos.y = DEFAULTS.pos.y
    if type(applyPositionAndSizeFromDB) == "function" then
      applyPositionAndSizeFromDB()
    else
      btn:SetSize(TPB_DB.size.w, TPB_DB.size.h)
      btn:ClearAllPoints()
      btn:SetPoint(TPB_DB.pos.point, UIParent, TPB_DB.pos.point, TPB_DB.pos.x, TPB_DB.pos.y)
    end
    if not btn:IsShown() then btn:Show() end
    if not currentIconID then
      currentIconID = TEST_PORTAL_ICON
      if overlayText and overlayText.SetText then
        UpdateOverlayText({name = "Test Dungeon"})
      end
    end
    if icon and icon.SetTexture then icon:SetTexture(currentIconID) end
    if icon and icon.Show then icon:Show() end
    if icon and icon.SetDesaturated then icon:SetDesaturated(false) end
    Print(L["POSITION_RESET"])
  end)

  frame:SetScript("OnShow", function()
    -- Sync size slider/label
    if sizeWidgetStandalone and sizeWidgetStandalone.SetValue then
      sizeWidgetStandalone:SetValue((TPB_DB and TPB_DB.fontSize) or DEFAULTS.fontSize)
      if sizeValueLabelStandalone then
        sizeValueLabelStandalone:SetText(tostring((TPB_DB and TPB_DB.fontSize) or
          DEFAULTS.fontSize))
      end
    elseif sizeSliderStandalone and sizeSliderStandalone.SetValue then
      sizeSliderStandalone:SetValue((TPB_DB and TPB_DB.fontSize) or DEFAULTS.fontSize)
      if sizeValueLabelStandalone then
        sizeValueLabelStandalone:SetText(tostring((TPB_DB and TPB_DB.fontSize) or
          DEFAULTS.fontSize))
      end
    end
    if fontWidgetRefStandalone and fontWidgetRefStandalone.SetValue then
      fontWidgetRefStandalone:SetValue(TPB_DB.fontKey or "")
    end
    -- Sync "only when ready" checkbox
    if readyOnlyCheck then
      readyOnlyCheck:SetChecked(TPB_DB.onlyWhenReady or false)
    end
    refreshPreview()
    applyFontFromDB()
  end)

  if lsm and lsm.RegisterCallback and not standaloneLSMCallbacksHooked then
    standaloneLSMCallbacksHooked = true
    lsm:RegisterCallback("LibSharedMedia_Registered", function(event, mediatype, key)
      if mediatype == "font" then
        refreshPreview()
      end
    end)
    lsm:RegisterCallback("LibSharedMedia_SetGlobal", function(event, mediatype)
      if mediatype == "font" then
        applyFontFromDB()
        refreshPreview()
      end
    end)
  end

  standaloneOptionsFrame = frame
  frame:Show()
end
local function InitOptionsPanel()
  if optionsPanel then return end
  optionsPanel = CreateFrame("Frame", nil)
  local displayName = "|TInterface\\AddOns\\LFGTeleportButtonMidnight\\Media\\portal.blp:16|t LFG Teleport Button"
  optionsPanel.name = displayName

  -- Open Config Button
  ---@diagnostic disable-next-line: redundant-parameter
  local openStandaloneBtn = CreateFrame("Button", nil, optionsPanel, "UIPanelButtonTemplate")
  openStandaloneBtn:SetPoint("CENTER", optionsPanel, "CENTER", 0, 0)
  openStandaloneBtn:SetSize(200, 48)
  openStandaloneBtn:SetText(L["OPEN_CONFIG"])
  do
    local fs = openStandaloneBtn:GetFontString()
    if fs and fs.GetFont then
      local fontPath, fontSize, fontFlags = fs:GetFont()
      local newSize = floor((fontSize or 14) * 2)
      fs:SetFont(fontPath or DEFAULT_FONT_NAME, newSize, fontFlags)
    end
  end
  openStandaloneBtn:SetScript("OnClick", function()
    EnsureStandaloneOptions()
  end)

  if Settings and Settings.RegisterCanvasLayoutCategory then
    local category = Settings.RegisterCanvasLayoutCategory(optionsPanel, optionsPanel.name)
    Settings.RegisterAddOnCategory(category)
  else
    -- Settings API check
    -- as we are strictly targeting modern retail.
    Print(L["SETTINGS_NOT_FOUND"])
  end
end

local function updateMovable()
  -- Drag handling
  btn:SetScript("OnDragStart", function(self)
    if IsControlKeyDown and IsControlKeyDown() and not IsInCombat() then
      self:StartMoving()
    end
  end)
  btn:SetScript("OnDragStop", function(self)
    self:StopMovingOrSizing()
    local p, _, _, x, y = self:GetPoint()
    TPB_DB.pos.point = p; TPB_DB.pos.x = x; TPB_DB.pos.y = y
  end)
end

-- ALT+RightClick to hide
btn:SetScript("OnMouseUp", function(self, mouseButton)
  -- Play sound on unlearned click
  if mouseButton == "LeftButton" then
    if currentSpellID and not isTeleportKnown(currentSpellID) and not notLearnedSoundPlayed then
      if PlaySoundFile then
        PlaySoundFile(NOT_LEARNED_SFX, "Master")
      end
      notLearnedSoundPlayed = true
      -- Show Git Gud for 3 seconds
      if GetTime then showGitGudUntil = GetTime() + 3 end
      -- Update overlay to reflect the new message once the sound condition is met
      local title = (currentDungeon and currentDungeon.name) or "Teleport"
      overlayText:SetText(getOverlayPrefix() .. title .. "|r" .. getUnknownSuffix(false))
      -- Schedule text refresh after Git Gud expires
      if C_Timer and C_Timer.After then
        C_Timer.After(3.1, function()
          if currentDungeon and btn:IsShown() then
            local t = currentDungeon.name or "Teleport"
            local k = currentSpellID and isTeleportKnown(currentSpellID)
            overlayText:SetText(getOverlayPrefix() .. t .. "|r" .. getUnknownSuffix(k))
          end
        end)
      end
    end
  end
  if mouseButton == "RightButton" and (IsAltKeyDown and IsAltKeyDown()) then
    if GameTooltip and GameTooltip:IsOwned(self) then GameTooltip:Hide() end
    userDismissed = true
    -- Clear all state when user manually dismisses
    currentApplicationID = nil
    currentDungeon = nil
    currentSpellID = nil
    currentIconID = nil
    pendingApply = false
    pendingSecureUpdate = false
    suppressedByCombat = false
    teleportedForListing = false
    pendingInvites = {}
    if IsInCombat() then
      -- Avoid protected Show/Hide in combat; fade out instead
      self:SetAlpha(0)
      suppressedByCombat = true
    else
      self:Hide()
    end
  end
end)

-- Tooltip
btn:SetScript("OnEnter", function(self)
  if not GameTooltip then return end
  GameTooltip:SetOwner(self, "ANCHOR_BOTTOMRIGHT")
  GameTooltip:SetText(L["TOOLTIP_LEFT_CLICK"], 1, 1, 1)
  GameTooltip:AddLine(L["TOOLTIP_ALT_RIGHT"], 1, 1, 1)
  GameTooltip:AddLine(L["TOOLTIP_CTRL_DRAG"], 0.8, 0.8, 0.8)
  -- No chat command hints
  GameTooltip:Show()
  if hoverGlow then hoverGlow:Show() end
end)

btn:SetScript("OnLeave", function()
  if GameTooltip then GameTooltip:Hide() end
  if hoverGlow then hoverGlow:Hide() end
end)


local function updateKnownVisual()
  if not currentSpellID then return end
  -- Keep button fully opaque
  btn:SetAlpha(1)
  local known = isTeleportKnown(currentSpellID)
  if currentIconID then
    icon:SetTexture(currentIconID)
  end
  icon:Show()
  if icon and icon.SetDesaturated then icon:SetDesaturated(not known) end
end


IsInCombat = function()
  return (InCombatLockdown and InCombatLockdown())
end

UpdateOverlayText = function(dungeon)
  local title = (dungeon and dungeon.name) or "Teleport"
  local known = currentSpellID and isTeleportKnown(currentSpellID)
  local suffix = getUnknownSuffix(known)
  if overlayText and getOverlayPrefix then
    overlayText:SetText(getOverlayPrefix() .. title .. "|r" .. suffix)
  end
end

UpdateButtonDisplay = function(spellID)
  btn:SetAttribute("type2", nil)
  if spellID then
    local known = isTeleportKnown(spellID)
    if known then
      btn:SetAttribute("type", "spell")
      btn:SetAttribute("spell", spellID)
    else
      btn:SetAttribute("type", nil)
      btn:SetAttribute("spell", nil)
    end
  else
    btn:SetAttribute("type", nil)
    btn:SetAttribute("spell", nil)
  end
  updateKnownVisual()
  btn:ClearAllPoints()
  btn:SetPoint(TPB_DB.pos.point, UIParent, TPB_DB.pos.point, TPB_DB.pos.x, TPB_DB.pos.y)
  btn:SetSize(TPB_DB.size.w, TPB_DB.size.h)
  btn:Show()
end

local function updateCooldown()
  if not currentSpellID then return end

  -- "Show only when ready" mode: hide button when on cooldown, show when ready
  if TPB_DB.onlyWhenReady and currentDungeon and not userDismissed then
    local ready = isTeleportReady(currentSpellID)
    if not IsInCombat() then
      if ready and not btn:IsShown() then
        -- Cooldown just finished — show the button now
        currentIconID = C_Spell.GetSpellTexture(currentSpellID) or FALLBACK_PORTAL_ICON
        UpdateButtonDisplay(currentSpellID)
        UpdateOverlayText(currentDungeon)
        Print(L["TELEPORT_READY"] .. (currentDungeon.name or "unknown"))
      elseif not ready and btn:IsShown() then
        -- Spell went on cooldown — hide the button
        btn:Hide()
      end
    end
    if not ready then return end -- Don't update cooldown display if not ready
  end

  -- Wrap entire cooldown logic in pcall to handle "secret values" during combat
  local success = pcall(function()
    local info = C_Spell.GetSpellCooldown(currentSpellID)
    if info and info.startTime and info.duration and info.duration > 1.5 then
      cd:SetCooldown(info.startTime, info.duration)
    elseif cd.Clear then
      cd:Clear()
    end
  end)
  if not success and cd.Clear then
    cd:Clear()
  end

  -- Update on-button text overlay
  UpdateOverlayText(currentDungeon)
  -- Note: Combat lockdown is now handled in applyDungeon, so we don't hide here
  -- This was causing the button to disappear when SPELL_UPDATE_COOLDOWN fired during combat
  if suppressedByCombat and not IsInCombat() then
    btn:SetAlpha(1)
    suppressedByCombat = false
  end
end

local function applyDungeon(d)
  currentDungeon = d
  currentSpellID = resolveSpellID(d)

  if IsInCombat() then
    pendingApply = true
    return
  end

  -- Don't show if user dismissed the button
  if userDismissed then
    return
  end

  -- "Show only when ready" check: suppress if spell is not known or on cooldown
  if TPB_DB.onlyWhenReady then
    if not isTeleportReady(currentSpellID) then
      -- Don't show button; it will appear when cooldown finishes (via SPELL_UPDATE_COOLDOWN)
      return
    end
  end

  currentIconID = C_Spell.GetSpellTexture(currentSpellID) or FALLBACK_PORTAL_ICON
  UpdateButtonDisplay(currentSpellID)
  updateCooldown()
  UpdateOverlayText(d)
  Print(L["TELEPORT_READY"] .. (d.name or "unknown"))
end

-- Slash: /tpbutton lock  (toggle)
SLASH_TPBUTTON1 = "/tpbutton"
-- Forward declaration defined earlier; do not redeclare here
-- local applyPositionAndSizeFromDB

SlashCmdList.TPBUTTON = function(msg)
  msg = (msg or ""):gsub("^%s+", ""):gsub("%s+$", "")
  local lower = msg:lower()

  if lower == "lock" then
    TPB_DB.locked = not TPB_DB.locked
    updateMovable()
    Print(TPB_DB.locked and L["LOCKED_MSG"] or L["UNLOCKED_MSG"])
  elseif lower == "show" then
    if currentDungeon then
      -- Reset dismissal state for show command
      userDismissed = false
      applyDungeon(currentDungeon)
    else
      Print(L["NO_DUNGEON_YET"])
    end
  elseif lower:find("^test") then
    local idx = tonumber(msg:match("^%s*test%s+(%d+)%s*$"))
    if idx and DUNGEONS[idx] then
      -- Reset dismissal state for test commands
      userDismissed = false
      applyDungeon(DUNGEONS[idx])
    else
      Print(L["USAGE_TEST"] .. #DUNGEONS .. ")")
      for i, d in ipairs(DUNGEONS) do
        local resolved = resolveSpellID(d)
        local known = isTeleportKnown(resolved)
        local spellName = getSpellName(resolved)
        local altInfo = ""
        if d.altSpells then
          for _, altID in ipairs(d.altSpells) do
            altInfo = altInfo .. ", alt: " .. tostring(altID) .. "(" .. tostring(isTeleportKnown(altID)) .. ")"
          end
        end
        Print(i ..
          ": " ..
          d.name ..
          " (spell: " .. tostring(d.spell) .. ", resolved: " .. tostring(resolved) .. ", known: " .. tostring(known) .. altInfo .. ", spellName: " ..
          (spellName or "nil") .. ")")
      end
    end
  elseif lower == "debug" then
    Print("=== Dungeon Debug Info ===")
    for i, d in ipairs(DUNGEONS) do
      local resolved = resolveSpellID(d)
      local known = isTeleportKnown(resolved)
      local spellName = getSpellName(resolved)
      local iconID = C_Spell.GetSpellTexture(resolved) or "N/A"
      Print(i .. ": " .. d.name)
      Print("  Primary Spell ID: " .. tostring(d.spell) .. " (known: " .. tostring(isTeleportKnown(d.spell)) .. ")")
      if d.altSpells then
        for _, altID in ipairs(d.altSpells) do
          Print("  Alt Spell ID: " .. tostring(altID) .. " (known: " .. tostring(isTeleportKnown(altID)) .. ", name: " .. tostring(getSpellName(altID) or "nil") .. ")")
        end
      end
      Print("  Resolved to: " .. tostring(resolved))
      Print("  -> isTeleportKnown: " .. tostring(known))
      Print("  -> Name: " .. (spellName or "nil"))
      Print("  -> Icon (from API): " .. tostring(iconID))
    end
    Print("=== Activity Mappings ===")
    for actID, dungeonIdx in pairs(ACTIVITY_TO_DUNGEON) do
      local d = DUNGEONS[dungeonIdx]
      Print("Activity " .. actID .. " -> " .. (d and d.name or "INVALID INDEX " .. dungeonIdx))
    end
  elseif lower == "reset" then
    TPB_DB.pos.point = DEFAULTS.pos.point
    TPB_DB.pos.x = DEFAULTS.pos.x
    TPB_DB.pos.y = DEFAULTS.pos.y
    applyPositionAndSizeFromDB()
    Print(L["POSITION_RESET"])
  elseif lower == "state" or lower == "status" then
    Print("=== Button State ===")
    Print("IsShown: " .. tostring(btn:IsShown()))
    Print("Alpha: " .. tostring(btn:GetAlpha()))
    Print("userDismissed: " .. tostring(userDismissed))
    Print("pendingApply: " .. tostring(pendingApply))
    Print("pendingSecureUpdate: " .. tostring(pendingSecureUpdate))
    Print("suppressedByCombat: " .. tostring(suppressedByCombat))
    Print("InCombat: " .. tostring(InCombatLockdown and IsInCombat() or false))
    Print("currentDungeon: " .. tostring(currentDungeon and currentDungeon.name or "nil"))
    Print("currentSpellID: " .. tostring(currentSpellID))
    Print("currentIconID: " .. tostring(currentIconID))
    Print("currentApplicationID: " .. tostring(currentApplicationID))
    Print("onlyWhenReady: " .. tostring(TPB_DB.onlyWhenReady))
    if currentSpellID then
      Print("Spell Known: " .. tostring(isTeleportKnown(currentSpellID)))
      Print("Spell Name: " .. tostring(getSpellName(currentSpellID)))
    end
  else
    Print(L["SLASH_COMMANDS"])
  end
end

-- =============================================
-- M+ UI Overlay Buttons (like DungeonTeleportButtons)
-- =============================================
local overlayButtons = {}

local function UpdateOverlayTooltip(parent, spellID, initialize)
  if not initialize and not GameTooltip:IsOwned(parent) then return end

  local Button_OnEnter = parent:GetScript("OnEnter")
  if not Button_OnEnter then return end

  local name = C_Spell.GetSpellName(spellID)
  Button_OnEnter(parent)

  if C_SpellBook and C_SpellBook.IsSpellKnownOrInSpellBook and C_SpellBook.IsSpellKnownOrInSpellBook(spellID) then
    GameTooltip:AddLine(" ")
    GameTooltip:AddLine(name or TELEPORT_TO_DUNGEON)
    -- Wrap cooldown check in pcall to handle "secret values" during combat
    local showReady = true
    pcall(function()
      local cooldownInfo = C_Spell.GetSpellCooldown(spellID)
      if cooldownInfo and cooldownInfo.duration and cooldownInfo.duration > 0 then
        local remaining = cooldownInfo.startTime + cooldownInfo.duration - GetTime()
        if remaining > 0 then
          GameTooltip:AddLine(SecondsToTime(math.ceil(remaining)), 1, 0, 0)
          showReady = false
        end
      end
    end)
    if showReady then
      GameTooltip:AddLine(READY, 0, 1, 0)
    end
  else
    GameTooltip:AddLine(" ")
    GameTooltip:AddLine(name or TELEPORT_TO_DUNGEON)
    GameTooltip:AddLine(SPELL_FAILED_NOT_KNOWN, 1, 0, 0)
  end

  GameTooltip:Show()
  C_Timer.After(1, function()
    if parent and parent:IsShown() and parent:IsMouseOver() then
      UpdateOverlayTooltip(parent, spellID)
    end
  end)
end

local function CreateOverlayButton(parent, spellID)
  if not spellID then return end

  local button = overlayButtons[parent] or CreateFrame("Button", nil, parent, "InsecureActionButtonTemplate")
  button:SetAllPoints(parent)
  button:RegisterForClicks("AnyDown", "AnyUp")
  button:SetAttribute("type", "spell")
  button:SetAttribute("spell", spellID)
  button:SetScript("OnEnter", function() UpdateOverlayTooltip(parent, spellID, true) end)
  button:SetScript("OnLeave", function() if GameTooltip:IsOwned(parent) then GameTooltip:Hide() end end)

  overlayButtons[parent] = button
end

-- Resolve the overlay spell for a map ID, checking alt spells if primary isn't known
local function resolveOverlaySpell(mapID)
  local primarySpell = MAP_ID_TO_SPELL[mapID]
  if not primarySpell then return nil end
  if isTeleportKnown(primarySpell) then return primarySpell end
  -- Check if any dungeon entry has alt spells for this primary spell
  for _, d in ipairs(DUNGEONS) do
    if d.spell == primarySpell and d.altSpells then
      for _, altID in ipairs(d.altSpells) do
        if isTeleportKnown(altID) then return altID end
      end
    end
  end
  return primarySpell -- fallback
end

local function CreateAllOverlayButtons()
  if IsInCombat() then return end
  if not ChallengesFrame then return end
  if not ChallengesFrame.DungeonIcons then return end

  for _, dungeonIcon in next, ChallengesFrame.DungeonIcons do
    CreateOverlayButton(dungeonIcon, resolveOverlaySpell(dungeonIcon.mapID))
  end
end

local function InitializeChallengesUI()
  if not C_AddOns.IsAddOnLoaded("Blizzard_ChallengesUI") then return false end

  if ChallengesFrame and type(ChallengesFrame.Update) == "function" then
    hooksecurefunc(ChallengesFrame, "Update", CreateAllOverlayButtons)
  end
  CreateAllOverlayButtons()
  return true
end

-- Track whether we're inside a M+ run (between START and COMPLETED)
local inChallengeMode = false

-- Event handler
local f = CreateFrame("Frame")
f:RegisterEvent("ADDON_LOADED")
f:RegisterEvent("PLAYER_LOGIN")
f:RegisterEvent("LFG_LIST_APPLICATION_STATUS_UPDATED")
f:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED")
f:RegisterEvent("SPELL_UPDATE_COOLDOWN")
f:RegisterEvent("SPELLS_CHANGED")
f:RegisterEvent("PLAYER_REGEN_ENABLED")
f:RegisterEvent("PLAYER_REGEN_DISABLED")
f:RegisterEvent("GROUP_ROSTER_UPDATE")
f:RegisterEvent("CHALLENGE_MODE_START")
f:RegisterEvent("CHALLENGE_MODE_COMPLETED")
f:RegisterEvent("LFG_LIST_ACTIVE_ENTRY_UPDATE")
f:RegisterEvent("PLAYER_ENTERING_WORLD")

applyPositionAndSizeFromDB = function()
  btn:SetSize(TPB_DB.size.w, TPB_DB.size.h)
  btn:ClearAllPoints()
  btn:SetPoint(TPB_DB.pos.point, UIParent, TPB_DB.pos.point, TPB_DB.pos.x, TPB_DB.pos.y)
end

local nonMatchPrinted = {}


local function CheckActiveGroup()
  if IsInCombat() then return end
  if userDismissed then return end
  -- Don't show during an active M+ run
  if inChallengeMode then return end

  -- Wrap in pcall: C_LFGList returns tainted values during raids
  local ok, err = pcall(function()
    local info = C_LFGList.GetActiveEntryInfo()
    if info then
      -- Try activityIDs first (array of all activities), then single activityID
      local d = nil
      if info.activityIDs then
        d = matchDungeonFromActivityIDs(info.activityIDs)
      end
      if not d then
        d = matchDungeonFromActivity(info.activityID, info.name)
      end
      if d and currentDungeon ~= d then
        -- The group's listed dungeon changed! Reset the teleport flag so the button can appear.
        teleportedForListing = false
      end
      -- Show button if: new dungeon detected, OR same dungeon but button is hidden
      if d and not teleportedForListing and (currentDungeon ~= d or not btn:IsShown()) then
        currentSource = "party"
        applyDungeon(d)
      end
    else
      -- No active listing: clear stale dungeon state from a previous listing
      -- This prevents currentDungeon from persisting after a listing is removed
      if currentDungeon and currentSource == "party" then
        currentDungeon = nil
        currentSpellID = nil
        currentIconID = nil
      end
      -- Listing removed, reset teleported flag so button can appear for next listing
      teleportedForListing = false
    end
  end)
  if not ok then
    Print("CheckActiveGroup error (tainted data?): " .. tostring(err))
  end
end

local events = {}

function events:ADDON_LOADED(name)
  if name == ADDON_NAME then
    TPB_DB = mergeDefaults(TPB_DB, DEFAULTS)
    -- Always start locked on load
    TPB_DB.locked = true
    updateMovable()
    -- Try to initialize M+ overlay if ChallengesUI is already loaded
    InitializeChallengesUI()
  elseif name == "Blizzard_ChallengesUI" then
    -- ChallengesUI just loaded, initialize our overlay buttons
    InitializeChallengesUI()
  end
end

function events:PLAYER_LOGIN()
  applyPositionAndSizeFromDB()
  applyFontFromDB()
  InitOptionsPanel()
  -- Reset per-login sound flag
  notLearnedSoundPlayed = false
  -- Try to initialize M+ overlay (in case it loaded before us)
  InitializeChallengesUI()
  -- Start 5s ticker for host/active group check
  C_Timer.NewTicker(5, CheckActiveGroup)
end

function events:LFG_LIST_APPLICATION_STATUS_UPDATED(searchResultID, newStatus, oldStatus, groupName)
  if newStatus == "invited" then
    -- Store dungeon info when invited, but don't show button yet
    -- Reset user dismissal state for new invitations
    userDismissed = false
    local info
    -- Resolve directly via searchResultID (first event parameter)
    if searchResultID and type(searchResultID) == "number" and searchResultID > 0 then
      pcall(function()
        info = C_LFGList.GetSearchResultInfo(searchResultID)
      end)
    end

    local listName = info and info.name or ""
    if info and (not listName or listName == "") then
      listName = (info.comment or "") .. " " .. (info.voiceChat or "")
    end
    local d = nil
    if info and info.activityIDs then
      d = matchDungeonFromActivityIDs(info.activityIDs)
    end
    if not d then
      d = matchDungeonFromActivity(info and info.activityID, listName)
    end
    if d then
      -- Store dungeon info keyed by searchResultID - supports multiple pending invites
      pendingInvites[searchResultID] = d
    else
      -- Rate-limit non-match prints
      local key = searchResultID or groupName or "unknown"
      if not nonMatchPrinted[key] then
        nonMatchPrinted[key] = true
        Print(L["NO_MATCH"])
      end
    end
  elseif newStatus == "inviteaccepted" or (oldStatus == "invited" and newStatus ~= "invited" and newStatus ~= "declined" and newStatus ~= "declined_full" and newStatus ~= "declined_delisted" and newStatus ~= "cancelled" and newStatus ~= "failed" and newStatus ~= "timedout" and newStatus ~= "invitedeclined") then
    -- Show button when invite is accepted
    -- Look up the correct dungeon for THIS specific searchResultID
    local d = pendingInvites[searchResultID]
    if d then
      currentSource = "invite"
      currentApplicationID = searchResultID
      applyDungeon(d)
      -- Clear all pending invites since we joined a group
      pendingInvites = {}
    end
  elseif newStatus == "declined" or newStatus == "declined_full" or newStatus == "declined_delisted" or newStatus == "cancelled" or newStatus == "failed" or newStatus == "timedout" or newStatus == "invitedeclined" then
    -- Clear rate-limit entry
    nonMatchPrinted[searchResultID or groupName] = nil
    -- Clear this specific pending invite
    pendingInvites[searchResultID] = nil
    -- Only hide the button if this status change is for the CURRENT invitation
    -- When you apply to multiple dungeons and get invited to one, WoW cancels all other applications
    -- We don't want to hide the button when those OTHER applications get cancelled
    if searchResultID == currentApplicationID then
      -- Clear all state since this invitation is done
      currentApplicationID = nil
      currentDungeon = nil
      currentSpellID = nil
      currentIconID = nil
      pendingApply = false
      pendingSecureUpdate = false
      suppressedByCombat = false
      btn:Hide()
    end
  end
end

function events:UNIT_SPELLCAST_SUCCEEDED(unit, _, spellID)
  if unit ~= "player" then return end
  -- Check if the cast spell matches the current spell or any alt spell of the current dungeon
  local isOurSpell = currentSpellID and spellID == currentSpellID
  if not isOurSpell and currentDungeon and currentDungeon.altSpells then
    for _, altID in ipairs(currentDungeon.altSpells) do
      if spellID == altID then isOurSpell = true; break end
    end
  end
  if not isOurSpell and currentDungeon and spellID == currentDungeon.spell then
    isOurSpell = true
  end
  -- Also check all DUNGEONS table entries in case currentDungeon was already cleared
  -- (e.g. by a race with another event) but the spell is still one of ours
  if not isOurSpell then
    for _, d in ipairs(DUNGEONS) do
      if spellID == d.spell then
        isOurSpell = true
        break
      end
      if d.altSpells then
        for _, altID in ipairs(d.altSpells) do
          if spellID == altID then isOurSpell = true; break end
        end
        if isOurSpell then break end
      end
    end
  end
  if isOurSpell then
    -- Mark that we already teleported for the current listing
    -- so CheckActiveGroup won't re-show the button
    teleportedForListing = true
    -- Clear all state since we're done with this teleport
    currentApplicationID = nil
    currentDungeon = nil
    currentSpellID = nil
    currentIconID = nil
    pendingApply = false
    pendingSecureUpdate = false
    pendingInvites = {}
    if IsInCombat() then
      -- Can't Hide() a secure button in combat; fade out and defer
      btn:SetAlpha(0)
      suppressedByCombat = true
    else
      suppressedByCombat = false
      btn:Hide()
    end
  end
end

function events:SPELL_UPDATE_COOLDOWN()
  -- Only process if button is shown and we have a current spell
  if currentSpellID and btn:IsShown() then
    updateCooldown()
    if showGitGudUntil then
      local now = GetTime and GetTime() or 0
      if now < showGitGudUntil then
        local title = (currentDungeon and currentDungeon.name) or "Teleport"
        local known = isTeleportKnown(currentSpellID)
        overlayText:SetText(getOverlayPrefix() .. title .. "|r" .. getUnknownSuffix(known))
      end
    end
  end
end

function events:SPELLS_CHANGED()
  -- Only process if we have a current spell being tracked
  if not currentSpellID then return end

  if IsInCombat() then
    pendingSecureUpdate = true
    -- Hide visuals to avoid interaction in combat
    btn:SetAlpha(0)
    suppressedByCombat = true
  else
    UpdateButtonDisplay(currentSpellID)
    UpdateOverlayText(currentDungeon)
    -- Ensure any font changes apply to the live overlay
    applyFontFromDB()
  end
end

function events:PLAYER_REGEN_DISABLED()
  -- Combat started - auto-hide button if shown
  if btn:IsShown() and btn:GetAlpha() > 0 and currentDungeon then
    btn:SetAlpha(0)
    hiddenForCombat = true
  end
end

function events:PLAYER_REGEN_ENABLED()
  -- Combat ended - restore button if it was auto-hidden
  if hiddenForCombat then
    hiddenForCombat = false
    if currentDungeon and not userDismissed then
      btn:SetAlpha(1)
    else
      -- Dungeon was cleared during combat (e.g. teleport succeeded), hide properly now
      btn:Hide()
    end
  end

  -- Only process if we have pending updates or suppressed state
  if not (pendingApply or pendingSecureUpdate or suppressedByCombat) then
    -- Nothing else to do
  else
    -- Safe to apply any deferred secure updates now
    if pendingApply and currentDungeon then
      pendingApply = false
      applyDungeon(currentDungeon)
    end
    if pendingSecureUpdate and currentSpellID then
      pendingSecureUpdate = false
      UpdateButtonDisplay(currentSpellID)
    end
    if suppressedByCombat then
      suppressedByCombat = false
      -- If no dungeon is tracked (teleport already succeeded), hide the button
      -- instead of restoring it to visible
      if currentDungeon and not userDismissed then
        btn:SetAlpha(1)
      else
        btn:Hide()
      end
    end
  end
end

function events:GROUP_ROSTER_UPDATE()
  local inGroup = (IsInGroup and IsInGroup()) or (IsInRaid and IsInRaid())

  -- Reset dismiss state when player leaves a group so button can appear for next party
  if not inGroup then
    if userDismissed then
      userDismissed = false
    end
    teleportedForListing = false
    return
  end

  -- Check if player joined a group and we have pending invites
  -- This handles cases where the status change event doesn't fire properly
  if not currentDungeon then
    -- Check if we have any pending invites
    local resultID, dungeon = next(pendingInvites)
    if resultID and dungeon then
      currentSource = "invite"
      currentApplicationID = resultID
      applyDungeon(dungeon)
      pendingInvites = {}
    else
      -- No pending invites — check if the group is already listed in LFG
      -- (e.g. joining an already-listed group won't fire LFG_LIST_ACTIVE_ENTRY_UPDATE)
      CheckActiveGroup()
    end
  end
end

function events:CHALLENGE_MODE_START()
  -- Mythic+ key activated — hide the teleport button and clear all state
  inChallengeMode = true
  currentApplicationID = nil
  currentDungeon = nil
  currentSpellID = nil
  currentIconID = nil
  pendingApply = false
  pendingSecureUpdate = false
  teleportedForListing = false
  pendingInvites = {}
  if not IsInCombat() then
    btn:Hide()
    suppressedByCombat = false
  else
    btn:SetAlpha(0)
    suppressedByCombat = true
  end
end

function events:CHALLENGE_MODE_COMPLETED()
  -- Clean up when run completes and allow re-listing detection
  inChallengeMode = false
  currentApplicationID = nil
  currentDungeon = nil
  currentSpellID = nil
  currentIconID = nil
  pendingApply = false
  pendingSecureUpdate = false
  teleportedForListing = false
  pendingInvites = {}
  userDismissed = false -- Reset so button can appear for next listing
  if not IsInCombat() then
    btn:Hide()
    suppressedByCombat = false
  else
    btn:SetAlpha(0)
    suppressedByCombat = true
  end
  -- Schedule re-checks: group often re-lists shortly after completion
  if C_Timer and C_Timer.After then
    C_Timer.After(3, CheckActiveGroup)
    C_Timer.After(8, CheckActiveGroup)
    C_Timer.After(15, CheckActiveGroup)
  end
end

function events:LFG_LIST_ACTIVE_ENTRY_UPDATE()
  -- Fires when your group's LFG listing is created, updated, or removed
  -- This is the instant detection path (instead of waiting for the 5s poll)
  CheckActiveGroup()
end

function events:PLAYER_ENTERING_WORLD()
  -- Check if the player entered the dungeon instance the button is showing for
  if not currentDungeon then return end

  local instanceName, instanceType, difficultyID, _, _, _, _, instanceMapID = GetInstanceInfo()
  -- Only care about Mythic difficulty (difficultyID 23) — keys are inserted inside
  if instanceType ~= "party" or difficultyID ~= 23 then return end

  -- Match by instance map ID first (locale-independent and most reliable)
  local matched = false
  if currentDungeon.instanceID and instanceMapID then
    matched = (currentDungeon.instanceID == instanceMapID)
  end

  -- Fallback: match by instance name against dungeon triggers
  if not matched and instanceName then
    local normalizedName = norm(instanceName)
    for _, trig in ipairs(currentDungeon.triggers) do
      if normalizedName:find(trig, 1, true) then
        matched = true
        break
      end
    end
  end

  if matched then
    Print(format(L["ENTERED_HIDING"], currentDungeon.name or "dungeon"))
    -- Mark as teleported so CheckActiveGroup won't re-show
    if currentSource == "party" then
      teleportedForListing = true
    end
    currentApplicationID = nil
    currentDungeon = nil
    currentSpellID = nil
    currentIconID = nil
    pendingApply = false
    pendingSecureUpdate = false
    pendingInvites = {}
    if not IsInCombat() then
      btn:Hide()
      suppressedByCombat = false
    else
      btn:SetAlpha(0)
      suppressedByCombat = true
    end
  end
end

f:SetScript("OnEvent", function(self, event, ...)
  if events[event] then
    events[event](events, ...)
  end
end)
