-- MarketWatch UI.lua
-- Final Pixel-Perfect 3.3.5a UI

MW = MW or {}
MW.UI = {}

-- Escape only closes frames Blizzard's default UI knows about - a named
-- global frame has to be listed in UISpecialFrames for the Escape key to
-- Hide() it. Registered by name (not by frame reference) so this doesn't
-- care whether the frame in question comes from XML (MarketWatchFrame,
-- MarketWatchSavedFrame) or is built later in this file (the Blacklist/
-- Keyword/Copy popups) - Blizzard looks it up by name when Escape is
-- actually pressed. The AceConfigDialog options window isn't listed here
-- deliberately - AceGUI's own Frame widget already handles Escape itself.
table.insert(UISpecialFrames, "MarketWatchFrame")
table.insert(UISpecialFrames, "MarketWatchSavedFrame")
table.insert(UISpecialFrames, "MW_BlacklistFrame")
table.insert(UISpecialFrames, "MW_KeywordFrame")
table.insert(UISpecialFrames, "MW_CopyFrame")

local currentTab = "All"
local currentSortKey = "time"
local sortReverse = true
local activeDisplayList = {}

local CLASS_COLOR_KEYS = {
	"PALADIN", "PRIEST", "WARLOCK", "MAGE", "ROGUE", 
	"DRUID", "HUNTER", "SHAMAN", "WARRIOR", "DEATHKNIGHT"
}

local KIND_COLORS = {
	WTS = "|cff55ff55",
	WTB = "|cffff5555",
	WTT = "|cffffff55",
}

local KIND_ICONS = {
	WTS = "Interface\\Icons\\INV_Misc_Bag_08",
	WTB = "Interface\\Icons\\INV_Letter_15",
	WTT = "Interface\\Icons\\inv_misc_coin_01",
}

local CATEGORY_ICONS = {
	Herbs = "Interface\\Icons\\inv_misc_herb_whispervine",
	MiningBS = "Interface\\Icons\\Trade_Mining",
	Mining = "Interface\\Icons\\Trade_Mining",
	Enchanting = "Interface\\Icons\\Trade_Engraving",
	Tailoring = "Interface\\Icons\\Trade_Tailoring",
	Jewelcrafting = "Interface\\Icons\\INV_Misc_Gem_01",
	Inscription = "Interface\\Icons\\INV_Inscription_Tradeskill01",
	FlasksPotions = "Interface\\Icons\\Trade_Alchemy",
	SFS = "Interface\\Icons\\INV_Enchant_ShardShadowfrostLarge",
	BoE = "Interface\\Icons\\inv_jewelcrafting_crimsonspinel_02",
	Mounts = "Interface\\Icons\\spell_deathknight_summondeathcharger",
	Misc = "Interface\\Icons\\INV_Misc_Bag_08",
	Boost = "Interface\\Icons\\INV_Misc_Gem_Sapphire_01",
	Transmog = "Interface\\Icons\\inv_sword_01",
	CharTrade = "Interface\\Icons\\Achievement_character_human_male",
	chartrade = "Interface\\Icons\\Achievement_character_human_male",
	["Char Trade"] = "Interface\\Icons\\Achievement_character_human_male",
	Uncategorized = "Interface\\Icons\\INV_Misc_QuestionMark",
	uncategorized = "Interface\\Icons\\INV_Misc_QuestionMark",
}

local CATEGORY_COLORS = {
	Herbs = "|cff55ff55",
	MiningBS = "|cff88aa88",
	Enchanting = "|cff9370db",
	Tailoring = "|cff4682b4",
	Jewelcrafting = "|cff00bfff",
	Inscription = "|cffc69b6d",
	FlasksPotions = "|cff3cb371",
	SFS = "|cffff8000",
	BoE = "|cffa335ee",
	Mounts = "|cff00ffff",
	Misc = "|cffe0c04c",
	Boost = "|cff0070dd",
	Transmog = "|cffff69b4",
	CharTrade = "|cffffd100",
	chartrade = "|cffffd100",
	["Char Trade"] = "|cffffd100",
	Uncategorized = "|cffaaaaaa",
	uncategorized = "|cffaaaaaa",
}

local MATS_CATEGORIES = {
	"Herbs",
	"MiningBS",
	"Enchanting",
	"Tailoring",
	"Jewelcrafting",
	"Inscription",
	"FlasksPotions",
	"SFS",
}

-- ============================================================
-- CLASS COLOR / CHAT GUID RESOLVER - WotLK 3.3.5a
--
-- CHAT_MSG_CHANNEL provides the sender GUID as event arg 12 on
-- the 3.3.5a client. We cache GUID -> class and name -> class
-- so Trade / World / Global / custom channels can be colored
-- just like Yell. GetPlayerInfoByGUID was added in patch 3.2.
-- ============================================================

local MW_ClassCache = {}
local MW_GUIDCache = {}

local function MW_NormalizeName(name)
	if not name then return nil end
	-- Strip realm suffix if the chat event gives Name-Realm.
	name = string.gsub(name, "%-[^%-]+$", "")
	return string.lower(name)
end

local function MW_NormalizeClass(className)
	if not className or className == "" then return nil end
	return string.upper(string.gsub(className, "%s+", ""))
end

local function MW_LearnClass(name, guid, className)
	local normalizedClass = MW_NormalizeClass(className)
	if not normalizedClass then
		return nil
	end

	if guid and guid ~= "" then
		MW_GUIDCache[guid] = normalizedClass
	end

	local normalizedName = MW_NormalizeName(name)
	if normalizedName then
		MW_ClassCache[normalizedName] = normalizedClass
	end

	return normalizedClass
end

local function MW_GetClassFromGUID(guid)
	if not guid or guid == "" then return nil end

	if MW_GUIDCache[guid] then
		return MW_GUIDCache[guid]
	end

	if GetPlayerInfoByGUID then
		local _, englishClass = GetPlayerInfoByGUID(guid)
		if englishClass and englishClass ~= "" then
			local classToken = MW_LearnClass(nil, guid, englishClass)
			if classToken then
				return classToken
			end
		end
	end

	return nil
end

local function MW_GetClassFromName(name)
	local normalizedName = MW_NormalizeName(name)
	if not normalizedName then return nil end

	if MW_ClassCache[normalizedName] then
		return MW_ClassCache[normalizedName]
	end

	-- Only use UnitClass with actual unit tokens. Passing an arbitrary
	-- channel sender name to UnitClass() is not a reliable lookup.
	local units = { "target", "mouseover", "focus", "player" }
	for _, unit in ipairs(units) do
		if UnitExists and UnitExists(unit) and UnitIsPlayer and UnitIsPlayer(unit) then
			local unitName = UnitName(unit)
			if MW_NormalizeName(unitName) == normalizedName then
				local _, englishClass = UnitClass(unit)
				if englishClass then
					return MW_LearnClass(name, UnitGUID(unit), englishClass)
				end
			end
		end
	end

	return nil
end

local function MW_RecordChatSender(event, message, sender, language, channelName, target, flags, zoneID, channelIndex, channelBaseName, languageID, lineID, senderGUID)
	if not sender or sender == "" then return end

	-- The important part for CHAT_MSG_CHANNEL is arg 12 = sender GUID.
	-- Yell/Say/etc. also pass a GUID on clients that support it.
	local classToken = MW_GetClassFromGUID(senderGUID)

	-- If the GUID is already known to the client, learn/cache the class.
	if classToken then
		MW_LearnClass(sender, senderGUID, classToken)
	end

	-- If no GUID was supplied but the sender is currently a known unit,
	-- try the unit lookup as a secondary fallback.
	if not classToken then
		classToken = MW_GetClassFromName(sender)
	end
end

-- Dedicated event frame. This does not modify Blizzard chat frames; it only
-- listens to the same events and builds a local class cache for MarketWatch.
local MW_ClassEventFrame = CreateFrame("Frame", "MarketWatchClassResolverFrame")
MW_ClassEventFrame:RegisterEvent("CHAT_MSG_CHANNEL")
MW_ClassEventFrame:RegisterEvent("CHAT_MSG_YELL")
MW_ClassEventFrame:RegisterEvent("CHAT_MSG_SAY")
MW_ClassEventFrame:RegisterEvent("CHAT_MSG_WHISPER")
MW_ClassEventFrame:RegisterEvent("CHAT_MSG_PARTY")
MW_ClassEventFrame:RegisterEvent("CHAT_MSG_RAID")
MW_ClassEventFrame:RegisterEvent("CHAT_MSG_RAID_LEADER")
MW_ClassEventFrame:RegisterEvent("CHAT_MSG_PARTY_LEADER")
MW_ClassEventFrame:RegisterEvent("CHAT_MSG_GUILD")

