local parentAddonName = "EnhanceQoL"
local addon = select(2, ...)
if _G[parentAddonName] then
	addon = _G[parentAddonName]
else
	error(parentAddonName .. " is not loaded")
end

local L = addon.L

-- luacheck: globals EQOLIgnoreFrame EQOLIgnoreFrame_OnLoad HybridScrollFrame_CreateButtons DeclineGuildInvite MenuUtil
local MU = MenuUtil
local issecretvalue = _G.issecretvalue
local Ignore = addon.Ignore or {}
addon.Ignore = Ignore

local function ensureFriendsFrame()
	if not addon.db or not (addon.db.ignoreAnchorFriendsFrame or addon.db.ignoreAttachFriendsFrame) then return end
	if not FriendsFrame then
		local loaded = false
		if C_AddOns and C_AddOns.LoadAddOn then
			loaded = C_AddOns.LoadAddOn("Blizzard_FriendsFrame")
		elseif LoadAddOn then
			loaded = LoadAddOn("Blizzard_FriendsFrame")
		end
		if not loaded then return end
	end
	if FriendsFrame and not Ignore.friendsHookInstalled then
		FriendsFrame:HookScript("OnShow", function()
			if addon.db.ignoreAttachFriendsFrame and Ignore.enabled then
				EQOLIgnoreFrame:Show()
				Ignore:UpdateAnchor()
			end
		end)
		FriendsFrame:HookScript("OnHide", function()
			if addon.db.ignoreAttachFriendsFrame then EQOLIgnoreFrame:Hide() end
		end)
		Ignore.friendsHookInstalled = true
	end
end

function Ignore:SavePosition()
	if not self.frame or not addon or not addon.db then return end
	if addon.db.ignoreAnchorFriendsFrame then return end
	local point, _, _, xOfs, yOfs = self.frame:GetPoint()
	addon.db.ignoreFramePoint = point
	addon.db.ignoreFrameX = xOfs
	addon.db.ignoreFrameY = yOfs
end
EnhanceQoL_IgnoreDB = EnhanceQoL_IgnoreDB or {}
Ignore.entries = EnhanceQoL_IgnoreDB
Ignore.entryLookup = Ignore.entryLookup or {}
Ignore.selectedIndex = nil
Ignore.searchText = Ignore.searchText or ""
Ignore.addFrame = Ignore.addFrame or nil

Ignore.enabled = Ignore.enabled or false
Ignore.registeredFilters = Ignore.registeredFilters or {}
Ignore.hooksInstalled = Ignore.hooksInstalled or false
Ignore.friendsHookInstalled = Ignore.friendsHookInstalled or false

local LOGIN_FRAME = CreateFrame("Frame")
local CHAT_EVENTS = {
	"CHAT_MSG_WHISPER",
	"CHAT_MSG_CHANNEL",
	"CHAT_MSG_SAY",
	"CHAT_MSG_YELL",
	"CHAT_MSG_EMOTE",
	"CHAT_MSG_BN_WHISPER",
	"CHAT_MSG_GUILD",
	"CHAT_MSG_OFFICER",
	"CHAT_MSG_PARTY",
	"CHAT_MSG_PARTY_LEADER",
	"CHAT_MSG_RAID",
	"CHAT_MSG_RAID_LEADER",
	"CHAT_MSG_RAID_WARNING",
	"CHAT_MSG_INSTANCE_CHAT",
	"CHAT_MSG_INSTANCE_CHAT_LEADER",
}
local INTERACTION_EVENTS = {
	"PARTY_INVITE_REQUEST",
	"DUEL_REQUESTED",
	"PET_BATTLE_PVP_DUEL_REQUESTED",
	"TRADE_SHOW",
	"GUILD_INVITE_REQUEST",
}

Ignore.filtered = {}

local ignoreDaysFromTodayCache = setmetatable({}, { __mode = "k" })
local ignoreDaysFromTodayCacheStamp

local function getIgnoreDaysFromTodayCacheStamp()
	local stamp = date("%Y-%m-%d")
	if ignoreDaysFromTodayCacheStamp ~= stamp then ignoreDaysFromTodayCacheStamp = stamp end
	return ignoreDaysFromTodayCacheStamp
end

local function getDaysFromTodayCached(entry)
	if not (entry and entry.date) then return 0 end
	local cacheStamp = getIgnoreDaysFromTodayCacheStamp()
	local cached = ignoreDaysFromTodayCache[entry]
	if cached and cached.stamp == cacheStamp and cached.date == entry.date then return cached.days end
	local days = Ignore.daysFromToday(entry.date)
	ignoreDaysFromTodayCache[entry] = {
		date = entry.date,
		days = days,
		stamp = cacheStamp,
	}
	return days
end

function Ignore:NormalizeName(name)
	if issecretvalue and issecretvalue(name) then return end
	if not name or name == "" then return nil end
	local player, server = strsplit("-", name)
	player = player or name
	server = server or (GetRealmName()):gsub("%s", "")
	return player:lower() .. "-" .. server:lower()
end

function Ignore:RebuildLookup()
	wipe(self.entryLookup)
	for _, entry in ipairs(self.entries) do
		local key = self:NormalizeName(entry.player .. "-" .. entry.server)
		if key then self.entryLookup[key] = entry end
	end
end

Ignore:RebuildLookup()

local IsIgnored = IsIgnored or C_FriendList.IsIgnored

function Ignore:CheckIgnore(name)
	local key = self:NormalizeName(name)
	if not key then return nil end
	return self.entryLookup[key]
end

function Ignore:IsPlayerIgnored(name)
	if not name or name == "" then return false end
	if self:CheckIgnore(name) then return true end
	local player, server = strsplit("-", name)
	if IsIgnored then
		if IsIgnored(name) then return true end
		if server == (GetRealmName()):gsub("%s", "") and IsIgnored(player) then return true end
	end
	return false
end

