local addonName, GF = ...

GF.Blocklist = {}

local BL = GF.Blocklist
local NOTE_ADVERTISEMENT = "广告"
local NOTE_SAME_TITLE = "同标题广告传染"
local NOTE_MANUAL = "手动拉黑"
local SOURCE_BLOCK_LEADER = "findgroup_block_leader"
local SOURCE_BLOCK_TITLE = "findgroup_block_title"
local SOURCE_REPORT_AD = "findgroup_report_ad"
local SOURCE_MANUAL = "manual_context_menu"

local function trim(s)
	if type(s) ~= "string" then
		return ""
	end
	return s:match("^%s*(.-)%s*$") or ""
end

local function normalizeLeader(name)
	if type(name) ~= "string" or name == "" then
		return nil
	end
	if type(Ambiguate) == "function" then
		local normalized = Ambiguate(name, "none")
		return normalized ~= "" and normalized or nil
	end
	return name
end

local function getCurrentRealmName()
	local realm = GetNormalizedRealmName and GetNormalizedRealmName()
	if realm and realm ~= "" then
		return realm
	end
	realm = GetRealmName and GetRealmName()
	if realm and realm ~= "" then
		return realm
	end
	return nil
end

local function splitLeaderRealm(leader)
	leader = normalizeLeader(leader)
	if not leader then
		return nil, nil
	end
	local name, realm = leader:match("^([^-]+)%-(.+)$")
	if name and name ~= "" and realm and realm ~= "" then
		return name, realm
	end
	return leader, getCurrentRealmName()
end

local function normalizeIdentifierPart(value)
	value = trim(value)
	if value == "" then
		return nil
	end
	return string.lower(value:gsub("%s+", ""))
end

local function getNow()
	if time then
		return time()
	end
	if GetTime then
		return math.floor(GetTime())
	end
	return 0
end

local function formatEntryTime(timestamp)
	timestamp = tonumber(timestamp) or getNow()
	if date then
		return date("%Y-%m-%d %H:%M", timestamp)
	end
	return tostring(timestamp)
end

local function parseEntryTimestamp(entry)
	if not entry then
		return 0
	end
	local value = tonumber(entry.updatedAt or entry.addedAt)
	if value and value > 0 then
		return value
	end
	local timeText = entry.time
	if type(timeText) == "string" then
		local y, m, d, h, min = timeText:match("^(%d+)%-(%d+)%-(%d+)%s+(%d+):(%d+)$")
		if y and time then
			return time({
				year = tonumber(y),
				month = tonumber(m),
				day = tonumber(d),
				hour = tonumber(h),
				min = tonumber(min),
				sec = 0,
			}) or 0
		end
	end
	return 0
end

local function buildLeaderKey(leader)
	local normalized = normalizeIdentifierPart(leader)
	if not normalized then
		return nil
	end
	return "leader:" .. normalized
end

local function getDefaultNoteForSource(source)
	source = tostring(source or "")
	if source:find("report_ad", 1, true) then
		return NOTE_ADVERTISEMENT
	end
	if source:find("block_title", 1, true) then
		return NOTE_SAME_TITLE
	end
	return NOTE_MANUAL
end

local function sourceCreatesStandaloneLeader(source)
	return source == SOURCE_MANUAL
		or source == SOURCE_BLOCK_LEADER
		or source == SOURCE_REPORT_AD
end

local function setRowUpdateFields(row, extra)
	if type(row) ~= "table" then
		return
	end
	local options = type(extra) == "table" and extra or {}
	local now = getNow()
	row.addedAt = tonumber(row.addedAt) or now
	row.updatedAt = now
	row.time = formatEntryTime(now)
	if type(options.source) == "string" and options.source ~= "" then
		row.source = options.source
	end
	if type(options.key) == "string" and options.key ~= "" then
		row.key = options.key
	elseif row.kind == "leader" then
		row.key = row.key or buildLeaderKey(row.leader)
	end
	if options.clearSourceTitle == true then
		row.sourceTitle = nil
	elseif type(options.sourceTitle) == "string" and options.sourceTitle ~= "" then
		row.sourceTitle = options.sourceTitle
	end
	local label = trim(options.displayLabel)
	if label ~= "" then
		row.displayLabel = label
	end
	local note = trim(options.note)
	if note ~= "" then
		row.note = note
	elseif options.forceNote == true then
		row.note = getDefaultNoteForSource(options.source)
	end
end

local SECRET_TOKEN_PATTERN = "^|K.-|k$"

local function isSecretToken(text)
	return type(text) == "string"
		and text ~= ""
		and string.match(text, SECRET_TOKEN_PATTERN) ~= nil
end

local TitleEvidence = {}
TitleEvidence.__index = TitleEvidence

function TitleEvidence:New()
	return setmetatable({ sessionTokens = {} }, self)
end

function TitleEvidence:RememberRenderableToken(title)
	if isSecretToken(title) then
		self.sessionTokens[title] = true
	end
end

function TitleEvidence:CanRenderNativeText(text)
	return not isSecretToken(text) or self.sessionTokens[text] == true
end

function TitleEvidence:ResetSession()
	for token in pairs(self.sessionTokens) do
		self.sessionTokens[token] = nil
	end
end

-- Renderability is evidence from this login only; PLAYER_LOGIN resets the registry.
local titleEvidence = TitleEvidence:New()

local function isReliableTitleText(title)
	if title == nil then
		return false
	end
	if issecretvalue and issecretvalue(title) then
		return false
	end
	if type(title) ~= "string" then
		return false
	end
	title = trim(title)
	if title == "" or title == "?" then
		return false
	end
	if GF.Result and GF.Result.IsUnreadableLfgText and GF.Result:IsUnreadableLfgText(title) then
		return false
	end
	return true, title
