local _, ns = ...
local SlickUI = ns.SlickUI
local Theme = SlickUI.Theme

local MythicKeys = {
	Prefix = "SlickUIKeys",
	AstralPrefix = "AstralKeys",
	Protocol = "V3",
	VisibleRows = 15,
	ScrollOffset = 0,
	Filtered = {},
	LastOwnSignature = nil,
	LastOwnedKeySignature = nil,
	LastAstralSignature = nil,
}
SlickUI.MythicKeys = MythicKeys

StaticPopupDialogs.SLICKUI_ANNOUNCE_NEW_KEY = {
	text = "SlickUI found your new Mythic+ key:\n\n%s\n\nPrepare an announcement in your current group channel?",
	button1 = "Prepare",
	button2 = CANCEL,
	OnAccept = function(_, data)
		if SlickUI.MythicKeys then SlickUI.MythicKeys:SendNewKeyAnnouncement(data) end
	end,
	timeout = 0,
	whileDead = true,
	hideOnEscape = true,
	preferredIndex = 3,
}

local function IsReadable(value)
	if canaccessvalue and not canaccessvalue(value) then return false end
	if issecretvalue and issecretvalue(value) then return false end
	return value ~= nil
end

local function SafeString(value)
	return IsReadable(value) and type(value) == "string" and value or nil
end

local function SafeNumber(value)
	return IsReadable(value) and type(value) == "number" and value or nil
end

local function ShortName(name)
	name = SafeString(name)
	return name and Ambiguate and Ambiguate(name, "short") or name or "Unknown"
end

local function FullPlayerName()
	local name, realm = UnitFullName("player")
	name = SafeString(name) or SafeString(UnitName("player")) or "Unknown"
	realm = SafeString(realm)
	if realm and realm ~= "" then return name .. "-" .. realm:gsub("%s+", "") end
	return name
end

local function CacheKey(name)
	return string.lower((SafeString(name) or "unknown"):gsub("%s+", ""))
end

local function ClassColor(classFile)
	local color = classFile and RAID_CLASS_COLORS and RAID_CLASS_COLORS[classFile]
	return color and color.r or 0.92, color and color.g or 0.94, color and color.b or 1.00
end

local function DungeonName(mapID)
	mapID = tonumber(mapID)
	if not mapID or mapID <= 0 or not C_ChallengeMode or type(C_ChallengeMode.GetMapUIInfo) ~= "function" then return "No Keystone" end
	local ok, name = pcall(C_ChallengeMode.GetMapUIInfo, mapID)
	return ok and SafeString(name) and name ~= "" and name or ("Dungeon " .. mapID)
end

local function NormalizeKeystoneData(data, mapID, level)
	data = SafeString(data)
	mapID, level = tonumber(mapID), tonumber(level)
	if not data or not mapID or not level or mapID <= 0 or level <= 0 or #data > 160 then return nil end
	local item, linkedMap, linkedLevel = data:match("^keystone:(%d+):(%d+):(%d+):")
	if not item or tonumber(linkedMap) ~= mapID or tonumber(linkedLevel) ~= level then return nil end
	-- Blizzard has extended the payload over time. Accept numeric colon fields
	-- after the verified item/map/level prefix instead of assuming one length.
	if not data:match("^keystone:%d+:%d+:%d+:[%d:]+$") then return nil end
	return data
end

local function KeystoneInfoFromLink(link)
	link = SafeString(link)
	if not link then return nil end
	local item, mapID, level, affix1, affix2, affix3, affix4 = link:match("keystone:(%d+):(%d+):(%d+):(%d+):(%d+):(%d+):(%d+)")
	if item then return tonumber(item), tonumber(mapID), tonumber(level), tonumber(affix1) or 0, tonumber(affix2) or 0, tonumber(affix3) or 0, tonumber(affix4) or 0 end
	local itemID, raw = link:match("item:(187786):([^|]+)")
	if not itemID then itemID, raw = link:match("item:(180653):([^|]+)") end
	if not itemID then itemID, raw = link:match("item:(138019):([^|]+)") end
	if not itemID or not raw then return nil end
	local fields, info = { strsplit(":", raw) }, {}
	for index = 12, #fields, 2 do
		local key = tonumber(fields[index])
		if key and key >= 17 and key <= 23 then info[key - 16] = fields[index + 1] end
	end
	return tonumber(itemID), tonumber(info[1]), tonumber(info[2]), tonumber(info[3]) or 0, tonumber(info[4]) or 0, tonumber(info[5]) or 0, tonumber(info[6]) or 0
end

local function ValidateKeystoneLink(link, mapID, level)
	link = SafeString(link)
	if not link or #link > 500 or link:find("[\r\n]") then return nil end
	local _, linkedMap, linkedLevel = KeystoneInfoFromLink(link)
	if linkedMap ~= tonumber(mapID) or linkedLevel ~= tonumber(level) then return nil end
	if not link:find("|H", 1, true) or not link:find("|h", 1, true) then return nil end
	return link
end

local function EncodeLink(link)
	link = SafeString(link)
	if not link then return "" end
	return (link:gsub("%%", "%%25"):gsub("|", "%%7C"))
end

local function DecodeLink(value)
	value = SafeString(value)
	if not value or #value > 1000 then return nil end
	return (value:gsub("%%7C", "|"):gsub("%%25", "%%"))
end