function Ignore.daysFromToday(dateStr)
	if not dateStr then return 0 end
	local y, m, d = dateStr:match("(%d+)%-(%d+)%-(%d+)")
	if not y then return 0 end
	local t = time({ year = y, month = m, day = d, hour = 0 })
	return math.floor((time() - t) / 86400)
end

function Ignore:GetExpireText(entry, daysListed)
	if not entry or not entry.expires or entry.expires == NEVER then return NEVER end
	local exp = tonumber(entry.expires)
	if not exp then return tostring(entry.expires) end
	local left = exp - (daysListed or getDaysFromTodayCached(entry))
	if left <= 0 then return "TODAY" end
	return left .. "d"
end

function Ignore:Expire()
	local removed = false
	local me, realm = UnitFullName("player")
	realm = realm or (GetRealmName()):gsub("%s", "")
	local selfKey = self:NormalizeName(me .. "-" .. realm)
	for i = #self.entries, 1, -1 do
		local e = self.entries[i]
		local key = self:NormalizeName(e.player .. "-" .. e.server)
		if key == selfKey then
			table.remove(self.entries, i)
			self.entryLookup[key] = nil
			removed = true
		else
			local exp = tonumber(e.expires)
			local daysListed = getDaysFromTodayCached(e)
			if exp and exp > 0 and daysListed >= exp then
				local name = e.player
				if e.server and e.server ~= "" then name = name .. "-" .. e.server end
				if self.origDelIgnore and IsIgnored and IsIgnored(name) then self.origDelIgnore(name) end
				table.remove(self.entries, i)
				self.entryLookup[key] = nil
				print(L["IgnoreExpiredRemoved"]:format(name))
				removed = true
			end
		end
	end
	if removed then self:RebuildLookup() end
end

local ROW_HEIGHT = 20

local widths = { 130, 150, 60, 60, 210 }
local titles = {
	PLAYER,
	L["Realm"],
	L["IgnoreListed"],
	L["IgnoreExpires"],
	L["IgnoreNote"],
}
local DOUBLE_CLICK_TIME = 0.5

Ignore.currentSort = addon.db.ignoreSortKey or "player"
Ignore.sortAsc = addon.db.ignoreSortAsc ~= false
Ignore.searchText = addon.db.ignoreSearchText or ""

local NUM_ROWS = 14
Ignore.rows = {}

local addEntry
local removeEntry
local removeEntryByIndex

local IgnoreRowTemplate = {}

function IgnoreRowTemplate:OnAcquired()
	if not self.initialized then
		self.bg = self:CreateTexture(nil, "BACKGROUND")
		self.bg:SetAllPoints(self)
		self.bg:SetColorTexture(0, 0, 0, 0)

		self.cols = {}
		local x = 0
		for i, width in ipairs(widths) do
			local fs = self:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
			fs:SetPoint("LEFT", x, 0)
			fs:SetWidth(width)
			fs:SetJustifyH("LEFT")
			self.cols[i] = fs
			x = x + width
		end

		self:SetHighlightTexture("Interface/QuestFrame/UI-QuestTitleHighlight")
		self:RegisterForClicks("LeftButtonUp", "RightButtonUp")
		self:SetScript("OnClick", function(frame, btn) frame:OnClick(btn) end)
		self.initialized = true
	end
	self:SetHeight(ROW_HEIGHT)
end

function IgnoreRowTemplate:Init(elementData)
	self.index = elementData.index
	self.cols[1]:SetText(elementData.player or "")
	self.cols[2]:SetText(elementData.server or "")
	self.cols[3]:SetText(elementData.listed or "")
	self.cols[4]:SetText(elementData.expire or "")
	do
		local noteText = elementData.note or ""
		local maxLen = 30
		if #noteText > maxLen then noteText = noteText:sub(1, maxLen - 3) .. "..." end
		self.cols[5]:SetText(noteText)
	end

	if self.index == Ignore.selectedIndex then
		self.bg:SetColorTexture(1, 1, 0, 0.3)
	else
		self.bg:SetColorTexture(0, 0, 0, 0)
	end
end

function IgnoreRowTemplate:OnClick(button)
	-- 'button' is the click type (e.g. "LeftButton"/"RightButton").
	-- Using ':' here means the implicit first argument is 'self'.
	Ignore.selectedIndex = self.index
	for _, frame in ipairs(Ignore.rows) do
		if frame.bg then
			if frame.index == Ignore.selectedIndex then
				frame.bg:SetColorTexture(1, 1, 0, 0.3)
			else
				frame.bg:SetColorTexture(0, 0, 0, 0)
			end
		end
	end

	if button == "RightButton" then
		local idx = self.index
		local entry = Ignore.filtered[idx]
		if not entry then return end
		local fullName = entry.player .. ((entry.server and entry.server ~= "") and ("-" .. entry.server) or "")
		MU.CreateContextMenu(self, function(_, root)
			root:CreateTitle(fullName)
			root:CreateButton(EDIT, function() Ignore:ShowAddFrame(fullName, entry.note, tonumber(entry.expires) or 0) end)
			root:CreateButton(L["IgnoreDay"]:format(IGNORE, 1), function() addEntry(fullName, entry.note, 1) end)
			root:CreateButton(L["IgnoreDay"]:format(IGNORE, 7), function() addEntry(fullName, entry.note, 7) end)
			root:CreateButton(REMOVE, function() removeEntry(fullName) end)
		end)
		return
	end

	if button == "LeftButton" then
		local now = GetTime()
		if self.lastClick and (now - self.lastClick) < DOUBLE_CLICK_TIME then
			local entry = Ignore.filtered[self.index]
			if entry then
				local full = entry.player
				if entry.server and entry.server ~= "" then full = full .. "-" .. entry.server end
				Ignore:ShowAddFrame(full, entry.note, entry.expires)
			end
		end
		self.lastClick = now
	end
end