MW_ClassEventFrame:SetScript("OnEvent", function(self, event, ...)
	local message, sender, language, channelName, target, flags, zoneID, channelIndex, channelBaseName, languageID, lineID, senderGUID = ...
	MW_RecordChatSender(event, message, sender, language, channelName, target, flags, zoneID, channelIndex, channelBaseName, languageID, lineID, senderGUID)
end)

local function GetClassColoredName(name, className, guid)
	if not name or name == "" then return "|cffffffffUnknown|r" end

	-- 1. Explicit class stored on the listing always wins.
	local detectedClass = MW_NormalizeClass(className)

	-- 2. Resolve from the sender GUID. This is the key fix for Trade/World/Global.
	if not detectedClass and guid then
		detectedClass = MW_GetClassFromGUID(guid)
	end

	-- 3. Resolve from the cached sender name.
	if not detectedClass then
		detectedClass = MW_GetClassFromName(name)
	end

	-- 4. Calculate 3.3.5a hex color from RAID_CLASS_COLORS.
	if detectedClass and RAID_CLASS_COLORS then
		local color = RAID_CLASS_COLORS[detectedClass]
		if color then
			local hex = string.format("%02x%02x%02x", math.floor(color.r * 255), math.floor(color.g * 255), math.floor(color.b * 255))
			return "|cff" .. hex .. name .. "|r"
		end
	end

	-- 5. Clean fallback to standard WoW Gold.
	return "|cffffd100" .. name .. "|r"
end

local function TimeAgo(t)
	local diff = time() - (t or time())
	if diff < 0 then diff = 0 end
	if diff < 60 then return diff .. "s" end
	if diff < 3600 then return math.floor(diff / 60) .. "m" end
	return math.floor(diff / 3600) .. "h"
end

function MW_GetListForTab(tabKey)
	local merged = {}
	if not MW.data then return merged end

	if tabKey == "All" then
		for _, catKey in ipairs(MW.categoryOrder or {}) do
			local list = MW.data.items and MW.data.items[catKey]
			if list then
				for _, e in ipairs(list) do table.insert(merged, e) end
			end
		end

		if MW.data.items and MW.data.items.Uncategorized then
			for _, e in ipairs(MW.data.items.Uncategorized) do table.insert(merged, e) end
		end

		for _, e in ipairs(MW.data.chartrade or {}) do table.insert(merged, e) end
		for _, e in ipairs(MW.data.boost or {}) do table.insert(merged, e) end
		for _, e in ipairs(MW.data.transmog or {}) do table.insert(merged, e) end

	elseif tabKey == "Mats" then
		for _, catKey in ipairs(MATS_CATEGORIES) do
			local list = MW.data.items and MW.data.items[catKey]
			if list then
				for _, e in ipairs(list) do table.insert(merged, e) end
			end
		end

	elseif tabKey == "BoE" then
		for _, e in ipairs((MW.data.items and MW.data.items.BoE) or {}) do table.insert(merged, e) end

	elseif tabKey == "Mounts" then
		for _, e in ipairs((MW.data.items and MW.data.items.Mounts) or {}) do table.insert(merged, e) end

	elseif tabKey == "Boost" then
		for _, e in ipairs(MW.data.boost or {}) do table.insert(merged, e) end

	elseif tabKey == "Transmog" then
		for _, e in ipairs(MW.data.transmog or {}) do table.insert(merged, e) end

	elseif tabKey == "CharTrade" then
		for _, e in ipairs(MW.data.chartrade or {}) do table.insert(merged, e) end

	elseif tabKey == "Uncategorized" then
		for _, e in ipairs((MW.data.items and MW.data.items.Uncategorized) or {}) do table.insert(merged, e) end

	elseif tabKey == "Misc" then
		for _, e in ipairs((MW.data.items and MW.data.items.Misc) or {}) do table.insert(merged, e) end
	end

	return merged
end

function MW_SortData()
	table.sort(activeDisplayList, function(a, b)
		local valA = a[currentSortKey] or ""
		local valB = b[currentSortKey] or ""
		if sortReverse then
			return valA > valB
		else
			return valA < valB
		end
	end)
end

function MW_SortByColumn(key)
	if currentSortKey == key then
		sortReverse = not sortReverse
	else
		currentSortKey = key
		sortReverse = false
	end
	MW_UpdateUI()
end

function MW_SelectTab(tabKey)
	currentTab = tabKey
	MW_UpdateUI()
end

--------------------------------------------------------------------
-- Flat "ElvUI-style" button skin - strips the stock 3D-bevel button art
-- (Normal/Pushed/Disabled textures) and replaces it with a flat dark
-- panel + thin 1px border, using the same explicit-edge-texture technique
-- already proven reliable for the window's own border (see the rainbow
-- border further down) rather than SetBackdrop's edgeFile, which tiles
-- unevenly at 1px in this client. Applied to every button in the addon
-- so it reads as one cohesive look instead of stock Blizzard gold bevels.
--------------------------------------------------------------------
local MW_ACCENT = { 0.85, 0.68, 0.25 }        -- gold accent, matches the rest of the addon
local MW_BTN_BG = { 0.09, 0.09, 0.10, 0.95 }
local MW_BTN_BG_ACTIVE = { 0.30, 0.24, 0.10, 0.95 }
local MW_BTN_BORDER = { 0.02, 0.02, 0.02, 1 }

local function MW_AddThinBorder(parent)
	local function edge(p1, p2, isVertical)
		local tex = parent:CreateTexture(nil, "BORDER")
		tex:SetTexture("Interface\\Buttons\\WHITE8X8")
		tex:SetVertexColor(unpack(MW_BTN_BORDER))
		tex:SetPoint(p1, parent, p1, 0, 0)
		tex:SetPoint(p2, parent, p2, 0, 0)
		if isVertical then tex:SetWidth(1) else tex:SetHeight(1) end
		return tex
	end
	edge("TOPLEFT", "TOPRIGHT", false)
	edge("BOTTOMLEFT", "BOTTOMRIGHT", false)
	edge("TOPLEFT", "BOTTOMLEFT", true)
	edge("TOPRIGHT", "BOTTOMRIGHT", true)
end

function MW_SkinButton(btn)
	if not btn or btn.mwSkinned then return end
	btn.mwSkinned = true

	local normalTex = btn:GetNormalTexture()
	if normalTex then normalTex:SetTexture(nil) end
	local pushedTex = btn:GetPushedTexture()
	if pushedTex then pushedTex:SetTexture(nil) end
	local disabledTex = btn:GetDisabledTexture()
	if disabledTex then disabledTex:SetTexture(nil) end

	local bg = btn:CreateTexture(nil, "BACKGROUND")
	bg:SetAllPoints(btn)
	bg:SetTexture("Interface\\Buttons\\WHITE8X8")
	bg:SetVertexColor(unpack(MW_BTN_BG))
	btn.mwBg = bg

	MW_AddThinBorder(btn)

	btn:SetHighlightTexture("Interface\\Buttons\\WHITE8X8")
	local hl = btn:GetHighlightTexture()
	hl:SetVertexColor(MW_ACCENT[1], MW_ACCENT[2], MW_ACCENT[3], 0.25)
	hl:SetBlendMode("ADD")

	local fs = btn:GetFontString()
	if fs then fs:SetTextColor(0.92, 0.92, 0.92) end

	btn:HookScript("OnMouseDown", function(self)
		if self:IsEnabled() then bg:SetVertexColor(0.03, 0.03, 0.03, 0.95) end
	end)
	btn:HookScript("OnMouseUp", function(self)
		bg:SetVertexColor(unpack(MW_BTN_BG))
	end)
end

-- Close buttons keep their template (and its built-in click-to-hide
-- behavior) untouched - only the stock icon textures are stripped and
-- swapped for a flat chip with a plain "x" glyph, matching RareHunt's
-- own hand-built close button.
function MW_SkinCloseButton(btn)
	if not btn or btn.mwSkinned then return end
	btn.mwSkinned = true

	-- Stock UIPanelCloseButton defaults to 32x32, oversized next to these
	-- smaller flat windows - shrink it, same anchor point so nothing shifts.
	btn:SetSize(20, 20)

	local normalTex = btn:GetNormalTexture()
	if normalTex then normalTex:SetTexture(nil) end
	local pushedTex = btn:GetPushedTexture()
	if pushedTex then pushedTex:SetTexture(nil) end
	local highlightTex = btn:GetHighlightTexture()
	if highlightTex then highlightTex:SetTexture(nil) end
	local disabledTex = btn:GetDisabledTexture()
	if disabledTex then disabledTex:SetTexture(nil) end

	local bg = btn:CreateTexture(nil, "BACKGROUND")
	bg:SetAllPoints(btn)
	bg:SetTexture("Interface\\Buttons\\WHITE8X8")
	bg:SetVertexColor(unpack(MW_BTN_BG))
	btn.mwBg = bg

	MW_AddThinBorder(btn)

	local label = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
	label:SetPoint("CENTER", 0, 1)
	label:SetText("x")
	label:SetTextColor(0.85, 0.85, 0.85)

	btn:HookScript("OnEnter", function() label:SetTextColor(MW_ACCENT[1], MW_ACCENT[2], MW_ACCENT[3]) end)
	btn:HookScript("OnLeave", function() label:SetTextColor(0.85, 0.85, 0.85) end)
	btn:HookScript("OnMouseDown", function(self)
		if self:IsEnabled() then bg:SetVertexColor(0.03, 0.03, 0.03, 0.95) end
	end)
	btn:HookScript("OnMouseUp", function(self)
		bg:SetVertexColor(unpack(MW_BTN_BG))
	end)