end

local function isStableTitleLabelText(title)
	local ok, label = isReliableTitleText(title)
	if not ok or isSecretToken(label) then
		return nil
	end
	return label
end

local function getSearchResultInfo(resultID)
	if not (resultID and C_LFGList and C_LFGList.GetSearchResultInfo) then
		return nil
	end
	local ok, info = pcall(C_LFGList.GetSearchResultInfo, resultID)
	if ok then
		return info
	end
	return nil
end

local function getCachedSearchResultInfo(resultID)
	local result = GF.Result
	if not (result and resultID) then
		return nil
	end
	local entry = result.entryCache and result.entryCache[resultID]
	if entry and entry.info then
		return entry.info
	end
	return result.sortInfoCache and result.sortInfoCache[resultID] or nil
end

local function isCensoredSearchResultInfo(info)
	local result = GF.Result
	if result and type(result.IsCensoredSearchResult) == "function" then
		return result:IsCensoredSearchResult(info) == true
	end
	local snapshot = GF.SearchResultSnapshot
	return snapshot and type(snapshot.IsCensored) == "function"
		and snapshot.IsCensored(info) == true
end

local function resolveReliableTitle(resultID, info, displayTitle, preferFresh)
	local fresh
	local ok, title
	if preferFresh then
		fresh = getSearchResultInfo(resultID)
		if isCensoredSearchResultInfo(fresh) then
			return nil, fresh
		end
		ok, title = isReliableTitleText(fresh and fresh.name)
		if ok then
			return title, fresh
		end
	end
	if isCensoredSearchResultInfo(info) then
		return nil, info
	end
	ok, title = isReliableTitleText(info and info.name)
	if ok then
		return title, info
	end
	local cached = getCachedSearchResultInfo(resultID)
	if isCensoredSearchResultInfo(cached) then
		return nil, fresh or info or cached
	end
	ok, title = isReliableTitleText(cached and cached.name)
	if ok then
		return title, cached
	end
	if not preferFresh then
		fresh = getSearchResultInfo(resultID)
		ok, title = isReliableTitleText(fresh and fresh.name)
		if ok then
			return title, fresh
		end
	end
	ok, title = isReliableTitleText(displayTitle)
	if ok then
		return title, cached or info or fresh
	end
	return nil, fresh or info or cached
end

local function resolveStableDisplayTitle(resultID, info, displayTitle, titleInfo)
	if isCensoredSearchResultInfo(titleInfo)
		or isCensoredSearchResultInfo(info)
	then
		return nil
	end
	local label = isStableTitleLabelText(displayTitle)
	if label then
		return label
	end
	label = isStableTitleLabelText(titleInfo and titleInfo.name)
	if label then
		return label
	end
	label = isStableTitleLabelText(info and info.name)
	if label then
		return label
	end
	local cached = getCachedSearchResultInfo(resultID)
	if isCensoredSearchResultInfo(cached) then
		return nil
	end
	label = isStableTitleLabelText(cached and cached.name)
	if label then
		return label
	end
	return nil
end

local function resolveDisplayText(blizzardText, displayLabel)
	local nativeText = type(blizzardText) == "string" and blizzardText or ""
	local fallback = type(displayLabel) == "string" and displayLabel or ""
	if nativeText == "" then
		return fallback ~= "" and fallback or "?"
	end
	if titleEvidence:CanRenderNativeText(nativeText) or fallback == "" then
		return nativeText
	end
	return fallback
end

function BL:ClearReadableTitleTokens()
	titleEvidence:ResetSession()
end

local function findBlockRow(db, predicate)
	local list = type(db) == "table" and db.blocklist or nil
	for _, row in ipairs(type(list) == "table" and list or {}) do
		if type(row) == "table" and predicate(row) then
			return row
		end
	end
	return nil
end

local function findLeaderRow(db, leader, sourceTitle)
	local targetLeader = normalizeLeader(leader)
	if targetLeader == nil then
		return false, nil
	end
	local row = findBlockRow(db, function(candidate)
		return candidate.kind == "leader"
			and normalizeLeader(candidate.leader) == targetLeader
			and (sourceTitle == nil or candidate.sourceTitle == sourceTitle)
	end)
	return row ~= nil, row
end

local function leaderRowInList(db, leader)
	return findLeaderRow(db, leader, nil)
end

local function findLeaderForSourceTitle(db, leader, sourceTitle)
	if type(sourceTitle) ~= "string" or sourceTitle == "" then
		return false, nil
	end
	return findLeaderRow(db, leader, sourceTitle)
end

local function findTitleRow(db, title)
	if type(title) ~= "string" or title == "" then
		return false, nil
	end
	local row = findBlockRow(db, function(candidate)
		return candidate.kind == "title" and candidate.title == title
	end)
	return row ~= nil, row
end

local function titleInList(db, title)
	return findTitleRow(db, title)
end

local function isTitleParentLeader(db, title, leader)
	local normalizedLeader = normalizeLeader(leader)
	if type(title) ~= "string" or title == "" or normalizedLeader == nil then
		return false
	end
	local exists, row = findTitleRow(db, title)
	return exists == true and normalizeLeader(row.leader) == normalizedLeader
end