local function FilterEntries()
	wipe(Ignore.filtered)

	local search = Ignore.searchText and Ignore.searchText:lower() or ""
	local filtering = search ~= ""

	for _, data in ipairs(Ignore.entries) do
		if
			not filtering
			or (data.player and data.player:lower():find(search, 1, true))
			or (data.server and data.server:lower():find(search, 1, true))
			or (data.note and data.note:lower():find(search, 1, true))
		then
			table.insert(Ignore.filtered, data)
		end
	end
end

local function expireSortValue(entry)
	if not entry or not entry.expires or entry.expires == NEVER then return 99999 end
	local exp = tonumber(entry.expires)
	if not exp then return 99999 end
	local left = exp - getDaysFromTodayCached(entry)
	if left <= 0 then return 0 end
	return left
end

local function SortFiltered()
	if not Ignore.currentSort then return end
	local key = Ignore.currentSort
	local sortValues
	if key == "listed" or key == "expire" then
		sortValues = {}
		for _, entry in ipairs(Ignore.filtered) do
			if key == "listed" then
				sortValues[entry] = getDaysFromTodayCached(entry)
			else
				sortValues[entry] = expireSortValue(entry)
			end
		end
	end
	table.sort(Ignore.filtered, function(a, b)
		local av, bv
		if key == "listed" then
			av = sortValues and sortValues[a] or 0
			bv = sortValues and sortValues[b] or 0
		elseif key == "expire" then
			av = sortValues and sortValues[a] or expireSortValue(a)
			bv = sortValues and sortValues[b] or expireSortValue(b)
		else
			av = tostring(a[key] or "")
			bv = tostring(b[key] or "")
		end
		if Ignore.sortAsc then
			return av < bv
		else
			return av > bv
		end
	end)
end