local function FindOwnedKeystoneLink(mapID, level)
	if not C_Container then return nil end
	for bag = 0, (NUM_BAG_SLOTS or 4) do
		local slots
		if type(C_Container.GetContainerNumSlots) == "function" then
			local ok, value = pcall(C_Container.GetContainerNumSlots, bag)
			if ok then slots = SafeNumber(value) end
		end
		for slot = 1, (slots or 0) do
			local ok, link = pcall(C_Container.GetContainerItemLink, bag, slot)
			if ok then
				link = ValidateKeystoneLink(link, mapID, level)
				if link then return link end
			end
		end
	end
	return nil
end

local function KeystoneLink(entry)
	if type(entry) ~= "table" or (tonumber(entry.level) or 0) <= 0 or (tonumber(entry.mapID) or 0) <= 0 then return nil end
	local data = NormalizeKeystoneData(entry.keystoneData, entry.mapID, entry.level)
	local exactLink = ValidateKeystoneLink(entry.keystoneLink, entry.mapID, entry.level)
	if exactLink then return exactLink end
	if not data then return nil end
	return string.format("|cffa335ee|H%s|h[Keystone: %s (+%d)]|h|r", data, DungeonName(entry.mapID), entry.level)
end

local function RelativeAge(timestamp)
	timestamp = tonumber(timestamp)
	if not timestamp or timestamp <= 0 then return "Unknown" end
	local age = math.max(0, (time and time() or 0) - timestamp)
	if age < 60 then return "Now" end
	if age < 3600 then return math.floor(age / 60) .. "m" end
	if age < 86400 then return math.floor(age / 3600) .. "h" end
	return math.floor(age / 86400) .. "d"
end

local function SourceLabel(entry)
	if entry.source == "self" then return "This character" end
	if entry.source == "astral" then
		return entry.scope and ("Astral: " .. entry.scope) or "Astral Keys"
	end
	if entry.scope == "PARTY" or entry.scope == "RAID" then return "Party / Raid" end
	if entry.scope == "GUILD" then return "Guild" end
	if entry.scope == "WHISPER" or entry.scope == "BNET" then return "Friends" end
	return "SlickUI"
end

local function CurrentWeeklyBest()
	local best = 0
	if C_MythicPlus and type(C_MythicPlus.GetRunHistory) == "function" then
		local ok, history = pcall(C_MythicPlus.GetRunHistory, false, true)
		if ok and type(history) == "table" then
			for _, run in ipairs(history) do
				if type(run) == "table" and run.thisWeek and SafeNumber(run.level) then best = math.max(best, run.level) end
			end
		end
	end
	return best
end

local function CurrentScore()
	if C_PlayerInfo and type(C_PlayerInfo.GetPlayerMythicPlusRatingSummary) == "function" then
		local ok, summary = pcall(C_PlayerInfo.GetPlayerMythicPlusRatingSummary, "player")
		if ok and type(summary) == "table" then return SafeNumber(summary.currentSeasonScore) or 0 end
	end
	return 0
end

local function CurrentFaction()
	local faction = UnitFactionGroup and UnitFactionGroup("player")
	return SafeString(faction) or "Neutral"
end

function MythicKeys:GetDB()
	return SlickUI.DB.mythicKeys
end

function MythicKeys:MergeEntry(entry, force, silent)
	if type(entry) ~= "table" or not SafeString(entry.name) then return false end
	entry.mapID = tonumber(entry.mapID) or 0
	entry.level = tonumber(entry.level) or 0
	entry.weeklyBest = tonumber(entry.weeklyBest) or 0
	entry.score = tonumber(entry.score) or 0
	entry.timestamp = tonumber(entry.timestamp) or (time and time() or 0)
	entry.classFile = SafeString(entry.classFile) or "PRIEST"
	entry.source = SafeString(entry.source) or "slickui"
	entry.scope = SafeString(entry.scope) or "UNKNOWN"
	entry.faction = SafeString(entry.faction) or "Neutral"
	entry.keystoneData = NormalizeKeystoneData(entry.keystoneData, entry.mapID, entry.level)
	entry.keystoneLink = ValidateKeystoneLink(entry.keystoneLink, entry.mapID, entry.level)
	if #entry.name > 80 or #entry.classFile > 20 or entry.mapID < 0 or entry.level < 0 or entry.level > 99 then return false end
	local now = time and time() or 0
	if entry.timestamp > now + 300 then entry.timestamp = now end
	local key = CacheKey(entry.name)
	local current = self:GetDB().cache[key]
	local currentStamp = current and tonumber(current.timestamp) or 0
	if current and current.source == "self" and entry.source ~= "self" then return false end
	if not force and current and currentStamp > entry.timestamp and current.source ~= "astral" then return false end
	self:GetDB().cache[key] = entry
	if not silent then self:RefreshDisplays() end
	return true
end