local function pruneRedundantTitleChildren(db)
	local list = type(db.blocklist) == "table" and db.blocklist or {}
	local kept = {}
	local removedCount = 0
	for _, row in ipairs(list) do
		local redundant = row and row.kind == "leader"
			and row.sourceTitle ~= nil
			and isTitleParentLeader(db, row.sourceTitle, row.leader)
		if redundant then
			removedCount = removedCount + 1
		else
			kept[#kept + 1] = row
		end
	end
	if removedCount > 0 then
		db.blocklist = kept
	end
	return removedCount > 0
end

local function clearStaleSourceTitles(db)
	for _, row in ipairs(type(db.blocklist) == "table" and db.blocklist or {}) do
		if type(row) == "table"
			and row.kind == "leader"
			and sourceCreatesStandaloneLeader(row.source)
		then
			row.sourceTitle = nil
		end
	end
end

local function repairBlocklistRows(db)
	if type(db.blocklist) ~= "table" then
		db.blocklist = {}
		return db.blocklist
	end
	local list = db.blocklist
	local numericKeys = {}
	local validRowKeys = {}
	for key, row in pairs(list) do
		if type(key) == "number" then
			numericKeys[#numericKeys + 1] = key
			if key >= 1 and key % 1 == 0 and type(row) == "table" then
				validRowKeys[#validRowKeys + 1] = key
			end
		end
	end
	table.sort(validRowKeys)
	local validRows = {}
	for _, key in ipairs(validRowKeys) do
		validRows[#validRows + 1] = list[key]
	end
	-- The length operator has no reliable boundary for damaged sparse arrays.
	-- Remove every old numeric slot first, then rebuild a dense array in the
	-- original positive-integer key order while preserving each valid row object.
	for _, key in ipairs(numericKeys) do
		list[key] = nil
	end
	for index, row in ipairs(validRows) do
		list[index] = row
	end
	return list
end

function BL:Init()
	self:ClearTipState()
	self:RebuildMaps()
end

function BL:ClearTipState()
	self._scanNoticeBatch = nil
	self._manualTitleNotice = nil
end

function BL:IsEnabled()
	local db = GF.GetDB()
	return db.blacklistEnabled ~= false
end

function BL:HasActiveRules()
	return self:IsEnabled() == true and next(self.leaders or {}) ~= nil
end

function BL:RebuildMaps()
	local leaders = {}
	local db = GF.GetDB()
	repairBlocklistRows(db)
	clearStaleSourceTitles(db)
	if self:IsEnabled() then
		pruneRedundantTitleChildren(db)
		for _, entry in ipairs(db.blocklist) do
			local leader = normalizeLeader(entry.leader)
			local supportedKind = entry.kind == "leader" or entry.kind == "title"
			if leader ~= nil and supportedKind then
				leaders[leader] = true
			end
		end
	end
	self.leaders = leaders
	self.revision = 1 + (self.revision or 0)
end

function BL:GetRevision()
	return self.revision or 0
end

function BL:FindTitleEntry(titleToken)
	local db = GF.GetDB()
	local _, row = findTitleRow(db, titleToken)
	return row
end

function BL:GetDisplayText(entry, itemRole)
	if type(entry) ~= "table" then
		return "?"
	end
	local useTitle = entry.kind == "title" or itemRole == "parent"
	local nativeText = useTitle and entry.title or entry.leader
	return resolveDisplayText(nativeText, entry.displayLabel)
end

local function tipLabelText(text)
	return type(text) == "string" and text or ""
end

local function colorStatusText(text, colorCode)
	local reset = GF.CHAT_RESET_COLOR_CODE or "|r"
	local bodyColor = GF.CHAT_STATUS_BODY_COLOR_CODE or "|cffffffff"
	return (colorCode or "") .. tostring(text or "") .. reset .. bodyColor
end

local LEADER_NAME_BLUE_COLOR_CODE = "|cff71d5ff"

local function colorLeaderName(leaderName)
	return colorStatusText(
		normalizeLeader(leaderName) or "?",
		LEADER_NAME_BLUE_COLOR_CODE)
end

local function colorCodeFromRGB(color, fallback)
	if type(color) ~= "table" then
		return fallback
	end
	if type(color.GenerateHexColorMarkup) == "function" then
		local ok, markup = pcall(color.GenerateHexColorMarkup, color)
		if ok and type(markup) == "string" and markup ~= "" then
			return markup
		end
	end
	local r = tonumber(color.r)
	local g = tonumber(color.g)
	local b = tonumber(color.b)
	if r == nil or g == nil or b == nil then
		return fallback
	end
	local function byte(value)
		return math.floor((math.max(0, math.min(1, value)) * 255) + 0.5)
	end
	return string.format("|cff%02x%02x%02x", byte(r), byte(g), byte(b))
end

local function getSystemMessageColorCode()
	local systemColor = ChatTypeInfo and ChatTypeInfo.SYSTEM
	return colorCodeFromRGB(
		systemColor,
		colorCodeFromRGB(YELLOW_FONT_COLOR, "|cffffff00"))
end

local function colorAdvertisementLeaderName(leaderName)
	return colorLeaderName(leaderName)
end

local NoticeAccumulator = {}
NoticeAccumulator.__index = NoticeAccumulator

function NoticeAccumulator:New(owner)
	return setmetatable({
		owner = owner,
		leaderNames = {},
		leaderSeen = {},
		titleRelations = {},
		titleRelationSeen = {},
		panelRefreshRequested = false,
		finished = false,
	}, self)
end

function NoticeAccumulator:AddLeader(leader)
	local normalized = normalizeLeader(leader)
	if normalized == nil or self.finished or self.leaderSeen[normalized] then
		return false
	end
	self.leaderSeen[normalized] = true
	self.leaderNames[#self.leaderNames + 1] = normalized
	return true
end

function NoticeAccumulator:AddTitleRelation(leader, sourceTitle)
	local normalized = normalizeLeader(leader)
	if normalized == nil or self.finished
		or type(sourceTitle) ~= "string" or sourceTitle == ""
	then
		return false
	end
	local leadersForTitle = self.titleRelationSeen[sourceTitle]
	if leadersForTitle == nil then
		leadersForTitle = {}
		self.titleRelationSeen[sourceTitle] = leadersForTitle
	end
	if leadersForTitle[normalized] then
		return false
	end
	leadersForTitle[normalized] = true
	self.titleRelations[#self.titleRelations + 1] = {
		leader = normalized,
		sourceTitle = sourceTitle,
	}
	return true
end

function NoticeAccumulator:RequestPanelRefresh()
	if not self.finished then
		self.panelRefreshRequested = true
	end
end

function NoticeAccumulator:PublishTitleRelations()
	if #self.titleRelations == 0 then
		return
	end
	local locale = GF.L or {}
	local titlePrefix = tipLabelText(locale.BLOCK_TIP_CONTAGION_TITLE_PREFIX or "Title “")
	local middle = tipLabelText(locale.BLOCK_TIP_CONTAGION_MID or "” also matched leader ")
	for _, relation in ipairs(self.titleRelations) do
		local titleShown = resolveDisplayText(relation.sourceTitle, nil)
		self.owner:Tip(titlePrefix .. titleShown .. middle .. relation.leader)
	end
end

function NoticeAccumulator:PublishLeaderSummary()
	if #self.leaderNames == 0 then
		return
	end
	local locale = GF.L or {}
	local prefix = tipLabelText(locale.BLOCK_TIP_LEADER_PREFIX or "Leader added to blocklist: ")
	self.owner:Tip(prefix .. table.concat(self.leaderNames, "、"))
end

function NoticeAccumulator:Finish()
	if self.finished then
		return
	end
	self.finished = true
	-- Related-title notices are intentionally emitted before the leader summary.
	self:PublishTitleRelations()
	self:PublishLeaderSummary()
	local panel = GF.BlocklistPanel
	if self.panelRefreshRequested and panel and type(panel.Refresh) == "function" then
		panel:Refresh()
	end
end

function BL:BeginScanTipBatch()
	if self._scanNoticeBatch ~= nil then
		self:EndScanTipBatch()
	end
	self._scanNoticeBatch = NoticeAccumulator:New(self)
end

function BL:EndScanTipBatch()
	local batch = self._scanNoticeBatch
	if batch == nil then
		return
	end
	self._scanNoticeBatch = nil
	batch:Finish()
end

local function acquireNoticeAccumulator(bl)
	local active = bl._scanNoticeBatch
	if active ~= nil then
		return active, false
	end
	return NoticeAccumulator:New(bl), true
end

local function queueLeaderTip(bl, leader)
	local notices, finishImmediately = acquireNoticeAccumulator(bl)
	notices:AddLeader(leader)
	if finishImmediately then
		notices:Finish()
	end
end

local function queueTitleRelationTip(bl, leader, sourceTitle)
	local notices, finishImmediately = acquireNoticeAccumulator(bl)
	notices:AddTitleRelation(leader, sourceTitle)
	if finishImmediately then
		notices:Finish()
	end
end

local function beginManualTitleNotice(bl, kind)
	bl._manualTitleNotice = {
		kind = kind,
		leaders = {},
		leaderSeen = {},
	}
end

local function recordManualTitleNotice(bl, title, leader, displayLabel)
	local notice = bl._manualTitleNotice
	if type(notice) ~= "table" then
		notice = { kind = "title", leaders = {}, leaderSeen = {} }
		bl._manualTitleNotice = notice
	end
	notice.kind = "title"
	notice.title = title
	notice.displayLabel = isStableTitleLabelText(displayLabel) or notice.displayLabel
	local normalized = normalizeLeader(leader)
	if normalized and not notice.leaderSeen[normalized] then
		notice.leaderSeen[normalized] = true
		notice.leaders[#notice.leaders + 1] = normalized
	end
end

local function publishManualTitleNotice(bl)
	local notice = bl._manualTitleNotice
	bl._manualTitleNotice = nil
	if type(notice) ~= "table" then
		return
	end
	local locale = GF.L or {}
	if notice.kind == "title" and notice.title ~= nil then
		local titleShown = isStableTitleLabelText(notice.displayLabel)
			or isStableTitleLabelText(resolveDisplayText(notice.title, nil))
			or locale.BLOCK_NOTE_TITLE_UNREADABLE
			or "标题暂不可读"
		local titlePrefix = tipLabelText(locale.BLOCK_TIP_TITLE_PREFIX or "Title rule added: ")
		if notice.leaders[1] then
			local leadersPrefix = tipLabelText(locale.BLOCK_TIP_LEADERS_PREFIX or "; leaders: ")
			bl:Tip(titlePrefix .. titleShown .. leadersPrefix .. table.concat(notice.leaders, "、"))
		else
			bl:Tip(titlePrefix .. titleShown)
		end
	end
end

local function isSystemNote(kind, note)
	local normalized = trim(note)
	if normalized == "" then
		return true
	end
	local locale = GF.L or {}
	local defaults = {
		title = locale.BLOCK_NOTE_TITLE or "Blocked title",
		leader = locale.BLOCK_NOTE_LEADER or "Blocked leader",
	}
	return defaults[kind] ~= nil and normalized == defaults[kind]
end

local function findExistingEntry(db, kind, leader, title)
	if kind == "leader" then
		local exists, row = leaderRowInList(db, leader)
		return exists and row or nil
	end
	if kind == "title" then
		local exists, row = titleInList(db, title)
		return exists and row or nil
	end
	return nil
end

local function updateExistingEntry(row, kind, note, extra)
	local fields = {
		displayLabel = extra.displayLabel,
		note = note,
	}
	if kind == "leader" then
		fields.source = extra.source
		fields.sourceTitle = extra.sourceTitle
		fields.clearSourceTitle = extra.clearSourceTitle
		fields.forceNote = extra.forceNote
	end
	setRowUpdateFields(row, fields)
end

local function makeEntry(kind, leader, title, note, extra)
	local timestamp = getNow()
	local row = {
		leader = leader or title,
		kind = kind,
		addedAt = timestamp,
		updatedAt = timestamp,
		time = formatEntryTime(timestamp),
	}
	if kind == "leader" then
		row.key = buildLeaderKey(leader)
	end
	if type(title) == "string" and title ~= "" then
		row.title = title
	end
	local customNote = trim(note)
	if customNote ~= "" and not isSystemNote(kind, customNote) then
		row.note = customNote
	elseif extra.forceNote == true then
		row.note = getDefaultNoteForSource(extra.source)
	end
	if type(extra.source) == "string" and extra.source ~= "" then
		row.source = extra.source
	end
	if type(extra.sourceTitle) == "string" and extra.sourceTitle ~= "" then
		row.sourceTitle = extra.sourceTitle
	end
	local label = trim(extra.displayLabel)
	if label ~= "" then
		row.displayLabel = label
	end
	return row
end

local function queueAddedEntryTip(bl, row, extra)
	if extra.skipTip == true then
		return
	end
	if row.kind == "title" then
		if bl._manualTitleNotice then
			recordManualTitleNotice(bl, row.title, row.leader, extra.displayLabel)
		end
		return
	end
	local sourceTitle = extra.sourceTitle
	if type(sourceTitle) == "string" and sourceTitle ~= "" then
		if extra.skipTip ~= true then
			queueTitleRelationTip(bl, row.leader, sourceTitle)
		end
		return
	end
	queueLeaderTip(bl, row.leader)
end

local function addEntry(bl, kind, leader, title, note, extra)
	local options = type(extra) == "table" and extra or {}
	local normalizedLeader = normalizeLeader(leader)
	local validLeader = kind == "leader" and normalizedLeader ~= nil
	local validTitle = kind == "title" and type(title) == "string" and title ~= ""
	if not validLeader and not validTitle then
		return nil
	end
	local db = GF.GetDB()
	db.blocklist = type(db.blocklist) == "table" and db.blocklist or {}
	local existing = findExistingEntry(db, kind, normalizedLeader, title)
	if existing then
		updateExistingEntry(existing, kind, note, options)
		return existing, true
	end
	local row = makeEntry(kind, normalizedLeader, title, note, options)
	table.insert(db.blocklist, 1, row)
	queueAddedEntryTip(bl, row, options)
	return row
end

function BL:FindMatch(resultID, info)
	if self:IsEnabled() ~= true or resultID == nil or next(self.leaders or {}) == nil then
		return nil
	end
	local resultInfo = info or getSearchResultInfo(resultID)
	if type(resultInfo) ~= "table" then
		return nil
	end
	local leader = normalizeLeader(resultInfo.leaderName)
	if leader ~= nil and self.leaders[leader] then
		local _, row = findLeaderRow(GF.GetDB(), leader, nil)
		return "leader", row
	end
	return nil
end

function BL:FindPlayerMatch(playerName)
	if self:IsEnabled() ~= true then
		return nil
	end
	local leader = normalizeLeader(playerName)
	local indexed = leader ~= nil and self.leaders and self.leaders[leader]
	if not indexed then
		return nil
	end
	local _, row = findLeaderRow(GF.GetDB(), leader, nil)
	return row or true
end

function BL:ShouldHide(resultID, info, entry)
	local findGroup = GF.FindGroup
	if findGroup and type(findGroup.ShouldHideBlockedResult) == "function" then
		return findGroup:ShouldHideBlockedResult(resultID, info, entry)
	end
	return self:FindMatch(resultID, info) ~= nil
end

local function getTitleChildren(list, titleEntry)
	local children = {}
	local titleKey = type(titleEntry) == "table" and titleEntry.title or nil
	if type(titleKey) ~= "string" or titleKey == "" then
		return children
	end
	local seen = {}
	local parentLeader = normalizeLeader(titleEntry.leader)
	if parentLeader ~= nil then
		seen[parentLeader] = true
	end
	for _, entry in ipairs(list or {}) do
		local isChild = entry.kind == "leader" and entry.sourceTitle == titleKey
		local leader = isChild and normalizeLeader(entry.leader) or nil
		if leader ~= nil and not seen[leader] then
			seen[leader] = true
			children[#children + 1] = entry
		end
	end
	return children
end

function BL:FormatEntryNote(entry, childCount)
	local locale = GF.L or {}
	if type(entry) ~= "table" then
		return ""
	end
	local leaderShown = normalizeLeader(entry.leader) or entry.leader or "?"
	if entry.kind == "title" then
		local fmt = locale.BLOCK_NOTE_TITLE_PARENT_FMT or "标题规则：%s"
		return string.format(fmt, leaderShown)
	end
	if type(entry.sourceTitle) == "string" and entry.sourceTitle ~= "" then
		local _, parent = findTitleRow(GF.GetDB(), entry.sourceTitle)
		local rawParentLeader = parent and parent.leader
		local parentLeader = normalizeLeader(rawParentLeader) or rawParentLeader or "?"
		local fmt = locale.BLOCK_NOTE_TITLE_CHILD_FMT or "关联至标题规则【%s】"
		return string.format(fmt, parentLeader)
	end
	return entry.note or locale.BLOCK_NOTE_LEADER or "Blocked leader"
end

function BL:LinkLeaderToBlockedTitle(leader, title, opts)
	local options = type(opts) == "table" and opts or {}
	local db = GF.GetDB()
	if type(title) ~= "string" or title == "" then
		return
	end
	local normalizedLeader = normalizeLeader(leader)
	if normalizedLeader == nil then
		return
	end
	self.leaders[normalizedLeader] = true
	if isTitleParentLeader(db, title, normalizedLeader) then
		return
	end
	local exists, row = findLeaderForSourceTitle(db, normalizedLeader, title)
	if not exists then
		exists, row = leaderRowInList(db, normalizedLeader)
	end
	if row then
		if options.source ~= nil or options.note ~= nil or options.forceNote == true then
			setRowUpdateFields(row, {
				source = options.source,
				sourceTitle = title,
				note = options.note,
				forceNote = options.forceNote,
			})
		end
		return
	end
	addEntry(self, "leader", normalizedLeader, nil, options.note, {
		sourceTitle = title,
		source = options.source or SOURCE_BLOCK_TITLE,
		forceNote = options.forceNote,
		skipTip = options.skipTip,
	})
	if self._scanNoticeBatch ~= nil then
		self._scanNoticeBatch:RequestPanelRefresh()
	else
		local panel = GF.BlocklistPanel
		if panel and type(panel.Refresh) == "function" then
			panel:Refresh()
		end
	end
end

function BL:IndexVisibleLeadersForBlockedTitle(titleToken, displayLabel)
	if type(titleToken) ~= "string" or titleToken == ""
		or self:IsEnabled() ~= true
	then
		return 0
	end
	local resultService = GF.Result
	local order = resultService
		and (resultService.frozenOrder or resultService.resultIDs) or {}
	if #order == 0 then
		return 0
	end
	local seen = {}
	local linkedCount = 0
	local targetLabel = isStableTitleLabelText(displayLabel)
	for _, resultID in ipairs(order) do
		local info = getSearchResultInfo(resultID)
		local title = resolveReliableTitle(resultID, info)
		local matches = title == titleToken
		if not matches and targetLabel ~= nil then
			local rowLabel = resolveStableDisplayTitle(resultID, info, nil, info)
			matches = rowLabel == targetLabel
		end
		if type(info) == "table" and matches then
			local leader = normalizeLeader(info.leaderName)
			if leader ~= nil and not seen[leader] then
				seen[leader] = true
				self:LinkLeaderToBlockedTitle(leader, titleToken)
				linkedCount = linkedCount + 1
			end
		end
	end
	return linkedCount
end

function BL:IndexRevealedSearchResult(resultID, info)
	if self:IsEnabled() ~= true or type(info) ~= "table"
		or isCensoredSearchResultInfo(info)
	then
		return false
	end
	-- The caller supplies the authoritative post-reveal reread. Do not perform a
	-- third native read merely to rebuild the title index for this one row.
	local reliable, title = isReliableTitleText(info.name)
	title = reliable and title or nil
	local titleEntry = title and self:FindTitleEntry(title)
	local leader = normalizeLeader(info.leaderName)
	if not (titleEntry and leader) then
		return false
	end
	self:LinkLeaderToBlockedTitle(leader, title, {
		skipTip = true,
		source = SOURCE_BLOCK_TITLE,
		note = NOTE_SAME_TITLE,
		forceNote = true,
	})
	self.revision = 1 + (self.revision or 0)
	local resultService = GF.Result
	if resultService
		and type(resultService.InvalidateAllBlockedMemberCache) == "function"
	then
		resultService:InvalidateAllBlockedMemberCache()
	end
	return true
end

function BL:RefreshAfterBlock(preferredResultID)
	self.revision = 1 + (self.revision or 0)
	local resultService = GF.Result
	if resultService and type(resultService.InvalidateAllBlockedMemberCache) == "function" then
		resultService:InvalidateAllBlockedMemberCache()
	end
	local tab = GF.FindGroupTab
	local retirementStarted = tab
		and type(tab.RetireHiddenByBlocklist) == "function"
		and tab:RetireHiddenByBlocklist(preferredResultID) == true
	if not retirementStarted and tab
		and type(tab.RemoveHiddenByBlocklist) == "function"
	then
		tab:RemoveHiddenByBlocklist()
	end
	local panel = GF.BlocklistPanel
	if panel and type(panel.Refresh) == "function" then
		panel:Refresh()
	end
end

function BL:AddLeader(leaderName, note, displayLabel, preferredResultID)
	return self:AddLeaderWithSource(
		leaderName,
		note,
		displayLabel,
		SOURCE_BLOCK_LEADER,
		true,
		false,
		preferredResultID)
end

function BL:AddManualPlayer(playerName, note)
	return self:AddLeaderWithSource(
		playerName,
		note,
		nil,
		SOURCE_MANUAL,
		true,
		true)
end

function BL:AddLeaderWithSource(
	leaderName,
	note,
	displayLabel,
	source,
	forceNote,
	clearSourceTitle,
	preferredResultID,
	skipTip)
	if self:IsEnabled() ~= true then
		return false
	end
	local leader = normalizeLeader(leaderName)
	if leader == nil then
		return false
	end
	local resolvedSource = source or SOURCE_BLOCK_LEADER
	local options = {
		displayLabel = displayLabel,
		source = resolvedSource,
		forceNote = forceNote == true,
		skipTip = skipTip == true,
		clearSourceTitle = clearSourceTitle == true
			or sourceCreatesStandaloneLeader(resolvedSource),
	}
	local row = addEntry(self, "leader", leader, nil, note, options)
	if row ~= nil then
		self.leaders[leader] = true
		self:RefreshAfterBlock(preferredResultID)
		return true
	end
	return false
end

function BL:AddTitle(title, leaderName, note, displayLabel, preferredResultID)
	if self:IsEnabled() ~= true or type(title) ~= "string" or title == "" then
		return false
	end
	local leader = normalizeLeader(leaderName)
	beginManualTitleNotice(self, "title")
	local titleOptions = {
		displayLabel = displayLabel,
		source = SOURCE_BLOCK_TITLE,
	}
	local row = addEntry(
		self,
		"title",
		leader or title,
		title,
		note,
		titleOptions)
	if row == nil then
		self._manualTitleNotice = nil
		return false
	end
	titleEvidence:RememberRenderableToken(title)
	if leader ~= nil then
		self.leaders[leader] = true
		self:LinkLeaderToBlockedTitle(leader, title, {
			skipTip = true,
			source = SOURCE_BLOCK_TITLE,
			note = note or NOTE_SAME_TITLE,
			forceNote = true,
		})
	end
	self:BeginScanTipBatch()
	self:IndexVisibleLeadersForBlockedTitle(title, displayLabel)
	publishManualTitleNotice(self)
	self:RefreshAfterBlock(preferredResultID)
	self:EndScanTipBatch()
	return true
end

function BL:AddTitleLeader(title, leaderName, displayLabel, preferredResultID)
	local label = isStableTitleLabelText(displayLabel)
	return self:AddTitle(
		title, leaderName, NOTE_SAME_TITLE, label, preferredResultID)
end

function BL:AddAdvertisementLeader(leaderName, preferredResultID)
	return self:AddLeaderWithSource(
		leaderName,
		NOTE_ADVERTISEMENT,
		nil,
		SOURCE_REPORT_AD,
		true,
		false,
		preferredResultID,
		true)
end

function BL:TipUnreadableTitleFallback()
	local L = GF.L or {}
	self:Tip(L.BLOCK_TIP_TITLE_UNREADABLE_FALLBACK or "Group title is unreadable; only the current leader was blocked.")
end

function BL:BlockLeaderFromSearchResult(resultID, info)
	if not info and resultID and C_LFGList and C_LFGList.GetSearchResultInfo then
		info = getSearchResultInfo(resultID)
	end
	local leaderName = info and info.leaderName
	local added = self:AddLeaderWithSource(
		leaderName,
		NOTE_MANUAL,
		nil,
		SOURCE_BLOCK_LEADER,
		true,
		false,
		resultID,
		true)
	if added then
		local locale = GF.L or {}
		local fmt = locale.BLOCK_TIP_LEADER_ADDED_FMT
			or "已将 %s 加入黑名单。"
		self:Tip(string.format(fmt, colorLeaderName(leaderName)))
	end
	return added
end

function BL:BlockSameTitleFromSearchResult(resultID, info, displayTitle)
	if not info and resultID and C_LFGList and C_LFGList.GetSearchResultInfo then
		info = getSearchResultInfo(resultID)
	end
	if not info then
		return false
	end
	local leaderName = info.leaderName
	if not normalizeLeader(leaderName) then
		return false
	end
	local title, titleInfo = resolveReliableTitle(resultID, info, displayTitle, true)
	local displayLabel = title and resolveStableDisplayTitle(resultID, info, displayTitle, titleInfo)
	if title then
		return self:AddTitleLeader(title, leaderName, displayLabel, resultID)
	end

	local added = self:AddLeaderWithSource(
		leaderName,
		NOTE_MANUAL,
		nil,
		SOURCE_BLOCK_LEADER,
		true,
		false,
		resultID)
	self:TipUnreadableTitleFallback()
	return added
end

function BL:BlockAdvertisementFromSearchResult(resultID, info)
	local resultInfo = info
	if resultInfo == nil and resultID ~= nil
		and C_LFGList and type(C_LFGList.GetSearchResultInfo) == "function"
	then
		resultInfo = getSearchResultInfo(resultID)
	end
	return self:AddAdvertisementLeader(
		resultInfo and resultInfo.leaderName, resultID)
end

function BL:TipAdvertisementReport(leaderName, addedToBlocklist)
	local locale = GF.L or {}
	local submitted = locale.BLOCK_TIP_REPORT_AD_SUBMITTED
		or "已向暴雪提交广告举报！"
	local message = colorStatusText(
		submitted,
		getSystemMessageColorCode())
	local normalizedLeader = normalizeLeader(leaderName)
	if addedToBlocklist == true and normalizedLeader ~= nil then
		local addedFmt = locale.BLOCK_TIP_REPORT_AD_BLOCKED_FMT
			or "同时已将 %s 加入黑名单。"
		message = message .. string.format(
			addedFmt, colorAdvertisementLeaderName(normalizedLeader))
	end
	self:Tip(message)
end

function BL:Tip(msg)
	local db = GF.GetDB()
	if db.showBlacklistChatNotice == false or msg == nil then
		return
	end
	if type(GF.ShowStatusMessage) == "function" then
		GF.ShowStatusMessage(msg)
	elseif type(print) == "function" then
		print(msg)
	end
end

function BL:GetBlacklistNoteManual()
	return NOTE_MANUAL
end

function BL:GetEntryReason(entry)
	local note = tostring(entry and entry.note or "")
	local source = tostring(entry and entry.source or "")
	local sourceLower = string.lower(source)
	if entry and entry.kind == "title" then
		return "title_parent"
	end
	if sourceLower:find("report_ad", 1, true) then
		return "ad"
	end
	if sourceLower:find("block_title", 1, true)
	then
		return "same_title_ad"
	end
	if source == SOURCE_MANUAL or source == SOURCE_BLOCK_LEADER then
		return "manual"
	end
	-- 无 source 或未知 source 的历史/导入数据按旧规则兼容推断。
	if note == NOTE_ADVERTISEMENT then
		return "ad"
	end
	if entry and entry.sourceTitle and entry.sourceTitle ~= ""
		or note:find("同标题", 1, true)
		or note:find("标题", 1, true)
		or note:find("传染", 1, true)
	then
		return "same_title_ad"
	end
	return "manual"
end

function BL:GetEntryReasonText(reason)
	local L = GF.L or {}
	if reason == "ad" then
		return L.BLOCKLIST_REASON_AD or "广告"
	end
	if reason == "title_parent" then
		return L.BLOCKLIST_REASON_TITLE_PARENT or "标题父项"
	end
	if reason == "same_title_ad" then
		return L.BLOCKLIST_REASON_TITLE or "标题传染"
	end
	return L.BLOCKLIST_REASON_MANUAL or "手动添加"
end

function BL:GetEntrySourceText(reason)
	local L = GF.L or {}
	if reason == "ad" then
		return L.BLOCKLIST_SOURCE_REPORT_AD or "右键举报广告"
	end
	if reason == "title_parent" then
		return L.BLOCKLIST_SOURCE_TITLE_PARENT or "同标题批处理父项"
	end
	if reason == "same_title_ad" then
		return L.BLOCKLIST_SOURCE_TITLE or "屏蔽同标题广告来源"
	end
	return L.BLOCKLIST_SOURCE_MANUAL or "加入黑名单"
end

local function compareBlacklistEntryByUpdated(left, right)
	local leftUpdated = tonumber(left and (left.updatedAt or left.addedAt)) or 0
	local rightUpdated = tonumber(right and (right.updatedAt or right.addedAt)) or 0
	if leftUpdated ~= rightUpdated then
		return leftUpdated > rightUpdated
	end
	return tostring(left and (left.displayName or left.name or left.key) or "") < tostring(right and (right.displayName or right.name or right.key) or "")
end

function BL:GetBlacklistEntries()
	local entries = {}
	for index, row in ipairs(GF.GetDB().blocklist or {}) do
		if row.kind == "title" and row.title then
			local key = row.key or ("title:" .. tostring(row.title))
			row.key = key
			local updatedAt = parseEntryTimestamp(row)
			if updatedAt <= 0 then
				updatedAt = getNow()
			end
			row.addedAt = tonumber(row.addedAt) or updatedAt
			row.updatedAt = tonumber(row.updatedAt) or updatedAt
			row.time = row.time or formatEntryTime(updatedAt)
			local children = getTitleChildren(GF.GetDB().blocklist, row)
			local leader = normalizeLeader(row.leader) or self:GetDisplayText(row, "parent")
			local entry = {
				key = key,
				name = leader,
				displayName = leader,
				note = self:FormatEntryNote(row, #children),
				source = row.source or "",
				addedAt = tonumber(row.addedAt) or updatedAt,
				updatedAt = tonumber(row.updatedAt) or updatedAt,
				_row = row,
			}
			entry.reason = self:GetEntryReason(row)
			entries[#entries + 1] = entry
		elseif row.kind == "leader" then
			local leader = normalizeLeader(row.leader)
			if leader and not isTitleParentLeader(GF.GetDB(), row.sourceTitle, leader) then
				local key = row.key or buildLeaderKey(leader) or ("leader:" .. tostring(index))
				local name, realm = splitLeaderRealm(leader)
				row.key = key
				local updatedAt = parseEntryTimestamp(row)
				if updatedAt <= 0 then
					updatedAt = getNow()
				end
				row.addedAt = tonumber(row.addedAt) or updatedAt
				row.updatedAt = tonumber(row.updatedAt) or updatedAt
				row.time = row.time or formatEntryTime(updatedAt)
				local entry = {
					key = key,
					name = name or leader,
					realm = realm,
					displayName = leader,
					note = self:FormatEntryNote(row),
					source = row.source or "",
					addedAt = tonumber(row.addedAt) or updatedAt,
					updatedAt = tonumber(row.updatedAt) or updatedAt,
					sourceTitle = row.sourceTitle,
					_row = row,
				}
				entry.reason = self:GetEntryReason(row)
				entries[#entries + 1] = entry
			end
		end
	end
	table.sort(entries, compareBlacklistEntryByUpdated)
	return entries
end

local function findRowByKey(db, key)
	if not key then
		return nil
	end
	for index, row in ipairs(db.blocklist or {}) do
		local rowKey = row.key or (row.kind == "leader" and buildLeaderKey(row.leader)) or ("row:" .. tostring(index))
		if rowKey == key then
			row.key = rowKey
			return row, index
		end
	end
	return nil
end

function BL:SetBlacklistNote(entryOrKey, note)
	local row = type(entryOrKey) == "table" and (entryOrKey._row or entryOrKey) or findRowByKey(GF.GetDB(), entryOrKey)
	if not row then
		return false
	end
	row.note = tostring(note or "")
	setRowUpdateFields(row, {})
	self:RefreshAfterBlock()
	return true
end

function BL:RemovePlayerFromBlacklist(entryOrKey)
	local db = GF.GetDB()
	local target = type(entryOrKey) == "table"
		and (entryOrKey._row or entryOrKey) or nil
	if target == nil then
		target = findRowByKey(db, entryOrKey)
	end
	if target == nil then
		return false
	end
	local titleToRemove = target.kind == "title" and target.title or nil
	local kept = {}
	local found = false
	for _, row in ipairs(db.blocklist or {}) do
		local remove = row == target
			or (titleToRemove ~= nil and row.sourceTitle == titleToRemove)
		if remove then
			found = found or row == target
		else
			kept[#kept + 1] = row
		end
	end
	if not found then
		return false
	end
	db.blocklist = kept
	self:RebuildMaps()
	self:RefreshAfterBlock()
	return true, target
end