local function RefreshList()
	Ignore:Expire()
	FilterEntries()
	SortFiltered()
	if Ignore.counter then Ignore.counter:SetText(format(L["IgnoreEntries"], #Ignore.filtered)) end
	if Ignore.scrollFrame then
		HybridScrollFrame_Update(Ignore.scrollFrame, #Ignore.filtered * ROW_HEIGHT, NUM_ROWS * ROW_HEIGHT)
		Ignore:UpdateRows()
	end
end

function Ignore:UpdateRows()
	if not self.scrollFrame then return end
	local offset = HybridScrollFrame_GetOffset(self.scrollFrame)
	for i, row in ipairs(self.rows) do
		local idx = i + offset
		local e = self.filtered[idx]
		if e then
			local daysListed = getDaysFromTodayCached(e)
			row:Init({
				index = idx,
				player = e.player,
				server = e.server,
				listed = e.date and (daysListed .. "d") or "",
				expire = self:GetExpireText(e, daysListed),
				note = e.note,
			})
			row:Show()
		else
			row:Hide()
		end
	end
end

-- Frame created from XML
function EQOLIgnoreFrame_OnLoad(frame)
	Ignore.frame = frame
	frame:SetFrameStrata("MEDIUM")
	frame:SetFrameLevel(100)
	frame.title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
	frame.title:SetPoint("TOP", frame, "TOP", 0, -6)
	frame.title:SetText(L["IgnoreWindowTitle"])
	local fn = frame:GetName()
	Ignore.counter = _G[fn .. "Counter"]
	Ignore.searchBox = _G[fn .. "SearchBox"]
	frame.searchLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
	frame.searchLabel:SetPoint("RIGHT", Ignore.searchBox, "LEFT", -5, 0)
	frame.searchLabel:SetText(SEARCH .. ":")
	Ignore.header = _G[fn .. "Header"]
	Ignore.scrollFrame = _G[fn .. "ScrollFrame"]
	-- Ensure scrollFrame.scrollBar references the XML-declared slider
	Ignore.scrollFrame.scrollBar = _G[fn .. "ScrollFrameScrollBar"]
	Ignore.removeBtn = _G[fn .. "RemoveButton"]
	Ignore.removeBtn:SetText(REMOVE)
	Ignore.removeBtn:Hide()

	local listWidth = 0
	for _, w in ipairs(widths) do
		listWidth = listWidth + w
	end

	local x = 0
	local c = 1
	for idx, col in ipairs({
		{ text = PLAYER, width = widths[1], key = "player" },
		{ text = L["Realm"], width = widths[2], key = "server" },
		{ text = L["IgnoreListed"], width = widths[3], key = "listed" },
		{ text = L["IgnoreExpires"], width = widths[4], key = "expire" },
		{ text = L["IgnoreNote"], width = widths[5], key = "note" },
	}) do
		local colH = _G[fn .. "HeaderCol" .. c]
		if colH then
			colH:SetWidth(col.width)
			_G[fn .. "HeaderCol" .. c .. "Middle"]:SetWidth(col.width - 9)
			colH:SetText(col.text)
			colH.sortKey = col.key
			colH:SetScript("OnClick", function(self)
				Ignore.sortAsc = (Ignore.currentSort ~= self.sortKey) and true or not Ignore.sortAsc
				Ignore.currentSort = self.sortKey
				SortFiltered()
				Ignore:UpdateRows()
				if addon and addon.db then
					addon.db.ignoreSortKey = Ignore.currentSort
					addon.db.ignoreSortAsc = Ignore.sortAsc
				end
			end)
		end
		c = c + 1
	end

	-- Create buttons and retrieve them from the scrollFrame.buttons table
	HybridScrollFrame_CreateButtons(Ignore.scrollFrame, "EQOLIgnoreRowTemplate", 5, -6)
	local rows = Ignore.scrollFrame.buttons or {}
	Ignore.rows = rows

	-- Make sure the scroll area starts just below the column header
	Ignore.scrollFrame:ClearAllPoints()
	Ignore.scrollFrame:SetPoint("TOPLEFT", Ignore.header, "BOTTOMLEFT", 0, -2)
	Ignore.scrollFrame:SetPoint("TOPRIGHT", Ignore.header, "BOTTOMRIGHT", 0, -2)
	Ignore.scrollFrame:SetPoint("BOTTOMLEFT", EQOLIgnoreFrame, "BOTTOMLEFT", 10, 10)
	Ignore.scrollFrame:SetPoint("BOTTOMRIGHT", EQOLIgnoreFrame, "BOTTOMRIGHT", -30, 10)

	-- Initialize each button via our template mixin
	for _, row in ipairs(rows) do
		Mixin(row, IgnoreRowTemplate)
		row:OnAcquired()
	end
	Ignore.scrollFrame.update = function() Ignore:UpdateRows() end

	Ignore.searchBox:SetScript("OnTextChanged", function(self)
		Ignore.searchText = self:GetText() or ""
		if addon and addon.db then addon.db.ignoreSearchText = Ignore.searchText end
		Ignore.selectedIndex = nil
		RefreshList()
	end)

	Ignore.searchBox:SetText(Ignore.searchText or "")

	Ignore.removeBtn:SetScript("OnClick", function()
		local entry = Ignore.filtered[Ignore.selectedIndex]
		if entry then
			local name = entry.player
			if entry.server and entry.server ~= "" then name = name .. "-" .. entry.server end
			removeEntry(name)
		end
		Ignore.selectedIndex = nil
		RefreshList()
	end)

	RefreshList()

	frame:SetScript("OnMouseUp", function(self)
		self:StopMovingOrSizing()
		Ignore:SavePosition()
	end)
	frame:SetScript("OnHide", function() Ignore:SavePosition() end)

	ensureFriendsFrame()
	Ignore:UpdateAnchor()
end

function Ignore:Toggle()
	ensureFriendsFrame()
	Ignore:UpdateAnchor()
	if EQOLIgnoreFrame:IsShown() then
		EQOLIgnoreFrame:Hide()
	else
		EQOLIgnoreFrame:Show()
	end
end

Ignore.origAddIgnore = Ignore.origAddIgnore or (C_FriendList and C_FriendList.AddIgnore)
Ignore.origDelIgnoreByIndex = Ignore.origDelIgnoreByIndex or (C_FriendList and C_FriendList.DelIgnoreByIndex)
Ignore.origDelIgnore = Ignore.origDelIgnore or (C_FriendList and C_FriendList.DelIgnore)
Ignore.origAddOrDelIgnore = Ignore.origAddOrDelIgnore or (C_FriendList and C_FriendList.AddOrDelIgnore)

function Ignore:HasFreeSlot()
	local num = 0
	if C_FriendList and C_FriendList.GetNumIgnores then
		num = C_FriendList.GetNumIgnores()
	elseif GetNumIgnores then
		num = GetNumIgnores()
	end
	if MAX_IGNORE then return num < MAX_IGNORE end
	return true
end

addEntry = function(name, note, expires)
	local player, server = strsplit("-", name)
	local myServer = (GetRealmName()):gsub("%s", "")
	local sameRealm = not server or server == myServer
	player = player or name
	server = server or myServer
	local myName, myRealm = UnitFullName("player")
	myRealm = myRealm or myServer
	if player:lower() == (myName and myName:lower() or "") and server:lower() == myRealm:lower() then return end
	local key = Ignore:NormalizeName(player .. "-" .. server)
	local entry = Ignore.entryLookup[key]
	if entry then
		if note ~= nil then entry.note = note end
		if expires ~= nil then entry.expires = expires > 0 and expires or NEVER end
		RefreshList()
		return
	end
	--if Ignore.origAddIgnore and sameRealm and Ignore:HasFreeSlot() then Ignore.origAddIgnore(name) end
	local newEntry = {
		player = player,
		server = server,
		date = date("%Y-%m-%d"),
		expires = expires > 0 and expires or NEVER,
		note = note or "",
	}
	table.insert(Ignore.entries, newEntry)
	Ignore.entryLookup[key] = newEntry
	print(L["IgnoreAdded"]:format(name))
	RefreshList()
end

removeEntryByIndex = function(index)
	local entry = Ignore.entries[index]
	if entry then
		local fullName = entry.player .. "-" .. entry.server
		Ignore.entryLookup[Ignore:NormalizeName(fullName)] = nil
		table.remove(Ignore.entries, index)
		print(L["IgnoreRemoved"]:format(fullName))
	end
	RefreshList()
end

removeEntry = function(name)
	local player, server = strsplit("-", name)
	if server == (GetRealmName()):gsub("%s", "") then name = player end

	if Ignore.origDelIgnore and IsIgnored and IsIgnored(name) then Ignore.origDelIgnore(name) end
	local key = Ignore:NormalizeName(player .. "-" .. (server or ""))
	local entry = Ignore.entryLookup[key]
	if entry then
		for i, e in ipairs(Ignore.entries) do
			if e == entry then
				table.remove(Ignore.entries, i)
				break
			end
		end
		Ignore.entryLookup[key] = nil
	end
	print(L["IgnoreRemoved"]:format(name))
	RefreshList()
end

local function addOrRemove(name)
	local player, server = strsplit("-", name)
	player = player or name
	server = server or (GetRealmName()):gsub("%s", "")
	if Ignore.entryLookup[Ignore:NormalizeName(player .. "-" .. server)] then
		removeEntry(name)
		return
	end
	if IsIgnored and IsIgnored(name) then
		removeEntry(name)
	else
		C_FriendList.AddIgnore(name)
	end
end

function Ignore:ShowAddFrame(name, note, expires)
	if not name then return end

	if self.addFrame then
		self.addFrame:Hide()
	end

	local frame = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
	frame:SetSize(440, 270)
	frame:SetPoint("CENTER")
	frame:SetFrameStrata("DIALOG")
	frame:SetMovable(true)
	frame:EnableMouse(true)
	frame:RegisterForDrag("LeftButton")
	frame:SetScript("OnDragStart", frame.StartMoving)
	frame:SetScript("OnDragStop", frame.StopMovingOrSizing)
	frame:SetBackdrop({
		bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
		edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
		tile = true,
		tileSize = 32,
		edgeSize = 32,
		insets = { left = 8, right = 8, top = 8, bottom = 8 },
	})

	local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
	title:SetPoint("TOP", frame, "TOP", 0, -14)
	title:SetText(L["IgnoreWindowTitle"])

	local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
	close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -6, -6)
	close:SetScript("OnClick", function()
		frame:Hide()
		Ignore.addFrame = nil
	end)

	local nameLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
	nameLabel:SetPoint("TOPLEFT", frame, "TOPLEFT", 24, -42)
	nameLabel:SetPoint("RIGHT", frame, "RIGHT", -24, 0)
	nameLabel:SetJustifyH("LEFT")
	nameLabel:SetText("|cffffd200" .. name .. "|r")

	local noteLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
	noteLabel:SetPoint("TOPLEFT", nameLabel, "BOTTOMLEFT", 0, -8)
	noteLabel:SetText(L["IgnoreNote"])

	local editField = CreateFrame("Frame", nil, frame, "BackdropTemplate")
	editField:SetPoint("TOPLEFT", noteLabel, "BOTTOMLEFT", 0, -6)
	editField:SetPoint("RIGHT", frame, "RIGHT", -28, 0)
	editField:SetHeight(96)
	editField:SetBackdrop({
		bgFile = "Interface\\Buttons\\WHITE8x8",
		edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
		tile = false,
		edgeSize = 12,
		insets = { left = 3, right = 3, top = 3, bottom = 3 },
	})
	editField:SetBackdropColor(0, 0, 0, 0.55)
	editField:SetBackdropBorderColor(0.45, 0.36, 0.2, 1)

	local editScroll = CreateFrame("ScrollFrame", nil, editField, "UIPanelScrollFrameTemplate")
	editScroll:SetPoint("TOPLEFT", editField, "TOPLEFT", 8, -8)
	editScroll:SetPoint("BOTTOMRIGHT", editField, "BOTTOMRIGHT", -28, 8)

	local editBox = CreateFrame("EditBox", nil, editScroll)
	editBox:SetMultiLine(true)
	editBox:SetAutoFocus(false)
	editBox:SetFontObject(ChatFontNormal)
	editBox:SetWidth(360)
	editBox:SetHeight(82)
	editBox:SetText(note or "")
	editBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
	editScroll:SetScrollChild(editBox)
	editField:EnableMouse(true)
	editField:SetScript("OnMouseDown", function()
		editBox:SetFocus()
	end)
	editScroll:EnableMouse(true)
	editScroll:SetScript("OnMouseDown", function()
		editBox:SetFocus()
	end)

	local check = CreateFrame("CheckButton", nil, frame, "UICheckButtonTemplate")
	check:SetPoint("TOPLEFT", editField, "BOTTOMLEFT", -4, -12)
	local checkText = check.Text or check:CreateFontString(nil, "OVERLAY", "GameFontNormal")
	checkText:SetPoint("LEFT", check, "RIGHT", 0, 1)
	checkText:SetText(L["IgnoreExpiresDays"])

	local numBox = CreateFrame("EditBox", nil, frame, "InputBoxTemplate")
	numBox:SetPoint("LEFT", checkText, "RIGHT", 14, 0)
	numBox:SetSize(60, 22)
	numBox:SetAutoFocus(false)
	numBox:SetNumeric(true)
	numBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)

	local function updateExpiryState()
		local enabled = check:GetChecked() == true
		numBox:SetEnabled(enabled)
		numBox:SetShown(enabled)
		if not enabled then numBox:SetText("") end
	end
	check:SetScript("OnClick", updateExpiryState)

	if expires and expires ~= NEVER then
		check:SetChecked(true)
		numBox:SetText(tostring(expires))
	else
		check:SetChecked(false)
		numBox:SetText("")
	end
	updateExpiryState()

	local addBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
	addBtn:SetSize(120, 24)
	addBtn:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 24, 20)
	addBtn:SetText(L["IgnoreAddSave"])
	addBtn:SetScript("OnClick", function()
		local n = editBox:GetText()
		local exp = 0
		if check:GetChecked() then
			exp = tonumber(numBox:GetText())
			if not exp then exp = 0 end
		end
		addEntry(name, n, exp)
		frame:Hide()
		Ignore.addFrame = nil
	end)

	editBox:SetFocus()
	editBox:SetScript("OnEnterPressed", function()
		addBtn:Click()
	end)

	local cancelBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
	cancelBtn:SetSize(120, 24)
	cancelBtn:SetPoint("LEFT", addBtn, "RIGHT", 12, 0)
	cancelBtn:SetText(CANCEL)
	cancelBtn:SetScript("OnClick", function()
		frame:Hide()
		Ignore.addFrame = nil
	end)

	self.addFrame = frame
	frame:Show()