function MythicKeys:GetOwnEntry()
	local mapID, level, keystoneData, keystoneLink
	if C_MythicPlus then
		if type(C_MythicPlus.GetOwnedKeystoneChallengeMapID) == "function" then
			local ok, value = pcall(C_MythicPlus.GetOwnedKeystoneChallengeMapID)
			if ok then mapID = SafeNumber(value) end
		end
		if type(C_MythicPlus.GetOwnedKeystoneLevel) == "function" then
			local ok, value = pcall(C_MythicPlus.GetOwnedKeystoneLevel)
			if ok then level = SafeNumber(value) end
		end
		if type(C_MythicPlus.GetOwnedKeystoneLink) == "function" then
			local ok, link = pcall(C_MythicPlus.GetOwnedKeystoneLink)
			if ok and SafeString(link) then
				keystoneLink = link
				keystoneData = link:match("|H(keystone:[^|]+)|h")
			end
		end
	end
	keystoneLink = ValidateKeystoneLink(keystoneLink, mapID, level) or FindOwnedKeystoneLink(mapID, level)
	if keystoneLink then
		keystoneData = keystoneLink:match("|H(keystone:[^|]+)|h") or keystoneData
		if not keystoneData then
			local itemID, linkedMap, linkedLevel, affix1, affix2, affix3, affix4 = KeystoneInfoFromLink(keystoneLink)
			if itemID and linkedMap and linkedLevel then
				keystoneData = string.format("keystone:%d:%d:%d:%d:%d:%d:%d", itemID, linkedMap, linkedLevel, affix1, affix2, affix3, affix4)
			end
		end
	end
	local _, classFile = UnitClass("player")
	return {
		name = FullPlayerName(), classFile = SafeString(classFile) or "PRIEST",
		mapID = mapID or 0, level = level or 0, weeklyBest = CurrentWeeklyBest(), score = CurrentScore(),
		keystoneData = NormalizeKeystoneData(keystoneData, mapID, level),
		keystoneLink = ValidateKeystoneLink(keystoneLink, mapID, level),
		faction = CurrentFaction(), timestamp = time and time() or 0, source = "self", scope = "SELF",
	}
end

function MythicKeys:Serialize(entry)
	-- The compact authoritative keystone payload stays safely below WoW's addon
	-- message limit even when the bag item link itself contains many bonus fields.
	return table.concat({ self.Protocol, entry.name, entry.classFile, entry.mapID, entry.level, entry.weeklyBest, entry.score, entry.faction, entry.timestamp, entry.keystoneData or "", "" }, "|")
end

function MythicKeys:ParseNative(message, channel)
	if not SafeString(message) then return nil end
	local version, name, classFile, mapID, level, weeklyBest, score, faction, timestamp, keystoneData, encodedLink = strsplit("|", message)
	if (version ~= self.Protocol and version ~= "V2" and version ~= "V1") or not name or not classFile then return nil end
	mapID, level = tonumber(mapID), tonumber(level)
	return {
		name = name, classFile = classFile, mapID = mapID, level = level,
		weeklyBest = tonumber(weeklyBest), score = tonumber(score), faction = faction,
		timestamp = tonumber(timestamp), source = "slickui", scope = channel,
		keystoneData = NormalizeKeystoneData(keystoneData, mapID, level),
		keystoneLink = ValidateKeystoneLink(DecodeLink(encodedLink), mapID, level),
	}
end

function MythicKeys:BroadcastNative(entry)
	if not C_ChatInfo or type(C_ChatInfo.SendAddonMessage) ~= "function" then return end
	local message = self:Serialize(entry)
	if IsInGuild and IsInGuild() then pcall(C_ChatInfo.SendAddonMessage, self.Prefix, message, "GUILD") end
	if IsInRaid and IsInRaid() then
		pcall(C_ChatInfo.SendAddonMessage, self.Prefix, message, "RAID")
	elseif IsInGroup and IsInGroup() then
		pcall(C_ChatInfo.SendAddonMessage, self.Prefix, message, "PARTY")
	end
end

function MythicKeys:AstralWeekForPlayer()
	if type(AstralKeys) == "table" then
		local player = CacheKey(FullPlayerName())
		for _, entry in ipairs(AstralKeys) do
			if type(entry) == "table" and CacheKey(entry.unit) == player then return tonumber(entry.week) or 0 end
		end
	end
	return 0
end

function MythicKeys:BroadcastAstral(entry)
	if not IsInGuild or not IsInGuild() or not C_ChatInfo or type(C_ChatInfo.SendAddonMessage) ~= "function" then return end
	local payload = string.format("updateV9 %s:%s:%d:%d:%d:%d:%d", entry.name, entry.classFile, entry.mapID, entry.level, entry.weeklyBest, self:AstralWeekForPlayer(), entry.score)
	pcall(C_ChatInfo.SendAddonMessage, self.AstralPrefix, payload, "GUILD")
end

function MythicKeys:GetGroupChatChannel(allowGuild)
	local channel
	if IsInGroup and IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then
		channel = "INSTANCE_CHAT"
	elseif IsInRaid and IsInRaid() then
		channel = "RAID"
	elseif IsInGroup and IsInGroup() then
		channel = "PARTY"
	elseif allowGuild and IsInGuild and IsInGuild() then
		channel = "GUILD"
	end
	return channel
end

function MythicKeys:SendNewKeyAnnouncement(entry)
	if not entry or entry.level <= 0 then return end
	local channel = self:GetGroupChatChannel(true)
	if not channel then
		SlickUI:Print("No party, raid, instance, or guild channel is available.")
		return
	end
	local slashByChannel = { INSTANCE_CHAT = "/i ", RAID = "/raid ", PARTY = "/party ", GUILD = "/guild " }
	local message = "SlickUI: My new Mythic+ key is " .. (KeystoneLink(entry) or string.format("+%d %s", entry.level, DungeonName(entry.mapID))) .. "."
	if type(ChatFrame_OpenChat) == "function" then
		ChatFrame_OpenChat((slashByChannel[channel] or "") .. message)
		SlickUI:Print("New-key announcement prepared. Press Enter to send it.")
	else
		SlickUI:Print("WoW's chat input is unavailable; the announcement was not sent.")
	end