end

if _G["MarketWatchFrameCloseButton"] then MW_SkinCloseButton(_G["MarketWatchFrameCloseButton"]) end
if _G["MarketWatchSavedFrameCloseButton"] then MW_SkinCloseButton(_G["MarketWatchSavedFrameCloseButton"]) end

-- Flattens an InputBoxTemplate edit box (the stock blue inset look) to
-- match the rest of the addon. InputBoxTemplate's Left/Middle/Right
-- border textures are named off the edit box's own name via Blizzard's
-- $parent substitution, so the edit box MUST be created with an explicit
-- name for this to find them.
function MW_SkinEditBox(editBox)
	if not editBox or editBox.mwSkinned then return end
	editBox.mwSkinned = true

	local name = editBox:GetName()
	if name then
		local left = _G[name .. "Left"]
		local middle = _G[name .. "Middle"]
		local right = _G[name .. "Right"]
		if left then left:Hide() end
		if middle then middle:Hide() end
		if right then right:Hide() end
	end

	local bg = editBox:CreateTexture(nil, "BACKGROUND")
	bg:SetAllPoints(editBox)
	bg:SetTexture("Interface\\Buttons\\WHITE8X8")
	bg:SetVertexColor(0.05, 0.05, 0.06, 0.95)

	MW_AddThinBorder(editBox)

	editBox:SetTextColor(0.92, 0.92, 0.92)
end