end

local function hookedAddIgnore(name)
	if not name or name == "" then
		if UnitExists("target") and UnitIsPlayer("target") then
			local n, realm = UnitName("target")
			if issecretvalue and (issecretvalue(n) or issecretvalue(realm)) then return end
			if n then
				if realm and realm ~= "" then
					name = n .. "-" .. realm
				else
					name = n
				end
			end
		end
	end
	if not name or name == "" then return end
	Ignore:ShowAddFrame(name)
end

local function hookedDelIgnoreByIndex(index) removeEntryByIndex(index) end

local function hookedDelIgnore(name) removeEntry(name) end

local function hookedAddOrDelIgnore(name) addOrRemove(name) end

local monthMap = {
	Jan = 1,
	Feb = 2,
	Mar = 3,
	Apr = 4,
	May = 5,
	Jun = 6,
	Jul = 7,
	Aug = 8,
	Sep = 9,
	Oct = 10,
	Nov = 11,
	Dec = 12,
}

--- Wandelt einen String "DD Mon YYYY" in "YYYY-MM-DD" um.
-- @param dateStr  String im Format "26 Sep 2024"
-- @return String im Format "2024-09-26" oder nil bei Fehler
local function normalizeDate(dateStr)
	-- Extrahiere Tag, Monatskürzel und Jahr
	local day, monStr, year = dateStr:match("^(%d%d?)%s+(%a+)%s+(%d%d%d%d)$")
	if not (day and monStr and year) then return nil, "Ungültiges Datumsformat" end

	local month = monthMap[monStr]
	if not month then return nil, "Unbekannter Monat: " .. tostring(monStr) end

	-- Erzeuge einen Zeitstempel für Mitternacht dieses Tages
	local ts = time({ year = tonumber(year), month = month, day = tonumber(day), hour = 0 })

	-- Formatiere mit date()
	return date("%Y-%m-%d", ts)