end

function MythicKeys:AnnounceNewKey(entry)
	if not self:GetDB().announceNewKey or not entry or entry.level <= 0 then return end
	StaticPopup_Show("SLICKUI_ANNOUNCE_NEW_KEY", string.format("+%d %s", entry.level, DungeonName(entry.mapID)), nil, entry)
end

function MythicKeys:AppendKeyReports(reports, entries, channel)
	if not channel then return 0 end
	local added = 0
	for _, entry in ipairs(entries) do
		local message
		if entry.level > 0 then
			message = string.format("%s: %s", ShortName(entry.name), KeystoneLink(entry) or string.format("+%d %s", entry.level, DungeonName(entry.mapID)))
		else
			message = string.format("%s: no key reported", ShortName(entry.name))
		end
		reports[#reports + 1] = { message = message, channel = channel }
		added = added + 1
	end
	return added
end

function MythicKeys:BuildSelectedKeyReports()
	local scope = self:GetDB().scopeFilter or "all"
	local groupChannel = self:GetGroupChatChannel(false)
	local guildChannel = IsInGuild and IsInGuild() and "GUILD" or nil
	local reports = {}

	if scope == "group" then
		if not groupChannel then SlickUI:Print("You must be in a party or raid to report party keys."); return reports end
		local entries = {}
		for _, entry in ipairs(self:BuildGroupRosterEntries()) do
			if self:MatchesFilters(entry) then entries[#entries + 1] = entry end
		end
		self:AppendKeyReports(reports, entries, groupChannel)
	elseif scope == "guild" then
		if not guildChannel then SlickUI:Print("You must be in a guild to report guild keys."); return reports end
		self:BuildFiltered()
		self:AppendKeyReports(reports, self.Filtered, guildChannel)
	elseif scope == "all" then
		if groupChannel then
			local groupEntries = {}
			for _, entry in ipairs(self:BuildGroupRosterEntries()) do
				if self:MatchesFilters(entry) then groupEntries[#groupEntries + 1] = entry end
			end
			self:AppendKeyReports(reports, groupEntries, groupChannel)
		end
		if guildChannel then
			self:AppendKeyReports(reports, self:BuildGuildRosterEntries(), guildChannel)
		end
	else
		-- This Character, Friends, and Astral views report their filtered rows to
		-- the best available active channel without changing the selected filter.
		local channel = groupChannel or guildChannel
		if not channel then SlickUI:Print("No party, raid, instance, or guild channel is available."); return reports end
		self:BuildFiltered()
		self:AppendKeyReports(reports, self.Filtered, channel)
	end

	if #reports == 0 then SlickUI:Print("No keys match the selected source and filters.") end
	return reports
end

function MythicKeys:RefreshOwnKey(force)
	local entry = self:GetOwnEntry()
	local signature = table.concat({ entry.mapID, entry.level, entry.weeklyBest, entry.score }, ":")
	local keySignature = table.concat({ entry.mapID, entry.level }, ":")
	entry.timestamp = time and time() or 0
	self:MergeEntry(entry, true)
	local db = self:GetDB()
	db.seenOwnedKeys = type(db.seenOwnedKeys) == "table" and db.seenOwnedKeys or {}
	local characterKey = CacheKey(FullPlayerName())
	local seen = db.seenOwnedKeys[characterKey]
	if type(seen) ~= "table" then
		seen = {}
		db.seenOwnedKeys[characterKey] = seen
	end
	local hasSeenPrevious = next(seen) ~= nil
	if not seen[keySignature] then
		-- The first observed state establishes a persistent baseline without a
		-- login popup. A later unseen dungeon/level combination is a new key.
		seen[keySignature] = entry.timestamp
		if hasSeenPrevious and entry.level > 0 then self:AnnounceNewKey(entry) end
	end
	self.LastOwnedKeySignature = keySignature
	if force or signature ~= self.LastOwnSignature then
		self.LastOwnSignature = signature
		self:BroadcastNative(entry)
		self:BroadcastAstral(entry)
	end
end

function MythicKeys:ImportAstralCache()
	if type(AstralKeys) ~= "table" then return end
	local signatures = {}
	for _, value in ipairs(AstralKeys) do
		if type(value) == "table" and SafeString(value.unit) then
			local signature = table.concat({ value.unit, value.dungeon_id or 0, value.key_level or 0, value.weekly_best or 0, value.mplus_score or 0, value.time_stamp or 0 }, ":")
			signatures[#signatures + 1] = signature
		end
	end
	table.sort(signatures)
	local combined = table.concat(signatures, "|")
	if combined == self.LastAstralSignature then return end
	self.LastAstralSignature = combined
	for _, value in ipairs(AstralKeys) do
		if type(value) == "table" and SafeString(value.unit) then
			self:MergeEntry({
				name = value.unit, classFile = value.class, mapID = value.dungeon_id, level = value.key_level,
				weeklyBest = value.weekly_best, score = value.mplus_score, faction = value.faction,
				timestamp = time and time() or 0, source = "astral", scope = value.source or "cache",
			}, false, true)
		end
	end
	self:RefreshDisplays()
end

function MythicKeys:ParseAstralRecord(record, kind, channel)
	local values = { strsplit(":", record) }
	local name, classFile = values[1], values[2]
	if not SafeString(name) or not SafeString(classFile) then return nil end
	local mapID, level, weeklyBest, score
	if kind == "update5" then
		mapID, level, weeklyBest, score = values[3], values[4], values[5], values[8]
	elseif kind == "sync6" and channel == "BNET" then
		mapID, level, weeklyBest, score = values[3], values[4], values[8], values[9]
	else
		mapID, level, weeklyBest, score = values[3], values[4], values[5], kind == "sync6" and values[8] or values[7]
	end
	return {
		name = name, classFile = classFile, mapID = tonumber(mapID), level = tonumber(level),
		weeklyBest = tonumber(weeklyBest), score = tonumber(score), faction = kind == "update5" and values[7] or nil,
		timestamp = time and time() or 0, source = "astral", scope = channel,
	}
end

function MythicKeys:ReceiveAstral(message, channel)
	if not SafeString(message) then return end
	local kind, payload = message:match("^(%S+)%s+(.+)$")
	if kind == "updateV9" or kind == "updateV8" or kind == "update5" then
		local entry = self:ParseAstralRecord(payload, kind, channel)
		if entry then self:MergeEntry(entry, false) end
	elseif kind == "sync6" then
		for record in payload:gmatch("([^_]+)") do
			local entry = self:ParseAstralRecord(record, kind, channel)
			if entry then self:MergeEntry(entry, false, true) end
		end
		self:RefreshDisplays()
	end
end

function MythicKeys:GetEntries()
	local entries = {}
	local cutoff = (time and time() or 0) - (self:GetDB().maxAgeDays or 21) * 86400
	for _, entry in pairs(self:GetDB().cache) do
		if type(entry) == "table" and (tonumber(entry.timestamp) or 0) >= cutoff then entries[#entries + 1] = entry end
	end
	table.sort(entries, function(left, right)
		if left.level ~= right.level then return left.level > right.level end
		return string.lower(left.name) < string.lower(right.name)
	end)
	return entries
end

function MythicKeys:GetBarText()
	local own = self:GetDB().cache[CacheKey(FullPlayerName())]
	if own and own.level > 0 then return string.format("Mythic + Keys: +%d %s", own.level, DungeonName(own.mapID)) end
	local count = 0
	for _, entry in ipairs(self:GetEntries()) do if entry.level > 0 then count = count + 1 end end
	return string.format("Mythic + Keys: %d known", count)
end

function MythicKeys:BuildTooltip(tooltip)
	local entries = self:GetEntries()
	tooltip:AddLine("Mythic + Keys", unpack(Theme.Colors.goldText))
	local shown = 0
	for _, entry in ipairs(entries) do
		if entry.level > 0 then
			shown = shown + 1
			if shown > 12 then break end
			local r, g, b = ClassColor(entry.classFile)
			tooltip:AddDoubleLine(ShortName(entry.name), string.format("+%d  %s", entry.level, DungeonName(entry.mapID)), r, g, b, 0.45, 0.90, 1.00)
		end
	end
	if shown == 0 then tooltip:AddLine("No current keystones are known yet.", 0.72, 0.75, 0.82, true) end
	if #entries > 12 then tooltip:AddLine(string.format("...and %d more", #entries - 12), 0.68, 0.71, 0.78) end
	tooltip:AddLine(" ")
	tooltip:AddLine("Reads SlickUI shares and compatible Astral Keys data.", 0.68, 0.71, 0.78, true)
end

local ScopeFilters = { "all", "self", "group", "guild", "friends", "astral" }
local ScopeLabels = { all = "All Sources", self = "This Character", group = "Party / Raid", guild = "Guild", friends = "Friends", astral = "Astral Keys" }
local MinimumLevels = { 0, 2, 5, 10, 15, 20 }

function MythicKeys:MatchesFilters(entry)
	local db = self:GetDB()
	local scope = db.scopeFilter or "all"
	if scope == "self" and entry.source ~= "self" then return false end
	-- Guild membership is resolved by BuildGuildRosterEntries so that the
	-- player's SELF record and keys learned from other sources are included.
	if scope == "friends" and entry.scope ~= "WHISPER" and entry.scope ~= "BNET" and entry.scope ~= "friend" then return false end
	if scope == "astral" and entry.source ~= "astral" then return false end
	if entry.level < (db.minimumLevel or 0) then return false end
	local search = string.lower(db.search or "")
	if search ~= "" then
		local haystack = string.lower(table.concat({ entry.name or "", DungeonName(entry.mapID), SourceLabel(entry) }, " "))
		if not haystack:find(search, 1, true) then return false end
	end
	return true
end

local function GroupRosterUnits()
	local units = {}
	if IsInRaid and IsInRaid() then
		for index = 1, GetNumGroupMembers() do units[#units + 1] = "raid" .. index end
	else
		units[#units + 1] = "player"
		for index = 1, GetNumSubgroupMembers() do units[#units + 1] = "party" .. index end
	end
	return units
end

function MythicKeys:BuildGroupRosterEntries()
	local cachedByName = {}
	for _, entry in ipairs(self:GetEntries()) do
		cachedByName[CacheKey(entry.name)] = entry
		local short = ShortName(entry.name)
		if short ~= "Unknown" and cachedByName[CacheKey(short)] == nil then
			cachedByName[CacheKey(short)] = entry
		end
	end

	local entries = {}
	for _, unit in ipairs(GroupRosterUnits()) do
		if UnitExists(unit) then
			local name, realm = UnitFullName(unit)
			name = SafeString(name) or SafeString(UnitName(unit))
			realm = SafeString(realm)
			if name then
				local fullName = realm and realm ~= "" and (name .. "-" .. realm:gsub("%s+", "")) or name
				local cached = cachedByName[CacheKey(fullName)] or cachedByName[CacheKey(name)]
				if cached then
					entries[#entries + 1] = cached
				else
					local _, classFile = UnitClass(unit)
					entries[#entries + 1] = {
						name = fullName,
						classFile = SafeString(classFile) or "PRIEST",
						mapID = 0,
						level = 0,
						weeklyBest = 0,
						score = 0,
						timestamp = 0,
						source = "roster",
						scope = IsInRaid and IsInRaid() and "RAID" or "PARTY",
					}
				end
			end
		end
	end
	return entries
end

function MythicKeys:BuildGuildRosterEntries()
	local guildNames = { [CacheKey(FullPlayerName())] = true }
	if IsInGuild and IsInGuild() then
		if type(GuildRoster) == "function" then pcall(GuildRoster) end
		for index = 1, (GetNumGuildMembers and GetNumGuildMembers() or 0) do
			local name = SafeString(GetGuildRosterInfo(index))
			if name then
				guildNames[CacheKey(name)] = true
			end
		end
	end

	local entries, added = {}, {}
	for _, entry in ipairs(self:GetEntries()) do
		local key = CacheKey(entry.name)
		local isGuildSource = entry.scope == "GUILD" or entry.scope == "guild"
		if entry.level > 0 and not added[key] and (isGuildSource or guildNames[key]) and self:MatchesFilters(entry) then
			entries[#entries + 1] = entry
			added[key] = true
		end
	end
	return entries
end

function MythicKeys:BuildFiltered()
	wipe(self.Filtered)
	local scope = self:GetDB().scopeFilter or "all"
	local entries
	if scope == "group" then entries = self:BuildGroupRosterEntries()
	elseif scope == "guild" then entries = self:BuildGuildRosterEntries()
	else entries = self:GetEntries() end
	for _, entry in ipairs(entries) do if self:MatchesFilters(entry) then self.Filtered[#self.Filtered + 1] = entry end end
end

function MythicKeys:CreateWindow()
	if self.Window then return self.Window end
	local window = CreateFrame("Frame", "SlickUIMythicKeysFrame", UIParent, "BackdropTemplate")
	window:SetSize(850, 610)
	window:SetPoint("CENTER")
	window:SetFrameStrata("DIALOG")
	window:SetClampedToScreen(true)
	window:SetMovable(true)
	window:EnableMouse(true)
	window:RegisterForDrag("LeftButton")
	window:SetScript("OnDragStart", window.StartMoving)
	window:SetScript("OnDragStop", window.StopMovingOrSizing)
	Theme:CreatePanel(window)
	window:Hide()
	table.insert(UISpecialFrames, window:GetName())

	Theme:CreateHeaderIcon(window, 38, 15, -10)
	local title = Theme:CreateText(window, 20)
	title:SetPoint("TOPLEFT", 60, -17)
	title:SetText("Mythic + Keys")
	title:SetTextColor(unpack(Theme.Colors.goldText))
	local close = CreateFrame("Button", nil, window, "UIPanelCloseButton")
	close:SetPoint("TOPRIGHT", -4, -4)
	local summary = Theme:CreateText(window, 10)
	summary:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -7)
	window.Summary = summary

	local announce = CreateFrame("CheckButton", nil, window, "UICheckButtonTemplate")
	announce:SetSize(24, 24)
	announce:SetPoint("TOPRIGHT", -210, -18)
	announce:SetChecked(self:GetDB().announceNewKey == true)
	local announceLabel = Theme:CreateText(window, 10)
	announceLabel:SetPoint("LEFT", announce, "RIGHT", 2, 0)
	announceLabel:SetText("Announce new key")
	announce:SetScript("OnClick", function(button)
		MythicKeys:GetDB().announceNewKey = button:GetChecked() == true
	end)
	announce:SetScript("OnEnter", function(button)
		GameTooltip:SetOwner(button, "ANCHOR_BOTTOM")
		GameTooltip:SetText("Announce new key")
		GameTooltip:AddLine("When your keystone changes, show a confirmation to announce it to instance, raid, or party chat. If you are not grouped, use guild chat.", 1, 1, 1, true)
		GameTooltip:Show()
	end)
	announce:SetScript("OnLeave", GameTooltip_Hide)
	window.AnnounceCheck = announce

	local report = CreateFrame("Button", nil, window, "UIPanelButtonTemplate")
	report:SetSize(145, 24)
	report:SetPoint("TOPRIGHT", -365, -18)
	Theme:SetFont(report:GetFontString(), 10)
	report:SetText("Report All Keys")
	report:SetScript("OnClick", function()
		local reports = MythicKeys:BuildSelectedKeyReports()
		if type(SendChatMessage) ~= "function" then return end
		for _, reportData in ipairs(reports or {}) do
			-- WoW 12.1 requires protected chat sends to remain directly in the
			-- hardware-click handler. Do not move this into a timer or helper.
			SendChatMessage(reportData.message, reportData.channel)
		end
	end)
	report:SetScript("OnEnter", function(button)
		GameTooltip:SetOwner(button, "ANCHOR_BOTTOM")
		GameTooltip:SetText("Report selected keys")
		GameTooltip:AddLine("Uses the selected source: Guild reports to guild; Party / Raid reports to the active group channel; All Sources reports to both available destinations.", 1, 1, 1, true)
		GameTooltip:Show()
	end)
	report:SetScript("OnLeave", GameTooltip_Hide)
	window.ReportButton = report

	local search = CreateFrame("EditBox", nil, window, "InputBoxTemplate")
	search:SetSize(250, 24)
	search:SetPoint("TOPLEFT", 20, -66)
	search:SetAutoFocus(false)
	Theme:SetFont(search, 10)
	search:SetText(self:GetDB().search or "")
	search:SetScript("OnTextChanged", function(box, userInput)
		if not userInput then return end
		MythicKeys:GetDB().search = box:GetText() or ""
		MythicKeys:RefreshWindow()
	end)
	window.Search = search
	local placeholder = Theme:CreateText(window, 9)
	placeholder:SetPoint("LEFT", search, "RIGHT", 8, 0)
	placeholder:SetText("Search player, dungeon, or source")
	placeholder:SetTextColor(unpack(Theme.Colors.mutedText))

	local scope = CreateFrame("Button", nil, window, "UIPanelButtonTemplate")
	scope:SetSize(150, 24)
	Theme:SetFont(scope:GetFontString(), 10)
	scope:SetPoint("TOPLEFT", 440, -66)
	scope:SetScript("OnClick", function()
		local current = MythicKeys:GetDB().scopeFilter or "all"
		local index = 1
		for i, value in ipairs(ScopeFilters) do if value == current then index = i break end end
		MythicKeys:GetDB().scopeFilter = ScopeFilters[index % #ScopeFilters + 1]
		MythicKeys:RefreshWindow()
	end)
	window.ScopeButton = scope
	local minimum = CreateFrame("Button", nil, window, "UIPanelButtonTemplate")
	minimum:SetSize(120, 24)
	Theme:SetFont(minimum:GetFontString(), 10)
	minimum:SetPoint("LEFT", scope, "RIGHT", 10, 0)
	minimum:SetScript("OnClick", function()
		local current = MythicKeys:GetDB().minimumLevel or 0
		local index = 1
		for i, value in ipairs(MinimumLevels) do if value == current then index = i break end end
		MythicKeys:GetDB().minimumLevel = MinimumLevels[index % #MinimumLevels + 1]
		MythicKeys:RefreshWindow()
	end)
	window.MinimumButton = minimum
	local refresh = CreateFrame("Button", nil, window, "UIPanelButtonTemplate")
	refresh:SetSize(100, 24)
	Theme:SetFont(refresh:GetFontString(), 10)
	refresh:SetPoint("LEFT", minimum, "RIGHT", 10, 0)
	refresh:SetText("Refresh")
	refresh:SetScript("OnClick", function()
		MythicKeys:ImportAstralCache()
		MythicKeys:RefreshOwnKey(true)
		MythicKeys:RefreshWindow()
	end)

	local header = CreateFrame("Frame", nil, window, "BackdropTemplate")
	header:SetPoint("TOPLEFT", 20, -106)
	header:SetPoint("TOPRIGHT", -20, -106)
	header:SetHeight(25)
	header:SetBackdrop({ bgFile = Theme.Texture })
	header:SetBackdropColor(unpack(Theme.Colors.panelSoft))
	local columns = { {"Player", 8, 165}, {"Dungeon", 178, 230}, {"Key", 413, 45}, {"Weekly", 465, 60}, {"Score", 532, 60}, {"Source", 600, 135}, {"Updated", 742, 70} }
	for _, info in ipairs(columns) do
		local text = Theme:CreateText(header, 10)
		text:SetPoint("LEFT", info[2], 0)
		text:SetText(info[1])
		text:SetTextColor(unpack(Theme.Colors.goldText))
	end

	local list = CreateFrame("Frame", nil, window)
	list:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, -2)
	list:SetPoint("BOTTOMRIGHT", window, "BOTTOMRIGHT", -20, 38)
	list:EnableMouseWheel(true)
	list:SetScript("OnMouseWheel", function(_, delta)
		local maximum = math.max(0, #MythicKeys.Filtered - MythicKeys.VisibleRows)
		MythicKeys.ScrollOffset = math.max(0, math.min(maximum, MythicKeys.ScrollOffset - delta * 3))
		MythicKeys:RefreshWindowRows()
	end)
	window.Rows = {}
	for index = 1, self.VisibleRows do
		local row = CreateFrame("Frame", nil, list, "BackdropTemplate")
		row:SetPoint("TOPLEFT", 0, -(index - 1) * 29)
		row:SetPoint("TOPRIGHT", 0, -(index - 1) * 29)
		row:SetHeight(27)
		row:SetBackdrop({ bgFile = Theme.Texture })
		row:SetBackdropColor(index % 2 == 0 and 0.07 or 0.04, 0.05, 0.08, 0.72)
		row.Fields = {}
		for field, info in ipairs({ {8,165}, {178,230}, {413,45}, {465,60}, {532,60}, {600,135}, {742,70} }) do
			local text = Theme:CreateText(row, 9)
			text:SetPoint("LEFT", info[1], 0)
			text:SetWidth(info[2])
			text:SetJustifyH("LEFT")
			row.Fields[field] = text
		end
		window.Rows[index] = row
	end
	local hint = Theme:CreateText(window, 9)
	hint:SetPoint("BOTTOMLEFT", 20, 15)
	hint:SetText("Keys are shared by SlickUI and compatible Astral Keys users. Remote results reflect the newest received cache data.")
	hint:SetTextColor(unpack(Theme.Colors.mutedText))
	self.Window = window
	return window
end

function MythicKeys:RefreshWindowRows()
	if not self.Window then return end
	local maximum = math.max(0, #self.Filtered - self.VisibleRows)
	self.ScrollOffset = math.min(self.ScrollOffset, maximum)
	for index, row in ipairs(self.Window.Rows) do
		local entry = self.Filtered[self.ScrollOffset + index]
		row:SetShown(entry ~= nil)
		if entry then
			local r, g, b = ClassColor(entry.classFile)
			row.Fields[1]:SetText(entry.name)
			row.Fields[1]:SetTextColor(r, g, b)
			row.Fields[2]:SetText(DungeonName(entry.mapID))
			row.Fields[3]:SetText(entry.level > 0 and ("+" .. entry.level) or "--")
			row.Fields[3]:SetTextColor(entry.level > 0 and 0.45 or 0.68, entry.level > 0 and 0.90 or 0.71, entry.level > 0 and 1.00 or 0.78)
			row.Fields[4]:SetText(entry.weeklyBest > 0 and ("+" .. entry.weeklyBest) or "--")
			row.Fields[5]:SetText(entry.score > 0 and entry.score or "--")
			row.Fields[6]:SetText(SourceLabel(entry))
			row.Fields[7]:SetText(RelativeAge(entry.timestamp))
		end
	end
end

function MythicKeys:RefreshWindow()
	if not self.Window then return end
	self:BuildFiltered()
	local total, withKeys = #self.Filtered, 0
	for _, entry in ipairs(self.Filtered) do if entry.level > 0 then withKeys = withKeys + 1 end end
	self.Window.Summary:SetText(string.format("Known characters: %d    Current keystones: %d", total, withKeys))
	local scope = self:GetDB().scopeFilter or "all"
	self.Window.ScopeButton:SetText(ScopeLabels[scope] or "All Sources")
	local reportLabels = {
		all = "Report All Keys",
		group = "Report Party Keys",
		guild = "Report Guild Keys",
		self = "Report My Key",
		friends = "Report Friend Keys",
		astral = "Report Astral Keys",
	}
	self.Window.ReportButton:SetText(reportLabels[scope] or "Report Keys")
	local minimum = self:GetDB().minimumLevel or 0
	self.Window.MinimumButton:SetText(minimum > 0 and ("Minimum +" .. minimum) or "Any Key Level")
	self:RefreshWindowRows()
end

function MythicKeys:ShowWindow()
	local window = self:CreateWindow()
	self:ImportAstralCache()
	self:RefreshWindow()
	window:Show()
	window:Raise()
end

function MythicKeys:ToggleWindow()
	if self.Window and self.Window:IsShown() then self.Window:Hide() else self:ShowWindow() end
end

function MythicKeys:RefreshDisplays()
	if self.Window and self.Window:IsShown() then self:RefreshWindow() end
	if SlickUI.InformationBars then SlickUI.InformationBars:UpdateBlocks() end
end

function MythicKeys:Initialize()
	if C_ChatInfo and type(C_ChatInfo.RegisterAddonMessagePrefix) == "function" then
		pcall(C_ChatInfo.RegisterAddonMessagePrefix, self.Prefix)
		pcall(C_ChatInfo.RegisterAddonMessagePrefix, self.AstralPrefix)
	end
	local frame = CreateFrame("Frame")
	for _, event in ipairs({ "PLAYER_ENTERING_WORLD", "BAG_UPDATE_DELAYED", "CHALLENGE_MODE_START", "CHALLENGE_MODE_COMPLETED", "CHALLENGE_MODE_RESET", "GROUP_ROSTER_UPDATE", "GUILD_ROSTER_UPDATE", "CHAT_MSG_ADDON", "BN_CHAT_MSG_ADDON" }) do
		pcall(frame.RegisterEvent, frame, event)
	end
	frame:SetScript("OnEvent", function(_, event, ...)
		if event == "CHAT_MSG_ADDON" or event == "BN_CHAT_MSG_ADDON" then
			local prefix, message, channel = ...
			if prefix == MythicKeys.Prefix then
				local entry = MythicKeys:ParseNative(message, channel)
				if entry then MythicKeys:MergeEntry(entry, false) end
			elseif prefix == MythicKeys.AstralPrefix then
				MythicKeys:ReceiveAstral(message, event == "BN_CHAT_MSG_ADDON" and "BNET" or channel)
			end
			return
		end
		C_Timer.After(event == "PLAYER_ENTERING_WORLD" and 3 or 0.6, function()
			MythicKeys:ImportAstralCache()
			MythicKeys:RefreshOwnKey(event == "PLAYER_ENTERING_WORLD" or event == "GROUP_ROSTER_UPDATE" or event == "GUILD_ROSTER_UPDATE")
		end)
	end)
	self.Driver = frame
	C_Timer.After(3, function()
		MythicKeys:ImportAstralCache()
		MythicKeys:RefreshOwnKey(true)
	end)
end

SlickUI:RegisterModule("MythicKeys", MythicKeys)