-- Scrollbar skin (ported from RareHunt's SkinScrollBar) - kills the stock
-- up/down arrow buttons and track art, leaving a thin flat thumb in the
-- addon's own gold accent instead of RareHunt's neutral grey. Only the
-- Blacklist and Keyword windows use a real UIPanelScrollFrameTemplate
-- scrollbar - the main window and Saved Listings use a mouse-wheel-only
-- FauxScrollFrame with no slider to skin, and the Copy popup has none.
function MW_SkinScrollBar(scrollFrame)
	local name = scrollFrame and scrollFrame:GetName()
	if not name then return end

	local scrollBar = _G[name .. "ScrollBar"]
	local upBtn = _G[name .. "ScrollBarScrollUpButton"]
	local downBtn = _G[name .. "ScrollBarScrollDownButton"]

	local function KillButton(btn)
		if not btn then return end
		btn:SetSize(0.001, 0.001)
		btn:Hide()
		btn:SetScript("OnShow", function(self) self:Hide() end)
		if btn.SetNormalTexture then btn:SetNormalTexture("") end
		if btn.SetPushedTexture then btn:SetPushedTexture("") end
		if btn.SetDisabledTexture then btn:SetDisabledTexture("") end
		if btn.SetHighlightTexture then btn:SetHighlightTexture("") end
	end

	KillButton(upBtn)
	KillButton(downBtn)

	if scrollBar then
		scrollBar:ClearAllPoints()
		scrollBar:SetPoint("TOPRIGHT", scrollFrame, "TOPRIGHT", 14, 0)
		scrollBar:SetPoint("BOTTOMRIGHT", scrollFrame, "BOTTOMRIGHT", 14, 0)
		scrollBar:SetWidth(6)

		local bg = _G[name .. "ScrollBarBG"]
		if bg then bg:SetTexture(nil) end
		local track = _G[name .. "ScrollBarTrack"]
		if track then track:SetTexture(nil) end

		local thumb = _G[name .. "ScrollBarThumbTexture"]
		if thumb then
			thumb:SetTexture("Interface\\Buttons\\WHITE8X8")
			thumb:SetVertexColor(0.55, 0.45, 0.22, 1)
			thumb:SetWidth(6)
		end
	end
end

--------------------------------------------------------------------
-- Active-tab / active-sort-column indicators. Both just reflect the
-- currentTab / currentSortKey / sortReverse upvalues already tracked
-- above, so a single call from MW_UpdateUI keeps everything in sync.
-- With the flat skin above, "active" reads as an accent-tinted
-- background rather than a native pushed-button bevel.
--------------------------------------------------------------------
local TAB_KEYS = { "All", "Mats", "BoE", "Mounts", "Boost", "Transmog", "CharTrade", "Uncategorized" }

for i = 1, 8 do
	local btn = _G["MW_Tab" .. i]
	if btn then MW_SkinButton(btn) end
end
if _G["MW_ConfigButton"] then MW_SkinButton(_G["MW_ConfigButton"]) end
if _G["MW_SavedListingsButton"] then MW_SkinButton(_G["MW_SavedListingsButton"]) end

local function MW_UpdateTabHighlight()
	for i, key in ipairs(TAB_KEYS) do
		local btn = _G["MW_Tab" .. i]
		if btn and btn.mwBg then
			if key == currentTab then
				btn.mwBg:SetVertexColor(unpack(MW_BTN_BG_ACTIVE))
				btn:LockHighlight()
			else
				btn.mwBg:SetVertexColor(unpack(MW_BTN_BG))
				btn:UnlockHighlight()
			end
		end
	end
end

local SORT_COLUMNS = {
	{ key = "kind", btn = "MW_ColHeaderKind" },
	{ key = "message", btn = "MW_ColHeaderMessage" },
	{ key = "sender", btn = "MW_ColHeaderSeller" },
	{ key = "category", btn = "MW_ColHeaderCategory" },
	{ key = "time", btn = "MW_ColHeaderTime" },
}

-- Native Blizzard sort-arrow texture (same one the stock Auction House
-- frame uses on its own sortable column headers).
local sortArrows = {}
for _, col in ipairs(SORT_COLUMNS) do
	local btn = _G[col.btn]
	if btn then
		MW_SkinButton(btn)

		local arrow = btn:CreateTexture(nil, "OVERLAY")
		arrow:SetTexture("Interface\\Buttons\\UI-SortArrow")
		arrow:SetSize(12, 12)
		arrow:SetPoint("RIGHT", btn, "RIGHT", -6, 0)
		arrow:Hide()
		sortArrows[col.key] = arrow
	end
end

local function MW_UpdateSortHighlight()
	for _, col in ipairs(SORT_COLUMNS) do
		local btn = _G[col.btn]
		local arrow = sortArrows[col.key]
		if btn and btn.mwBg and arrow then
			if col.key == currentSortKey then
				btn.mwBg:SetVertexColor(unpack(MW_BTN_BG_ACTIVE))
				if sortReverse then
					arrow:SetTexCoord(0, 1, 0, 1)
				else
					arrow:SetTexCoord(0, 1, 1, 0)
				end
				arrow:Show()
			else
				btn.mwBg:SetVertexColor(unpack(MW_BTN_BG))
				arrow:Hide()
			end
		end
	end
end

function MW_UpdateUI()
	if not _G["MarketWatchFrame"] or not MarketWatchFrame:IsShown() then return end

	MW_UpdateTabHighlight()
	MW_UpdateSortHighlight()

	activeDisplayList = MW_GetListForTab(currentTab)
	MW_SortData()

	local numItems = #activeDisplayList
	local maxRows = 17
	local rowHeight = 22

	if MW_ScrollFrame then
		FauxScrollFrame_Update(MW_ScrollFrame, numItems, maxRows, rowHeight)
		local offset = FauxScrollFrame_GetOffset(MW_ScrollFrame)

		for i = 1, maxRows do
			local index = offset + i
			local rowButton = _G["MW_Row" .. i]

			if rowButton then
				if index <= numItems then
					local data = activeDisplayList[index]
					rowButton.entryData = data

					local kindColor = KIND_COLORS[data.kind] or "|cffffffff"
					local kindIcon = KIND_ICONS[data.kind] or "Interface\\Icons\\INV_Misc_Bag_08"
					
					local kindIconFrame = _G["MW_Row" .. i .. "KindIcon"]
					if kindIconFrame then kindIconFrame:SetTexture(kindIcon) end

					local kindTextFrame = _G["MW_Row" .. i .. "Kind"]
					if kindTextFrame then kindTextFrame:SetText(kindColor .. (data.kind or "?") .. "|r") end

					local msgFrame = _G["MW_Row" .. i .. "Message"]
					if msgFrame then
						local msgText = data.message or ""
						if data.senderCount and data.senderCount > 1 then
							msgText = msgText .. "  |cff888888x" .. data.senderCount .. "|r"
						end
						if data.isDeal and data.dealPercent then
							msgText = msgText .. "  |cff2ecc9c-" .. data.dealPercent .. "%|r"
						end
						msgFrame:SetText(msgText)
					end

					local sellerFrame = _G["MW_Row" .. i .. "Seller"]
					if sellerFrame then sellerFrame:SetText(GetClassColoredName(data.sender, data.class, data.guid)) end

					local catKey = data.category or "Uncategorized"
					local catLabel = MW.categoryLabels and MW.categoryLabels[catKey] or catKey
					local catIcon = CATEGORY_ICONS[catKey] or CATEGORY_ICONS[catLabel] or "Interface\\Icons\\INV_Misc_QuestionMark"
					local catColor = CATEGORY_COLORS[catKey] or CATEGORY_COLORS[catLabel] or "|cffffffff"

					local catIconFrame = _G["MW_Row" .. i .. "CatIcon"]
					if catIconFrame then catIconFrame:SetTexture(catIcon) end

					local catTextFrame = _G["MW_Row" .. i .. "Category"]
					if catTextFrame then catTextFrame:SetText(catColor .. catLabel .. "|r") end

					local timeFrame = _G["MW_Row" .. i .. "Time"]
					if timeFrame then timeFrame:SetText(TimeAgo(data.time)) end

					-- Keyword match / deal match: flags for the shared pulse ticker,
					-- reset immediately when neither applies (rows are pooled/
					-- reused, so a stale tint must be cleared). A deal always
					-- wins visually over a keyword match if a row somehow
					-- matches both - see the ticker below.
					local isMatch = MW_MessageMatchesKeyword(data.message)
					rowButton._keywordMatch = isMatch
					rowButton._dealMatch = data.isDeal or false
					if not isMatch and not rowButton._dealMatch then
						local bg = _G["MW_Row" .. i .. "Bg"]
						if bg then bg:SetTexture(0.05, 0.05, 0.05, 0.6) end
						if msgFrame then msgFrame:SetTextColor(1, 1, 1, 1) end
					end

					rowButton:Show()
				else
					rowButton._keywordMatch = false
					rowButton._dealMatch = false
					rowButton:Hide()
				end
			end
		end
	end

	if _G["MW_FooterStatus"] then
		local expiry = MarketWatch.db and MarketWatch.db.profile and MarketWatch.db.profile.expiryMinutes or 2
		MW_FooterStatus:SetText("Listings: " .. numItems .. "    |    Auto-expire: " .. expiry .. " min")
	end
end

-- Context Menu
local MW_ContextMenu = CreateFrame("Frame", "MW_ContextMenuFrame", UIParent, "UIDropDownMenuTemplate")

function MW_SaveListing(data)
	if not data then return end
	MarketWatch.db = MarketWatch.db or {}
	MarketWatch.db.profile = MarketWatch.db.profile or {}
	MarketWatch.db.profile.savedListings = MarketWatch.db.profile.savedListings or {}

	-- Same guard as MW_AddToBlacklist - without it, saving the same listing
	-- twice (double-click, or the same repost reappearing and getting saved
	-- again) silently piles up identical rows forever.
	for _, existing in ipairs(MarketWatch.db.profile.savedListings) do
		if existing.sender == data.sender and existing.message == data.message then
			DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[MarketWatch]|r Already saved that listing from " .. (data.sender or "Unknown") .. ".")
			return
		end
	end

	table.insert(MarketWatch.db.profile.savedListings, {
		sender = data.sender or "Unknown",
		message = data.message or "",
		category = data.category or "Uncategorized",
		kind = data.kind or "WTS",
		class = data.class,
		guid = data.guid,
		time = data.time or time(),
		savedAt = time(),
	})

	DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[MarketWatch]|r Saved listing from " .. (data.sender or "Unknown") .. "!")
	MW_UpdateSavedUI()
end

--------------------------------------------------------------------
-- Blacklist: add/remove + management window
--------------------------------------------------------------------
local function BlacklistFind(name)
	local list = MarketWatch.db.profile.blacklist
	local nameLo = name:lower()
	for i, e in ipairs(list) do
		if e.name and e.name:lower() == nameLo then return i end
	end
	return nil
end

-- entry is the exact same table reference stored in MW.data.* (GetListForTab/sort
-- never copy the entry tables themselves, only the arrays), so removal-by-identity
-- across every possible storage bucket reliably deletes exactly the clicked listing.
function MW_DeleteEntryEverywhere(entry)
	local function removeFrom(list)
		if not list then return end
		for i = #list, 1, -1 do
			if list[i] == entry then
				table.remove(list, i)
			end
		end
	end

	if MW.data and MW.data.items then
		for _, list in pairs(MW.data.items) do
			removeFrom(list)
		end
	end
	if MW.data then
		removeFrom(MW.data.chartrade)
		removeFrom(MW.data.boost)
		removeFrom(MW.data.transmog)
	end
end

function MW_AddToBlacklist(sender, knownClass, clickedEntry)
	if not sender or sender == "" then return end
	local db = MarketWatch.db.profile
	db.blacklist = db.blacklist or {}

	if BlacklistFind(sender) then
		DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[MarketWatch]|r " .. sender .. " is already blacklisted.")
		return
	end

	-- Resolve class the same way rows do, so the removal UI can show it correctly
	-- even though the entry itself never stores a class at capture time.
	local class = MW_NormalizeClass(knownClass) or MW_GetClassFromName(sender)

	table.insert(db.blacklist, { name = sender, class = class })
	DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[MarketWatch]|r Added " .. sender .. " to blacklist!")

	-- Instantly remove the specific listing that was right-clicked, rather
	-- than waiting for it to expire naturally.
	if clickedEntry then
		MW_DeleteEntryEverywhere(clickedEntry)
	end

	MW_UpdateUI()
	if MW_UpdateBlacklistUI then MW_UpdateBlacklistUI() end
end

function MW_RemoveFromBlacklist(index)
	local db = MarketWatch.db.profile
	if db.blacklist and db.blacklist[index] then
		table.remove(db.blacklist, index)
		if MW_UpdateBlacklistUI then MW_UpdateBlacklistUI() end
	end
end

local blacklistFrame, blacklistRows

local function BuildBlacklistFrame()
	if blacklistFrame then return end

	blacklistFrame = CreateFrame("Frame", "MW_BlacklistFrame", UIParent)
	blacklistFrame:SetSize(320, 400)
	-- Offset far enough from center that this can never overlap the
	-- Keyword window (see BuildKeywordFrame) even if both are open together.
	blacklistFrame:SetPoint("CENTER", 200, 0)
	blacklistFrame:SetMovable(true)
	blacklistFrame:EnableMouse(true)
	blacklistFrame:RegisterForDrag("LeftButton")
	blacklistFrame:SetScript("OnDragStart", function(self) self:StartMoving() end)
	blacklistFrame:SetScript("OnDragStop", function(self) self:StopMovingOrSizing() end)
	blacklistFrame:SetClampedToScreen(true)
	blacklistFrame:SetFrameStrata("DIALOG")
	blacklistFrame:Hide()

	blacklistFrame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
	blacklistFrame:SetBackdropColor(0.07, 0.07, 0.08, 0.97)
	blacklistFrame:SetBackdropBorderColor(0.55, 0.45, 0.22, 1)

	local title = blacklistFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
	title:SetPoint("TOP", 0, -12)
	title:SetText("|cffff0000Blacklist|r")

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

	local scroll = CreateFrame("ScrollFrame", "MW_BlacklistScroll", blacklistFrame, "UIPanelScrollFrameTemplate")
	scroll:SetPoint("TOPLEFT", 14, -36)
	scroll:SetPoint("BOTTOMRIGHT", -34, 14)
	MW_SkinScrollBar(scroll)

	local scrollChild = CreateFrame("Frame", nil, scroll)
	scrollChild:SetSize(1, 1)
	scroll:SetScrollChild(scrollChild)
	blacklistFrame.scrollChild = scrollChild

	blacklistRows = {}
end

function MW_UpdateBlacklistUI()
	if not blacklistFrame then return end

	local list = MarketWatch.db.profile.blacklist or {}
	local rowH = 24

	for _, r in ipairs(blacklistRows) do r:Hide() end

	blacklistFrame.scrollChild:SetHeight(math.max(1, #list * rowH))

	for i, entry in ipairs(list) do
		local row = blacklistRows[i]
		if not row then
			row = CreateFrame("Frame", nil, blacklistFrame.scrollChild)
			row:SetSize(280, rowH - 2)
			row:SetPoint("TOPLEFT", blacklistFrame.scrollChild, "TOPLEFT", 0, -(i - 1) * rowH)

			row.nameFS = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
			row.nameFS:SetPoint("LEFT", 4, 0)
			row.nameFS:SetWidth(190)
			row.nameFS:SetJustifyH("LEFT")

			row.removeBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
			row.removeBtn:SetSize(70, 20)
			row.removeBtn:SetText("Remove")
			row.removeBtn:SetPoint("RIGHT", 0, 0)
			MW_SkinButton(row.removeBtn)

			blacklistRows[i] = row
		end

		row.nameFS:SetText(GetClassColoredName(entry.name, entry.class, nil))
		row.removeBtn:SetScript("OnClick", function() MW_RemoveFromBlacklist(i) end)
		row:Show()
	end

	if #list == 0 then
		if not blacklistFrame.emptyFS then
			blacklistFrame.emptyFS = blacklistFrame.scrollChild:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
			blacklistFrame.emptyFS:SetPoint("TOPLEFT", 4, -4)
			blacklistFrame.emptyFS:SetText("No blacklisted sellers.")
		end
		blacklistFrame.emptyFS:Show()
	elseif blacklistFrame.emptyFS then
		blacklistFrame.emptyFS:Hide()
	end
end

function MW_ToggleBlacklistUI()
	BuildBlacklistFrame()
	if blacklistFrame:IsShown() then
		blacklistFrame:Hide()
	else
		blacklistFrame:Show()
		MW_UpdateBlacklistUI()
	end
end

--------------------------------------------------------------------
-- Keywords: add/remove + management window + row flash matching
--------------------------------------------------------------------
-- Strip WoW hyperlink/color escape codes before matching, so any hidden
-- formatting characters can never interfere with a plain substring search
-- (e.g. a shift-clicked item link like |cffffffff|Hitem:...|h[Primordial
-- Saronite]|h|r matches "Primordial Saronite" reliably either way, but this
-- removes any doubt).
local function StripCodesForMatching(message)
	local s = message
	s = s:gsub("|c%x%x%x%x%x%x%x%x", "")
	s = s:gsub("|H.-|h", "")
	s = s:gsub("|h", "")
	s = s:gsub("|r", "")
	return s
end

function MW_MessageMatchesKeyword(message)
	if not message then return false end
	local keywords = MarketWatch.db.profile.keywords
	if not keywords or #keywords == 0 then return false end

	local msgLo = StripCodesForMatching(message):lower()
	for _, kw in ipairs(keywords) do
		if kw ~= "" and msgLo:find(kw:lower(), 1, true) then
			return true
		end
	end
	return false
end

local keywordFrame, keywordRows, keywordEditBox

local function BuildKeywordFrame()
	if keywordFrame then return end

	keywordFrame = CreateFrame("Frame", "MW_KeywordFrame", UIParent)
	keywordFrame:SetSize(300, 380)
	-- Offset far enough from center that this can never overlap the
	-- Blacklist window (see BuildBlacklistFrame) even if both are open together.
	keywordFrame:SetPoint("CENTER", -200, 0)
	keywordFrame:SetMovable(true)
	keywordFrame:EnableMouse(true)
	keywordFrame:RegisterForDrag("LeftButton")
	keywordFrame:SetScript("OnDragStart", function(self) self:StartMoving() end)
	keywordFrame:SetScript("OnDragStop", function(self) self:StopMovingOrSizing() end)
	keywordFrame:SetClampedToScreen(true)
	keywordFrame:SetFrameStrata("DIALOG")
	keywordFrame:Hide()

	keywordFrame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
	keywordFrame:SetBackdropColor(0.07, 0.07, 0.08, 0.97)
	keywordFrame:SetBackdropBorderColor(0.55, 0.45, 0.22, 1)

	local title = keywordFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
	title:SetPoint("TOP", 0, -12)
	title:SetText("|cffff5555Keyword Alerts|r")

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

	keywordEditBox = CreateFrame("EditBox", "MW_KeywordEditBox", keywordFrame, "InputBoxTemplate")
	keywordEditBox:SetSize(180, 20)
	keywordEditBox:SetPoint("TOPLEFT", 20, -36)
	keywordEditBox:SetAutoFocus(false)
	MW_SkinEditBox(keywordEditBox)

	local addBtn = CreateFrame("Button", nil, keywordFrame, "UIPanelButtonTemplate")
	addBtn:SetSize(60, 22)
	addBtn:SetText("Add")
	addBtn:SetPoint("LEFT", keywordEditBox, "RIGHT", 8, 0)
	MW_SkinButton(addBtn)

	local function AddKeyword()
		local text = keywordEditBox:GetText()
		if text and text:trim() ~= "" then
			local trimmed = text:trim()
			local db = MarketWatch.db.profile
			db.keywords = db.keywords or {}

			-- Without this, double-clicking Add (or pressing Enter twice)
			-- silently piles up duplicate keyword entries forever.
			for _, existing in ipairs(db.keywords) do
				if existing:lower() == trimmed:lower() then
					keywordEditBox:SetText("")
					return
				end
			end

			table.insert(db.keywords, trimmed)
			keywordEditBox:SetText("")
			MW_UpdateKeywordUI()
			MW_UpdateUI()
		end
	end
	addBtn:SetScript("OnClick", AddKeyword)
	keywordEditBox:SetScript("OnEnterPressed", AddKeyword)

	local scroll = CreateFrame("ScrollFrame", "MW_KeywordScroll", keywordFrame, "UIPanelScrollFrameTemplate")
	scroll:SetPoint("TOPLEFT", 14, -66)
	scroll:SetPoint("BOTTOMRIGHT", -34, 14)
	MW_SkinScrollBar(scroll)

	local scrollChild = CreateFrame("Frame", nil, scroll)
	scrollChild:SetSize(1, 1)
	scroll:SetScrollChild(scrollChild)
	keywordFrame.scrollChild = scrollChild

	keywordRows = {}
end

function MW_UpdateKeywordUI()
	if not keywordFrame then return end

	local list = MarketWatch.db.profile.keywords or {}
	local rowH = 24

	for _, r in ipairs(keywordRows) do r:Hide() end

	keywordFrame.scrollChild:SetHeight(math.max(1, #list * rowH))

	for i, kw in ipairs(list) do
		local row = keywordRows[i]
		if not row then
			row = CreateFrame("Frame", nil, keywordFrame.scrollChild)
			row:SetSize(260, rowH - 2)
			row:SetPoint("TOPLEFT", keywordFrame.scrollChild, "TOPLEFT", 0, -(i - 1) * rowH)

			row.textFS = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
			row.textFS:SetPoint("LEFT", 4, 0)
			row.textFS:SetWidth(170)
			row.textFS:SetJustifyH("LEFT")

			row.removeBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
			row.removeBtn:SetSize(70, 20)
			row.removeBtn:SetText("Remove")
			row.removeBtn:SetPoint("RIGHT", 0, 0)
			MW_SkinButton(row.removeBtn)

			keywordRows[i] = row
		end

		row.textFS:SetText("|cffffd200" .. kw .. "|r")
		row.removeBtn:SetScript("OnClick", function()
			table.remove(MarketWatch.db.profile.keywords, i)
			MW_UpdateKeywordUI()
			MW_UpdateUI()
		end)
		row:Show()
	end

	if #list == 0 then
		if not keywordFrame.emptyFS then
			keywordFrame.emptyFS = keywordFrame.scrollChild:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
			keywordFrame.emptyFS:SetPoint("TOPLEFT", 4, -4)
			keywordFrame.emptyFS:SetText("No keywords yet. Type one above and hit Add.")
		end
		keywordFrame.emptyFS:Show()
	elseif keywordFrame.emptyFS then
		keywordFrame.emptyFS:Hide()
	end
end

function MW_ToggleKeywordUI()
	BuildKeywordFrame()
	if keywordFrame:IsShown() then
		keywordFrame:Hide()
	else
		keywordFrame:Show()
		MW_UpdateKeywordUI()
	end
end

-- Pulsing flash for rows that either match a keyword (red) or are flagged
-- as a deal (emerald/teal - deliberately distinct from the WTS kind tag's
-- own green so the two don't blend together). One shared ticker drives all
-- 17 pooled rows instead of an OnUpdate per row. A deal always wins over a
-- keyword match on the same row (see MW_UpdateUI). Two redundant visual
-- channels per state: the row background repaints via SetTexture (the Bg
-- region is a fileless <Color> texture in XML, so SetTexture(r,g,b,a) is
-- the reliable way to repaint it - SetVertexColor is meant for multiplying
-- over an actual image texture, not guaranteed for a solid-color one) and
-- the message text itself pulses between white and the state's color as a
-- second, independent guarantee that the flash is visible either way.
do
	local ticker = CreateFrame("Frame")
	local elapsedAccum = 0
	local UPDATE_INTERVAL = 0.06 -- ~16 updates/sec - smooth pulse, not per-frame
	local DEAL_COLOR = { 0.18, 0.80, 0.61 }

	ticker:SetScript("OnUpdate", function(self, elapsed)
		local mainFrame = _G["MarketWatchFrame"]
		if not mainFrame or not mainFrame:IsShown() then return end

		elapsedAccum = elapsedAccum + elapsed
		if elapsedAccum < UPDATE_INTERVAL then return end
		elapsedAccum = 0

		local pulse = (math.sin(GetTime() * 5) + 1) / 2 -- 0..1
		for i = 1, 17 do
			local rowButton = _G["MW_Row" .. i]
			local bg = _G["MW_Row" .. i .. "Bg"]
			local msgFrame = _G["MW_Row" .. i .. "Message"]

			if rowButton and rowButton:IsShown() and rowButton._dealMatch then
				if bg then
					bg:SetTexture(DEAL_COLOR[1], DEAL_COLOR[2], DEAL_COLOR[3], 0.35 + pulse * 0.35)
				end
				if msgFrame then
					local t = 1 - pulse
					msgFrame:SetTextColor(
						DEAL_COLOR[1] + (1 - DEAL_COLOR[1]) * t,
						DEAL_COLOR[2] + (1 - DEAL_COLOR[2]) * t,
						DEAL_COLOR[3] + (1 - DEAL_COLOR[3]) * t,
						1
					)
				end
			elseif rowButton and rowButton:IsShown() and rowButton._keywordMatch then
				if bg then
					bg:SetTexture(1, 0.1 * pulse, 0.1 * pulse, 0.35 + pulse * 0.35)
				end
				if msgFrame then
					msgFrame:SetTextColor(1, 0.3 + 0.7 * (1 - pulse), 0.3 + 0.7 * (1 - pulse), 1)
				end
			end
		end
	end)
end


--------------------------------------------------------------------
-- Copy Message popup (no direct clipboard API in this client - the
-- standard 3.3.5a-era workaround is an editable text box the player
-- can Ctrl+A/Ctrl+C from themselves once it's shown and selected).
--------------------------------------------------------------------
local copyFrame, copyEditBox

local function BuildCopyFrame()
	if copyFrame then return end

	copyFrame = CreateFrame("Frame", "MW_CopyFrame", UIParent)
	copyFrame:SetSize(460, 170)
	copyFrame:SetPoint("CENTER")
	copyFrame:SetFrameStrata("DIALOG")
	copyFrame:SetMovable(true)
	copyFrame:EnableMouse(true)
	copyFrame:RegisterForDrag("LeftButton")
	copyFrame:SetScript("OnDragStart", function(self) self:StartMoving() end)
	copyFrame:SetScript("OnDragStop", function(self) self:StopMovingOrSizing() end)
	copyFrame:SetClampedToScreen(true)

	copyFrame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
	copyFrame:SetBackdropColor(0.07, 0.07, 0.08, 0.97)
	copyFrame:SetBackdropBorderColor(0.55, 0.45, 0.22, 1)

	local title = copyFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
	title:SetPoint("TOP", 0, -12)
	title:SetText("|cffffd200Copy Message|r")

	local hint = copyFrame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
	hint:SetPoint("TOP", title, "BOTTOM", 0, -6)
	hint:SetText("Text is pre-selected - press Ctrl+C to copy")

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

	local scroll = CreateFrame("ScrollFrame", "MW_CopyScroll", copyFrame, "UIPanelScrollFrameTemplate")
	scroll:SetPoint("TOPLEFT", 16, -56)
	scroll:SetPoint("BOTTOMRIGHT", -34, 16)
	MW_SkinScrollBar(scroll)

	copyEditBox = CreateFrame("EditBox", nil, scroll)
	copyEditBox:SetMultiLine(true)
	copyEditBox:SetFontObject(ChatFontNormal)
	copyEditBox:SetWidth(400)
	copyEditBox:SetAutoFocus(true)
	copyEditBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
	scroll:SetScrollChild(copyEditBox)

	copyFrame:Hide()
end

function MW_ShowCopyPopup(entry)
	BuildCopyFrame()

	local categoryLabel = MW.categoryLabels[entry.category] or entry.category or "-"
	local plainMessage = StripCodesForMatching(entry.message)
	local senderLabel = entry.sender or "?"
	if entry.senderCount and entry.senderCount > 1 then
		senderLabel = senderLabel .. " (+" .. (entry.senderCount - 1) .. " more sellers)"
	end

	local text = string.format(
		"Sender: %s\nCategory: %s\nType: %s\n\n%s",
		senderLabel, categoryLabel, entry.kind or "?", plainMessage
	)

	copyEditBox:SetText(text)
	copyEditBox:HighlightText()
	copyEditBox:SetFocus()

	copyFrame:Show()
end

function MW_OnRowClick(self, button)
	local entry = self.entryData
	if not entry or not entry.sender then return end

	if button == "LeftButton" then
		ChatFrame_OpenChat("/w " .. entry.sender .. " ")
	elseif button == "RightButton" then
		local menuList = {
			{ text = "Seller: " .. entry.sender, isTitle = true },
			{ text = "Contact Seller", func = function() ChatFrame_OpenChat("/w " .. entry.sender .. " ") end },
			{ text = "Copy Message", func = function() MW_ShowCopyPopup(entry) end },
			{ text = "Save Listing", func = function() MW_SaveListing(entry) end },
			{ text = "Blacklist Seller", func = function()
				MW_AddToBlacklist(entry.sender, entry.class, entry)
			end },
			{ text = "Cancel", isTitle = false, func = function() end }
		}
		EasyMenu(menuList, MW_ContextMenu, "cursor", 0, 0, "MENU")
	end
end

function MW_ToggleSavedListingsUI()
	local savedFrame = _G["MarketWatchSavedFrame"]
	if not savedFrame then return end

	if savedFrame:IsShown() then
		savedFrame:Hide()
	else
		savedFrame:Show()
		MW_UpdateSavedUI()
	end
end

function MW_UpdateSavedUI()
	local savedFrame = _G["MarketWatchSavedFrame"]
	if not savedFrame or not savedFrame:IsShown() then return end

	local savedList = (MarketWatch.db and MarketWatch.db.profile and MarketWatch.db.profile.savedListings) or {}
	local numItems = #savedList
	local maxRows = 17
	local rowHeight = 22

	if MW_SavedScrollFrame then
		FauxScrollFrame_Update(MW_SavedScrollFrame, numItems, maxRows, rowHeight)
		local offset = FauxScrollFrame_GetOffset(MW_SavedScrollFrame)

		for i = 1, maxRows do
			local index = offset + i
			local rowButton = _G["MW_SavedRow" .. i]

			if rowButton then
				if index <= numItems then
					local data = savedList[index]
					rowButton.savedIndex = index
					rowButton.entryData = data

					if _G["MW_SavedRow" .. i .. "Message"] then _G["MW_SavedRow" .. i .. "Message"]:SetText(data.message or "") end
					if _G["MW_SavedRow" .. i .. "Seller"] then _G["MW_SavedRow" .. i .. "Seller"]:SetText(GetClassColoredName(data.sender, data.class, data.guid)) end

					local catKey = data.category or "Uncategorized"
					local catLabel = MW.categoryLabels and MW.categoryLabels[catKey] or catKey
					local catColor = CATEGORY_COLORS[catKey] or "|cffffffff"
					if _G["MW_SavedRow" .. i .. "Category"] then _G["MW_SavedRow" .. i .. "Category"]:SetText(catColor .. catLabel .. "|r") end
					if _G["MW_SavedRow" .. i .. "SavedTime"] then _G["MW_SavedRow" .. i .. "SavedTime"]:SetText(TimeAgo(data.savedAt or data.time)) end

					rowButton:Show()
				else
					rowButton:Hide()
				end
			end
		end
	end

	if _G["MW_SavedFooterStatus"] then
		MW_SavedFooterStatus:SetText("Saved Entries: " .. numItems)
	end
end

function MW_OnSavedRowClick(self, button)
	local entry = self.entryData
	local idx = self.savedIndex
	if not entry then return end

	if button == "LeftButton" then
		ChatFrame_OpenChat("/w " .. (entry.sender or "") .. " ")
	elseif button == "RightButton" then
		local menuList = {
			{ text = "Saved Entry: " .. (entry.sender or "Unknown"), isTitle = true },
			{ text = "Contact Seller", func = function() ChatFrame_OpenChat("/w " .. (entry.sender or "") .. " ") end },
			{ text = "Remove Saved Listing", func = function()
				if MarketWatch.db and MarketWatch.db.profile and MarketWatch.db.profile.savedListings then
					table.remove(MarketWatch.db.profile.savedListings, idx)
					MW_UpdateSavedUI()
				end
			end },
		}
		EasyMenu(menuList, MW_ContextMenu, "cursor", 0, 0, "MENU")
	end
end

function MW_OnRowEnter(self)
	local entry = self.entryData
	if not entry then return end

	GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
	GameTooltip:ClearLines()
	GameTooltip:AddLine((entry.sender or "Unknown") .. "  [" .. (entry.kind or "?") .. "]", 1, 0.82, 0)
	GameTooltip:AddLine(entry.channel or "", 0.6, 0.6, 0.6)

	if entry.senderCount and entry.senderCount > 1 and entry.senders then
		-- entry.sender (shown above) is itself a member of this set - list
		-- everyone else, so no name appears twice in the tooltip.
		local others = {}
		for name in pairs(entry.senders) do
			if name ~= entry.sender then
				table.insert(others, name)
			end
		end
		local shown = {}
		for i = 1, math.min(5, #others) do
			table.insert(shown, others[i])
		end
		local nameList = table.concat(shown, ", ")
		if #others > #shown then
			nameList = nameList .. ", +" .. (#others - #shown) .. " more"
		end
		GameTooltip:AddLine("Also posted by: " .. nameList, 1, 0.6, 0.2, true)
	end

	if entry.category then
		local catName = MW.categoryLabels and MW.categoryLabels[entry.category] or entry.category
		GameTooltip:AddLine("Category: " .. catName, 0.6, 0.8, 1)
	end

	if entry.isDeal then
		GameTooltip:AddLine(" ")
		GameTooltip:AddLine("DEAL - " .. (entry.dealPercent or 0) .. "% under market", 0.18, 0.8, 0.61)
		if entry.dealMarketPrice then
			GameTooltip:AddLine("Market price: " .. string.format("%.1fg", entry.dealMarketPrice / 10000), 0.8, 0.8, 0.8)
		end
		if entry.dealGoldSaved then
			GameTooltip:AddLine("You save: ~" .. entry.dealGoldSaved .. "g", 0.8, 0.8, 0.8)
		end
		if entry.dealSource then
			GameTooltip:AddLine("Source: " .. entry.dealSource, 0.5, 0.5, 0.5)
		end
	elseif entry.marketPrice then
		-- Not flagged as a deal (below the discount threshold, priced
		-- above market, or no listing price to compare against at all) -
		-- still show what Auctionator/TSM says it's worth so the user can
		-- judge it themselves, and so it's visible at a glance that price
		-- lookups are actually working.
		GameTooltip:AddLine(" ")
		GameTooltip:AddLine("Avg. market price: |cffffd100" .. string.format("%.1fg", entry.marketPrice / 10000) .. "|r", 0.18, 0.8, 0.61)
		if entry.priceSource then
			GameTooltip:AddLine("Source: " .. entry.priceSource, 0.5, 0.5, 0.5)
		end
	end

	GameTooltip:AddLine(" ")
	GameTooltip:AddLine(entry.message or "", 1, 1, 1, true)
	GameTooltip:AddLine(" ")
	GameTooltip:AddLine(date("%H:%M:%S", entry.time or time()) .. " (" .. TimeAgo(entry.time) .. " ago)", 0.5, 0.5, 0.5)
	GameTooltip:Show()
end

function MW.UI.ToggleMain()
	if not MW.IsUnlocked() then
		MW.UI.ShowLockOverlay()
		return
	end

	local frame = _G["MarketWatchFrame"]
	if not frame then return end

	-- Saved scale must be re-applied every time the window is (re)shown,
	-- since the XML frame exists before saved variables are even loaded -
	-- otherwise it silently reverts to 100% after every /reload until the
	-- config slider is touched again.
	local db = MarketWatch.db.profile
	MW.UI.ApplyScale((db.scalePercent or 100) / 100)

	if frame:IsShown() then
		frame:Hide()
	else
		frame:Show()
		MW_UpdateUI()
	end
end

function MW.UI.ToggleConfig()
	LibStub("AceConfigDialog-3.0"):Open("MarketWatch")
end

function MW.UI.ToggleTransmog()
	MW.UI.ToggleMain()
	MW_SelectTab("Transmog")
end

function MW.UI.ToggleCharTrade()
	MW.UI.ToggleMain()
	MW_SelectTab("CharTrade")
end

function MW.UI.OnNewEntry(routeType, catKey)
	MW_UpdateUI()
end

function MW.UI.RefreshActive()
	MW_UpdateUI()
end

function MW.UI.ApplyScale(scale)
	local frame = _G["MarketWatchFrame"]
	if frame then
		frame:SetScale(scale or 1)
	end
end

--------------------------------------------------------------------
-- Footer buttons for the new Blacklist / Keyword windows, anchored off
-- the existing Saved Listings button (defined in the XML).
--------------------------------------------------------------------
do
	local anchor = _G["MW_SavedListingsButton"]
	if anchor then
		local blacklistBtn = CreateFrame("Button", "MW_BlacklistButton", anchor:GetParent(), "UIPanelButtonTemplate")
		blacklistBtn:SetSize(90, 24)
		blacklistBtn:SetText("Blacklist")
		blacklistBtn:SetPoint("RIGHT", anchor, "LEFT", -8, 0)
		blacklistBtn:SetScript("OnClick", function() MW_ToggleBlacklistUI() end)
		MW_SkinButton(blacklistBtn)

		local keywordBtn = CreateFrame("Button", "MW_KeywordButton", anchor:GetParent(), "UIPanelButtonTemplate")
		keywordBtn:SetSize(90, 24)
		keywordBtn:SetText("Keywords")
		keywordBtn:SetPoint("RIGHT", blacklistBtn, "LEFT", -8, 0)
		keywordBtn:SetScript("OnClick", function() MW_ToggleKeywordUI() end)
		MW_SkinButton(keywordBtn)
	end
end

--------------------------------------------------------------------
-- Flat window chrome - replaces the old custom backdrop image and the
-- always-on animated rainbow border with the same minimal flat-panel
-- technique used for buttons above (mirrors RareHunt's own window
-- styling, so the two addons share one visual language).
--------------------------------------------------------------------
do
	local function SkinWindow(frame)
		if not frame then return end
		frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
		frame:SetBackdropColor(0.07, 0.07, 0.08, 0.97)
		frame:SetBackdropBorderColor(0.55, 0.45, 0.22, 1)
	end
	SkinWindow(_G["MarketWatchFrame"])
	SkinWindow(_G["MarketWatchSavedFrame"])
end

--------------------------------------------------------------------
-- Title text: gold base with a traveling gradient "spotlight" sweeping
-- across it - the glow itself cycles hue on its own clock (independent
-- of the sweep's travel time), so it reads as a soft moving rainbow
-- rather than a single flat color. Several neighboring letters blend
-- partially toward the glow at once (triangular falloff around a moving
-- center point), which is what reads as "smooth" instead of discrete
-- steps. Occasionally topped with a brief chromatic-aberration "glitch"
-- flourish - the same effect used on RareHunt's own title, ported here
-- so both addons share it.
--------------------------------------------------------------------
local function HSVtoRGB(h, s, v)
	local c = v * s
	local x = c * (1 - math.abs(((h / 60) % 2) - 1))
	local m = v - c
	local r, g, b = 0, 0, 0

	if h < 60 then r, g, b = c, x, 0
	elseif h < 120 then r, g, b = x, c, 0
	elseif h < 180 then r, g, b = 0, c, x
	elseif h < 240 then r, g, b = 0, x, c
	elseif h < 300 then r, g, b = x, 0, c
	else r, g, b = c, 0, x
	end

	return r + m, g + m, b + m
end

do
	local baseText = "RealNibba's MarketWatcher"
	local baseLen = #baseText
	local CYCLE_SECONDS = 30
	local GLOW_WIDTH = 2.5      -- how many letters on each side catch partial glow
	local HUE_CYCLE_SECONDS = 24 -- the glow's own color-drift clock

	local GOLD = { 1.0, 0.82, 0.0 }

	local elapsedAccum = 0
	local UPDATE_INTERVAL = 0.08 -- ~12 updates/sec - smooth motion, still far from per-frame

	local ticker = CreateFrame("Frame")
	ticker:SetScript("OnUpdate", function(self, elapsed)
		local titleFS = _G["MW_MainTitle"]
		local mainFrame = _G["MarketWatchFrame"]
		if not titleFS or not mainFrame or not mainFrame:IsShown() then return end

		elapsedAccum = elapsedAccum + elapsed
		if elapsedAccum < UPDATE_INTERVAL then return end
		elapsedAccum = 0

		-- center travels from just before the first letter to just past the
		-- last one, so the glow fades in/out smoothly at both ends instead
		-- of appearing/disappearing abruptly
		local progress = (GetTime() % CYCLE_SECONDS) / CYCLE_SECONDS
		local span = baseLen + (2 * GLOW_WIDTH)
		local center = (progress * span) - GLOW_WIDTH

		local hue = (GetTime() % HUE_CYCLE_SECONDS) / HUE_CYCLE_SECONDS * 360
		local gr, gg, gb = HSVtoRGB(hue, 0.8, 1)

		local parts = {}
		for i = 1, baseLen do
			local ch = baseText:sub(i, i)
			local dist = math.abs(i - center)
			local influence = 1 - (dist / GLOW_WIDTH)
			if influence < 0 then influence = 0 end

			local r = GOLD[1] + (gr - GOLD[1]) * influence
			local g = GOLD[2] + (gg - GOLD[2]) * influence
			local b = GOLD[3] + (gb - GOLD[3]) * influence

			table.insert(parts, string.format("|cff%02x%02x%02x%s|r",
				math.floor(r * 255 + 0.5), math.floor(g * 255 + 0.5), math.floor(b * 255 + 0.5), ch))
		end

		titleFS:SetText(table.concat(parts) .. " |cff888888v1.0|r")
	end)

	------ Title Glitch Effect (ported from RareHunt)
	-- Every so often, briefly split the title into offset red/blue copies
	-- (chromatic aberration look) with a few rapid jitters, then settle
	-- back to normal.
	local mainFrame = _G["MarketWatchFrame"]
	if mainFrame then
		local titleGlitchRed = mainFrame:CreateFontString(nil, "ARTWORK", "GameFontNormal")
		titleGlitchRed:SetPoint("TOP", 0, -12)
		titleGlitchRed:SetText(baseText)
		titleGlitchRed:SetTextColor(1, 0.15, 0.15, 0.8)
		titleGlitchRed:Hide()

		local titleGlitchBlue = mainFrame:CreateFontString(nil, "ARTWORK", "GameFontNormal")
		titleGlitchBlue:SetPoint("TOP", 0, -12)
		titleGlitchBlue:SetText(baseText)
		titleGlitchBlue:SetTextColor(0.25, 0.55, 1, 0.8)
		titleGlitchBlue:Hide()

		local function ResetTitleGlitch()
			titleGlitchRed:Hide()
			titleGlitchBlue:Hide()
		end

		local function DoGlitchBurst()
			local flickers = math.random(3, 6)

			local function Flicker()
				flickers = flickers - 1
				if flickers < 0 then
					ResetTitleGlitch()
					return
				end

				titleGlitchRed:ClearAllPoints()
				titleGlitchRed:SetPoint("TOP", 0 - math.random(1, 3), -12 + math.random(-2, 2))
				titleGlitchBlue:ClearAllPoints()
				titleGlitchBlue:SetPoint("TOP", 0 + math.random(1, 3), -12 + math.random(-2, 2))

				titleGlitchRed:Show()
				titleGlitchBlue:Show()

				MarketWatch:ScheduleTimer(Flicker, math.random(40, 90) / 1000)
			end

			Flicker()
		end

		local function ScheduleNextGlitch()
			MarketWatch:ScheduleTimer(function()
				if mainFrame:IsShown() then
					DoGlitchBurst()
				end
				ScheduleNextGlitch()
			end, math.random(8, 20))
		end
		ScheduleNextGlitch()
	end
end

--------------------------------------------------------------------
-- Minimap button (built by hand - no LibDBIcon bundled with this Ace3 copy)
-- Left click: toggle main window. Right click: open config. Draggable
-- around the minimap ring, position persisted to db.profile.minimap.angle.
--------------------------------------------------------------------
do
	local RADIUS = 80

	local function GetSavedMinimapDB()
		local db = MarketWatch.db and MarketWatch.db.profile
		if not db then return nil end
		db.minimap = db.minimap or { angle = 215, hide = false }
		return db.minimap
	end

	local minimapBtn = CreateFrame("Button", "MW_MinimapButton", Minimap)
	minimapBtn:SetSize(31, 31)
	minimapBtn:SetFrameStrata("MEDIUM")
	minimapBtn:SetFrameLevel(8)
	minimapBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
	minimapBtn:RegisterForDrag("LeftButton")
	minimapBtn:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")

	local icon = minimapBtn:CreateTexture(nil, "BACKGROUND")
	icon:SetTexture("Interface\\Icons\\inv_misc_coin_02")
	icon:SetSize(20, 20)
	icon:SetPoint("CENTER", 0, 0)
	icon:SetTexCoord(0.07, 0.93, 0.07, 0.93) -- crop the icon's own border, matches Blizzard minimap buttons

	local border = minimapBtn:CreateTexture(nil, "OVERLAY")
	border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")
	border:SetSize(54, 54)
	border:SetPoint("TOPLEFT", 0, 0)

	local function UpdateMinimapPosition()
		local mdb = GetSavedMinimapDB()
		if not mdb then return end
		local angle = math.rad(mdb.angle or 215)
		minimapBtn:ClearAllPoints()
		minimapBtn:SetPoint("CENTER", Minimap, "CENTER", RADIUS * math.cos(angle), RADIUS * math.sin(angle))
	end

	local function OnDragUpdate(self)
		local mx, my = Minimap:GetCenter()
		local px, py = GetCursorPosition()
		local scale = Minimap:GetEffectiveScale()
		px, py = px / scale, py / scale

		local angle = math.deg(math.atan2(py - my, px - mx))
		local mdb = GetSavedMinimapDB()
		if mdb then mdb.angle = angle end
		UpdateMinimapPosition()
	end

	minimapBtn:SetScript("OnDragStart", function(self)
		self:SetScript("OnUpdate", OnDragUpdate)
	end)
	minimapBtn:SetScript("OnDragStop", function(self)
		self:SetScript("OnUpdate", nil)
	end)

	minimapBtn:SetScript("OnClick", function(self, button)
		if button == "LeftButton" then
			MW.UI.ToggleMain()
		elseif button == "RightButton" then
			MW.UI.ToggleConfig()
		end
	end)

	minimapBtn:SetScript("OnEnter", function(self)
		GameTooltip:SetOwner(self, "ANCHOR_LEFT")
		GameTooltip:ClearLines()
		GameTooltip:AddLine("|cffff0000RealNibba|r's |cff00ff00MarketWatcher|r  |cff888888v1.0|r", 1, 1, 1)
		GameTooltip:AddLine("Left-click: open tracker", 0.8, 0.8, 0.8)
		GameTooltip:AddLine("Right-click: open config", 0.8, 0.8, 0.8)
		if MW.Pricing and MW.Pricing.anyDealLive then
			GameTooltip:AddLine("A deal is currently live!", 0.18, 0.8, 0.61)
		end
		GameTooltip:Show()
	end)
	minimapBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

	-- position it once the saved-variables DB actually exists (OnInitialize runs at ADDON_LOADED)
	local waiter = CreateFrame("Frame")
	waiter:RegisterEvent("PLAYER_LOGIN")
	waiter:SetScript("OnEvent", function()
		UpdateMinimapPosition()
		local mdb = GetSavedMinimapDB()
		if mdb and mdb.hide then
			minimapBtn:Hide()
		end
	end)

	-- Glows the coin icon gold when a deal is currently live (MW.Pricing.
	-- anyDealLive, recomputed whenever entries are added/expired - see
	-- Core.lua). Cheap boolean check each tick; only does any real work
	-- while a deal is actually active.
	local glowElapsed = 0
	local GLOW_INTERVAL = 0.1
	local glowTicker = CreateFrame("Frame")
	glowTicker:SetScript("OnUpdate", function(self, elapsed)
		if not (MW.Pricing and MW.Pricing.anyDealLive) then
			icon:SetVertexColor(1, 1, 1)
			return
		end

		glowElapsed = glowElapsed + elapsed
		if glowElapsed < GLOW_INTERVAL then return end
		glowElapsed = 0

		local pulse = (math.sin(GetTime() * 4) + 1) / 2
		icon:SetVertexColor(1, 0.82 + 0.18 * pulse, 0.2 + 0.4 * pulse)
	end)
end