end

local function showImportPopup()
	if not addon.db.enableIgnore then return end
	if not C_AddOns.IsAddOnLoaded("GlobalIgnoreList") then return end
	local gDB = GlobalIgnoreDB
	if not (gDB and gDB.ignoreList and #gDB.ignoreList > 0) then return end
	if StaticPopup_Visible("EQOL_IMPORT_GIL") then return end

	StaticPopupDialogs["EQOL_IMPORT_GIL"] = {
		text = L["ImportGILDialog"],
		button1 = L["ImportGILAccept"],
		button2 = CANCEL,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
		OnAccept = function()
			for i = 1, #gDB.ignoreList do
				local name = gDB.ignoreList[i]
				if name then
					local player, server = strsplit("-", name)
					player = player or name
					server = server or (GetRealmName()):gsub("%s", "")
					local nDate = normalizeDate(gDB.dateList[i])
					if not nDate then nDate = date("%Y-%m-%d") end
					local expires = gDB.expList[i]
					expires = expires > 0 and expires or NEVER
					local key = Ignore:NormalizeName(player .. "-" .. server)
					if key and not Ignore.entryLookup[key] then
						local e = {
							player = player,
							server = server,
							date = nDate,
							expires = expires,
							note = gDB.notes[i],
						}
						table.insert(Ignore.entries, e)
						Ignore.entryLookup[key] = e
					end
				end
			end
			C_AddOns.DisableAddOn("GlobalIgnoreList")
			ReloadUI()
		end,
		OnCancel = function()
			addon.db.enableIgnore = false
			Ignore.pendingImport = nil
			Ignore:SetEnabled(false)
			StaticPopupDialogs["EQOL_GIL_ACTIVE"] = {
				text = L["GILActivePopup"],
				button1 = OKAY,
				timeout = 0,
				whileDead = true,
				hideOnEscape = true,
				preferredIndex = 3,
			}
			StaticPopup_Show("EQOL_GIL_ACTIVE")
		end,
	}
	StaticPopup_Show("EQOL_IMPORT_GIL")
end

local function handleIgnoreLogin()
	if Ignore.pendingImport then
		showImportPopup()
		Ignore.pendingImport = nil
		return
	end
	if not Ignore.enabled then return end
	ensureFriendsFrame()
	local numIgnores = 0
	if C_FriendList and C_FriendList.GetNumIgnores then
		numIgnores = C_FriendList.GetNumIgnores()
	elseif GetNumIgnores then
		numIgnores = GetNumIgnores()
	end
	for i = 1, numIgnores do
		local name
		if C_FriendList and C_FriendList.GetIgnoreName then
			name = C_FriendList.GetIgnoreName(i)
		elseif GetIgnoreName then
			name = GetIgnoreName(i)
		end
		if name then
			local player, server = strsplit("-", name)
			player = player or name
			server = server or (GetRealmName()):gsub("%s", "")
			local key = Ignore:NormalizeName(player .. "-" .. server)
			if key and not Ignore.entryLookup[key] then
				local e = {
					player = player,
					server = server,
					date = date("%Y-%m-%d"),
					expires = NEVER,
					note = "",
				}
				table.insert(Ignore.entries, e)
				Ignore.entryLookup[key] = e
			end
		end
	end
	RefreshList()
end

LOGIN_FRAME:SetScript("OnEvent", handleIgnoreLogin)

local SLASH_NAME = "EQOLIGNORE"
local SLASH_CMD = "/eil"

-- chat message filter to block messages from ignored players
local function ignoreChatFilter(_, _, _, sender)
	if not Ignore.enabled then return end
	if Ignore:CheckIgnore(sender) then return true end
	return false
end

local function hookIgnoreApi()
	if C_FriendList and not Ignore.hooksInstalled then
		Ignore.origAddIgnore = Ignore.origAddIgnore or C_FriendList.AddIgnore
		Ignore.origDelIgnoreByIndex = Ignore.origDelIgnoreByIndex or C_FriendList.DelIgnoreByIndex
		Ignore.origDelIgnore = Ignore.origDelIgnore or C_FriendList.DelIgnore
		Ignore.origAddOrDelIgnore = Ignore.origAddOrDelIgnore or C_FriendList.AddOrDelIgnore

		C_FriendList.AddIgnore = hookedAddIgnore
		C_FriendList.DelIgnoreByIndex = hookedDelIgnoreByIndex
		C_FriendList.DelIgnore = hookedDelIgnore
		C_FriendList.AddOrDelIgnore = hookedAddOrDelIgnore
		Ignore.hooksInstalled = true
	end
end

local function unhookIgnoreApi()
	if C_FriendList and Ignore.hooksInstalled then
		if Ignore.origAddIgnore then C_FriendList.AddIgnore = Ignore.origAddIgnore end
		if Ignore.origDelIgnoreByIndex then C_FriendList.DelIgnoreByIndex = Ignore.origDelIgnoreByIndex end
		if Ignore.origDelIgnore then C_FriendList.DelIgnore = Ignore.origDelIgnore end
		if Ignore.origAddOrDelIgnore then C_FriendList.AddOrDelIgnore = Ignore.origAddOrDelIgnore end
		Ignore.hooksInstalled = false
	end
end

local function updateRegistration()
	LOGIN_FRAME:UnregisterAllEvents()
	if Ignore.enabled then
		hookIgnoreApi()
		Ignore:InstallTooltipHook()
		if IsLoggedIn and IsLoggedIn() then
			handleIgnoreLogin()
		else
			LOGIN_FRAME:RegisterEvent("PLAYER_LOGIN")
		end
		for _, e in ipairs(CHAT_EVENTS) do
			if not Ignore.registeredFilters[e] then
				ChatFrameUtil.AddMessageEventFilter(e, ignoreChatFilter)
				Ignore.registeredFilters[e] = true
			end
		end
		for _, evt in ipairs(INTERACTION_EVENTS) do
			Ignore.interactionBlocker:RegisterEvent(evt)
		end
		Ignore.groupCheckFrame:RegisterEvent("GROUP_ROSTER_UPDATE")
		if addon.functions and addon.functions.SetSlashCommandAlias then
			addon.functions.SetSlashCommandAlias(SLASH_NAME, 1, SLASH_CMD)
		else
			_G["SLASH_" .. SLASH_NAME .. "1"] = SLASH_CMD
		end
		SlashCmdList[SLASH_NAME] = function() Ignore:Toggle() end
	else
		for _, e in ipairs(CHAT_EVENTS) do
			if Ignore.registeredFilters[e] then
				ChatFrameUtil.RemoveMessageEventFilter(e, ignoreChatFilter)
				Ignore.registeredFilters[e] = nil
			end
		end
		for _, evt in ipairs(INTERACTION_EVENTS) do
			Ignore.interactionBlocker:UnregisterEvent(evt)
		end
		Ignore.groupCheckFrame:UnregisterEvent("GROUP_ROSTER_UPDATE")
		if EQOLIgnoreFrame then EQOLIgnoreFrame:Hide() end
		SlashCmdList[SLASH_NAME] = nil
		if addon.functions and addon.functions.SetSlashCommandAlias then
			addon.functions.SetSlashCommandAlias(SLASH_NAME, 1, nil)
		else
			_G["SLASH_" .. SLASH_NAME .. "1"] = nil
		end
		unhookIgnoreApi()
	end
end

function Ignore:SetEnabled(val)
	addon.db.enableIgnore = val and true or false
	Ignore.pendingImport = nil
	if val then
		if C_AddOns.IsAddOnLoaded("GlobalIgnoreList") then
			Ignore.enabled = false
			Ignore.pendingImport = true
			showImportPopup()
			updateRegistration()
			return
		end
		Ignore.enabled = true
	else
		Ignore.enabled = false
	end
	updateRegistration()
end

function Ignore:UpdateAnchor()
	if not self.frame then return end
	self.frame:ClearAllPoints()
	if addon and addon.db and addon.db.ignoreAnchorFriendsFrame and FriendsFrame then
		self.frame:SetPoint("TOPLEFT", FriendsFrame, "TOPRIGHT", 5, 0)
		self.frame:SetMovable(false)
	else
		local p = addon and addon.db and addon.db.ignoreFramePoint or "CENTER"
		local x = addon and addon.db and addon.db.ignoreFrameX or 0
		local y = addon and addon.db and addon.db.ignoreFrameY or 0
		self.frame:SetPoint(p, UIParent, p, x, y)
		self.frame:SetMovable(true)
	end
end

-- frame to check ignored members in current group
Ignore.groupCheckFrame = Ignore.groupCheckFrame or CreateFrame("Frame")
Ignore.groupCheckFrame.members = {}
Ignore.groupCheckFrame.lastPartySize = 0
Ignore.groupCheckFrame.lastIgnored = 0
Ignore.groupCheckFrame:SetScript("OnEvent", function()
	if not Ignore.enabled then return end
	local partyMembers = Ignore.groupCheckFrame.members
	wipe(partyMembers)

	local size = GetNumGroupMembers()
	if size == 0 then
		Ignore.groupCheckFrame.lastPartySize = 0
		Ignore.groupCheckFrame.lastIgnored = 0
		return
	end

	local prefix = IsInRaid() and "raid" or "party"
	local loops = IsInRaid() and size or (size - 1)
	for i = 1, loops do
		local unit = prefix .. i
		if UnitExists(unit) then
			local n, r = UnitFullName(unit)
			if issecretvalue and (issecretvalue(n) or issecretvalue(r)) then
			elseif n then
				r = r or (GetRealmName()):gsub("%s", "")
				partyMembers[n .. "-" .. r] = true
			end
		end
	end

	local pn, pr = UnitFullName("player")
	if issecretvalue and (issecretvalue(pn) or issecretvalue(pr)) then
		pn, pr = nil, nil
	end
	pr = pr or (GetRealmName()):gsub("%s", "")
	if pn then partyMembers[pn .. "-" .. pr] = true end

	local ignored = {}
	local count = 0
	for name in pairs(partyMembers) do
		if Ignore:CheckIgnore(name) then
			table.insert(ignored, name)
			count = count + 1
		end
	end

	if count > Ignore.groupCheckFrame.lastIgnored then
		local names = table.concat(ignored, "\n")
		StaticPopupDialogs["EQOL_IGNORE_GROUP"] = {
			text = L["IgnoreGroupPopupText"]:format(names),
			button1 = OKAY,
			timeout = 0,
			whileDead = true,
			hideOnEscape = true,
			preferredIndex = 3,
		}
		StaticPopup_Show("EQOL_IGNORE_GROUP")
	end

	Ignore.groupCheckFrame.lastPartySize = size
	Ignore.groupCheckFrame.lastIgnored = count
end)

Ignore.interactionBlocker = Ignore.interactionBlocker or CreateFrame("Frame")
Ignore.interactionBlocker:SetScript("OnEvent", function(_, event, ...)
	if not Ignore.enabled then return end
	local name = ...
	if event == "TRADE_SHOW" then name = UnitFullName("npc") end
	if Ignore:CheckIgnore(name or "") then
		if event == "PARTY_INVITE_REQUEST" then
			DeclineGroup()
			StaticPopup_Hide("PARTY_INVITE")
		elseif event == "GUILD_INVITE_REQUEST" then
			DeclineGuildInvite()
			StaticPopup_Hide("GUILD_INVITE")
		elseif event == "DUEL_REQUESTED" then
			CancelDuel()
			StaticPopup_Hide("DUEL_REQUESTED")
		elseif event == "PET_BATTLE_PVP_DUEL_REQUESTED" then
			C_PetBattles.CancelPVPDuel()
			StaticPopup_Hide("PET_BATTLE_PVP_DUEL_REQUESTED")
		elseif event == "TRADE_SHOW" then
			CancelTrade()
			StaticPopup_Hide("TRADE")
		end
	end
end)

local function EQOL_AddUnitIgnoreEntry(owner, root, ctx)
	if not Ignore.enabled then return end
	local name = ctx and ctx.name
	local realm = ctx and ctx.server
	if issecretvalue and (issecretvalue(name) or issecretvalue(realm)) then return end
	if name and not name:find("-") then
		realm = realm or (GetRealmName()):gsub("%s", "")
		name = name .. "-" .. realm
	end
	if not name then
		local unit = (ctx and ctx.unit) or (owner and owner.unit) or (owner and owner.GetUnit and owner:GetUnit())
		if unit and UnitName then
			local n, r = UnitName(unit)
			if issecretvalue and (issecretvalue(n) or issecretvalue(r)) then return end
			if n then
				r = r and r ~= "" and r or (GetRealmName()):gsub("%s", "")
				name = n .. "-" .. r
			end
		end
	end
	if not name then return end

	local isIgnored = Ignore:CheckIgnore(name) ~= nil

	local label = isIgnored and _G.UNIGNORE_QUEST or _G.IGNORE_PLAYER

	root:CreateDivider()
	root:CreateButton(label, function()
		if isIgnored then
			C_FriendList.DelIgnore(name)
		else
			C_FriendList.AddIgnore(name)
		end
	end)
end

if Menu and Menu.ModifyMenu then
	Menu.ModifyMenu("MENU_UNIT_PLAYER", EQOL_AddUnitIgnoreEntry)
	Menu.ModifyMenu("MENU_UNIT_ENEMY_PLAYER", EQOL_AddUnitIgnoreEntry)
	Menu.ModifyMenu("MENU_UNIT_RAID_PLAYER", EQOL_AddUnitIgnoreEntry)
	Menu.ModifyMenu("MENU_UNIT_PARTY", EQOL_AddUnitIgnoreEntry)
end

-- Format ignore-note for tooltip: normalize spaces, wrap every N words with \n, and hard cap length
local function EQOL_FormatNote(note, maxChars, wordsPerLine)
	if not note or note == "" then return "" end
	-- normalize whitespace and trim
	local s = note:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
	if s == "" then return "" end
	maxChars = maxChars or 100
	wordsPerLine = wordsPerLine or 5

	-- split into words
	local words = {}
	for w in s:gmatch("%S+") do
		table.insert(words, w)
	end
	if #words == 0 then return "" end

	-- join with newline after every wordsPerLine words
	local out, line, count = {}, {}, 0
	for i = 1, #words do
		table.insert(line, words[i])
		count = count + 1
		if count >= wordsPerLine then
			table.insert(out, table.concat(line, " "))
			line, count = {}, 0
		end
	end
	if #line > 0 then table.insert(out, table.concat(line, " ")) end

	local joined = table.concat(out, "\n")

	-- hard cap by characters, prefer cutting at whitespace
	if #joined <= maxChars then return joined end
	if maxChars <= 3 then return joined:sub(1, maxChars) end
	local truncated = joined:sub(1, maxChars - 3)
	-- try to cut back to last non-space to avoid trailing partials
	local cut = truncated:match("^(.*%S)") or truncated
	return cut .. "..."
end

function Ignore:InstallTooltipHook()
	if Ignore.tooltipHookInstalled then return end
	GameTooltip:HookScript("OnShow", function(tooltip)
		if tooltip:IsForbidden() or tooltip:IsProtected() then return end
		if not addon.db or not addon.db.ignoreTooltipNote then return end
		-- local _, unit = tooltip:GetUnit()
		local unit = "mouseover"
		if not UnitExists(unit) or not UnitIsPlayer(unit) then return end
		local name, realm = UnitName(unit)
		if issecretvalue and (issecretvalue(name) or issecretvalue(realm)) then return end
		if not name then return end
		realm = realm and realm ~= "" and realm or (GetRealmName()):gsub("%s", "")
		local entry = Ignore:CheckIgnore(name .. "-" .. realm)
		if entry and entry.note and entry.note ~= "" then
			RunNextFrame(function()
				local maxChars = (addon and addon.db and addon.db.ignoreTooltipMaxChars) or 100
				local wordsPerLine = (addon and addon.db and addon.db.ignoreTooltipWordsPerLine) or 5
				local text = EQOL_FormatNote(entry.note, maxChars, wordsPerLine)

				tooltip:AddLine(" ")
				tooltip:AddDoubleLine(L["IgnoreNote"], text)
				tooltip:Show()
			end)
		end
	end)
	Ignore.tooltipHookInstalled = true
end

if addon.db.enableIgnore ~= nil then Ignore:SetEnabled(addon.db.enableIgnore) end
